diff --git a/.htaccess b/.htaccess
index 2c2b8ae..d2f4cfb 100644
--- a/.htaccess
+++ b/.htaccess
@@ -36,6 +36,7 @@ RewriteRule ^home/modalidades/?$ site/modalidades.php [L,QSA]
RewriteRule ^home/horario/?$ site/horario.php [L,QSA]
RewriteRule ^home/contactos/?$ site/contactos.php [L,QSA]
RewriteRule ^home/agendar/?$ site/agendar.php [L,QSA]
+RewriteRule ^home/inscricao/?$ site/inscricao.php [L,QSA]
RewriteRule ^home/crossfit/?$ site/crossfit.php [L,QSA]
RewriteRule ^home/privacidade/?$ site/privacidade.php [L,QSA]
RewriteRule ^evento/([a-zA-Z0-9-]+)/?$ site/evento.php?slug=$1 [L,QSA]
diff --git a/config/ci_compat.php b/config/ci_compat.php
index 0a4d0f2..485f026 100644
--- a/config/ci_compat.php
+++ b/config/ci_compat.php
@@ -9,11 +9,19 @@ if (!defined('SITE_INCLUDES')) {
}
// ' . "\n";
+ return '' . "\n";
}
function link_tag(string $href, string $rel = 'stylesheet'): string {
- return ' ' . "\n";
+ return ' ' . "\n";
}
function site_url(string $path = ''): string { return base_url($path); }
diff --git a/config/functions.php b/config/functions.php
index 4551269..ee54c12 100644
--- a/config/functions.php
+++ b/config/functions.php
@@ -31,6 +31,9 @@ require_once __DIR__ . '/functions_galerias.php'; // galleries + images CRU
require_once __DIR__ . '/functions_work_hours.php'; // work hours CRUD
require_once __DIR__ . '/functions_horario.php'; // class schedule CRUD
require_once __DIR__ . '/functions_events.php'; // time-limited events + landing pages + leads
+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
// ─── DB-backed settings → legacy constants ───────────────────────────────────
// Resolved once from the settings cache so call sites read plain constants.
diff --git a/config/functions_info.php b/config/functions_info.php
new file mode 100644
index 0000000..e5eaade
--- /dev/null
+++ b/config/functions_info.php
@@ -0,0 +1,58 @@
+ [label, max length, input type]. Order is the form order.
+function info_fields(): array {
+ return [
+ 'name' => ['Nome do ginásio', 200, 'text'],
+ 'adress' => ['Morada', 200, 'text'],
+ 'phone' => ['Telefone principal', 25, 'tel'],
+ 'telephone' => ['Telefone secundário', 25, 'tel'],
+ 'whatsapp' => ['WhatsApp (só dígitos, ex.: 351964201137)', 13, 'text'],
+ 'email' => ['E-mail', 100, 'email'],
+ 'website' => ['Website', 100, 'text'],
+ 'instagram' => ['Instagram (URL)', 200, 'url'],
+ 'facebook' => ['Facebook (URL)', 200, 'url'],
+ 'messenger' => ['Messenger (URL)', 200, 'url'],
+ 'google_maps_link' => ['Google Maps — link', 255, 'url'],
+ 'google_maps' => ['Google Maps — código embed (iframe)', 0, 'textarea'],
+ ];
+}
+
+function info_from_post(array $post, array &$errors): array {
+ $data = [];
+ foreach (info_fields() as $key => [$label, $max, $type]) {
+ $v = trim((string) ($post[$key] ?? ''));
+ if ($key === 'whatsapp') $v = preg_replace('/\D+/', '', $v);
+ if ($max > 0 && mb_strlen($v) > $max) $errors[] = "«{$label}» tem mais de {$max} caracteres.";
+ if ($v !== '' && $type === 'email' && !filter_var($v, FILTER_VALIDATE_EMAIL)) $errors[] = 'O e-mail não é válido.';
+ if ($v !== '' && $type === 'url' && !preg_match('~^https?://~i', $v)) $errors[] = "«{$label}» tem de começar por https://";
+ $data[$key] = $v;
+ }
+ // Only a Google Maps iframe survives; anything else is dropped here rather
+ // than stored and filtered on every page view.
+ $data['google_maps'] = clean_map_embed($data['google_maps']);
+ if ($data['name'] === '') $errors[] = 'O nome é obrigatório.';
+ if ($data['phone'] === '') $errors[] = 'O telefone principal é obrigatório.';
+ return $data;
+}
+
+function db_info_save(PDO $pdo, array $data): bool {
+ $cols = array_keys(info_fields());
+ $exists = (bool) $pdo->query("SELECT COUNT(*) FROM fitme3070_info WHERE id = 1")->fetchColumn();
+ $vals = array_map(fn($c) => $data[$c] ?? '', $cols);
+ if ($exists) {
+ $sets = implode(', ', array_map(fn($c) => "`$c` = ?", $cols));
+ $stmt = $pdo->prepare("UPDATE fitme3070_info SET $sets, updated_at = NOW() WHERE id = 1");
+ } else {
+ $names = implode(', ', array_map(fn($c) => "`$c`", $cols));
+ $marks = implode(', ', array_fill(0, count($cols), '?'));
+ $stmt = $pdo->prepare("INSERT INTO fitme3070_info (id, $names, updated_at) VALUES (1, $marks, NOW())");
+ }
+ $stmt->execute($vals);
+ return $stmt->rowCount() > 0;
+}
diff --git a/config/functions_memberships.php b/config/functions_memberships.php
new file mode 100644
index 0000000..e18db5f
--- /dev/null
+++ b/config/functions_memberships.php
@@ -0,0 +1,191 @@
+ '', 'subtitle' => '', 'price' => '', 'is_addon' => 0,
+ 'price_suffix' => '/mês', 'features' => '', 'is_featured' => 0,
+ 'featured_label' => 'Mais popular', 'cta_label' => 'Inscrever',
+ 'item_order' => 0, 'is_active' => 1,
+ ];
+}
+
+function db_memberships_list(PDO $pdo, bool $include_deleted = false): array {
+ $sql = "SELECT * FROM memberships";
+ if (!$include_deleted) $sql .= " WHERE deleted_at IS NULL";
+ $sql .= " ORDER BY deleted_at IS NOT NULL, item_order, name";
+ return $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
+}
+
+// Public list: active, not deleted. Defensive — the table may not exist yet on
+// an install that hasn't run the migration.
+function site_memberships(PDO $pdo): array {
+ try {
+ return $pdo->query(
+ "SELECT * FROM memberships WHERE deleted_at IS NULL AND is_active = 1 ORDER BY item_order, name"
+ )->fetchAll(PDO::FETCH_ASSOC);
+ } catch (Throwable $e) { return []; }
+}
+
+function db_memberships_get(PDO $pdo, int $id): ?array {
+ $stmt = $pdo->prepare("SELECT * FROM memberships WHERE id = ? LIMIT 1");
+ $stmt->execute([$id]);
+ return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
+}
+
+// Normalise raw POST into the row shape; appends messages to $errors.
+function memberships_from_post(array $post, array &$errors): array {
+ $price = str_replace([' ', '€'], '', trim((string) ($post['price'] ?? '')));
+ $price = str_replace(',', '.', $price);
+ $data = [
+ 'name' => mb_substr(trim((string) ($post['name'] ?? '')), 0, 100),
+ 'subtitle' => mb_substr(trim((string) ($post['subtitle'] ?? '')), 0, 255),
+ 'price' => $price,
+ 'is_addon' => !empty($post['is_addon']) ? 1 : 0,
+ 'price_suffix' => mb_substr(trim((string) ($post['price_suffix'] ?? '')), 0, 60),
+ 'features' => trim((string) ($post['features'] ?? '')),
+ 'is_featured' => !empty($post['is_featured']) ? 1 : 0,
+ 'featured_label' => mb_substr(trim((string) ($post['featured_label'] ?? '')), 0, 40),
+ 'cta_label' => mb_substr(trim((string) ($post['cta_label'] ?? '')), 0, 40),
+ 'item_order' => (int) ($post['item_order'] ?? 0),
+ 'is_active' => !empty($post['is_active']) ? 1 : 0,
+ ];
+ if ($data['name'] === '') $errors[] = 'O nome é obrigatório.';
+ if ($price !== '' && (!is_numeric($price) || (float) $price < 0)) $errors[] = 'Preço inválido — usa um número, ex.: 34,90.';
+ if ($data['featured_label'] === '') $data['featured_label'] = 'Mais popular';
+ if ($data['cta_label'] === '') $data['cta_label'] = 'Inscrever';
+ return $data;
+}
+
+function _memberships_params(array $d): array {
+ return [
+ $d['name'], $d['subtitle'],
+ $d['price'] === '' || $d['price'] === null ? null : number_format((float) $d['price'], 2, '.', ''),
+ $d['is_addon'], $d['price_suffix'], $d['features'] !== '' ? $d['features'] : null,
+ $d['is_featured'], $d['featured_label'], $d['cta_label'], $d['item_order'], $d['is_active'],
+ ];
+}
+
+function _memberships_clear_featured(PDO $pdo, int $except): void {
+ $pdo->prepare("UPDATE memberships SET is_featured = 0 WHERE id <> ? AND is_featured = 1")->execute([$except]);
+}
+
+function db_memberships_create(PDO $pdo, array $d): int {
+ $pdo->beginTransaction();
+ $stmt = $pdo->prepare("
+ INSERT INTO memberships (name, subtitle, price, is_addon, price_suffix, features,
+ is_featured, featured_label, cta_label, item_order, is_active,
+ created_at, updated_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())
+ ");
+ $stmt->execute(_memberships_params($d));
+ $id = (int) $pdo->lastInsertId();
+ if ($d['is_featured']) _memberships_clear_featured($pdo, $id);
+ $pdo->commit();
+ return $id;
+}
+
+function db_memberships_update(PDO $pdo, int $id, array $d): bool {
+ $pdo->beginTransaction();
+ $stmt = $pdo->prepare("
+ UPDATE memberships SET name = ?, subtitle = ?, price = ?, is_addon = ?, price_suffix = ?,
+ features = ?, is_featured = ?, featured_label = ?, cta_label = ?, item_order = ?,
+ is_active = ?, updated_at = NOW()
+ WHERE id = ?
+ ");
+ $params = _memberships_params($d);
+ $params[] = $id;
+ $stmt->execute($params);
+ $changed = $stmt->rowCount() > 0;
+ if ($d['is_featured']) _memberships_clear_featured($pdo, $id);
+ $pdo->commit();
+ return $changed;
+}
+
+function db_memberships_delete(PDO $pdo, int $id): bool {
+ $stmt = $pdo->prepare("UPDATE memberships SET deleted_at = NOW(), is_featured = 0 WHERE id = ? AND deleted_at IS NULL");
+ $stmt->execute([$id]);
+ return $stmt->rowCount() > 0;
+}
+
+function db_memberships_restore(PDO $pdo, int $id): bool {
+ $stmt = $pdo->prepare("UPDATE memberships SET deleted_at = NULL WHERE id = ? AND deleted_at IS NOT NULL");
+ $stmt->execute([$id]);
+ return $stmt->rowCount() > 0;
+}
+
+// "34,90 €" / "+10 €" / "Sob consulta" — PT formatting, drops ",00".
+function membership_price_label(array $m): string {
+ if ($m['price'] === null || $m['price'] === '') return 'Sob consulta';
+ $p = (float) $m['price'];
+ $s = number_format($p, floor($p) == $p ? 0 : 2, ',', ' ');
+ return (!empty($m['is_addon']) ? '+' : '') . $s . ' €';
+}
+
+// Non-empty trimmed lines of a textarea — used for features, skills, formações.
+function text_lines(?string $text): array {
+ $out = [];
+ foreach (preg_split('/\R/', (string) $text) as $line) {
+ $line = trim($line, " \t-•");
+ if ($line !== '') $out[] = $line;
+ }
+ return $out;
+}
+
+// ─── Pedidos de inscrição (leads) ─────────────────────────────────────────────
+
+function membership_lead_statuses(): array {
+ return ['novo' => 'Novo', 'contactado' => 'Contactado', 'inscrito' => 'Inscrito', 'descartado' => 'Descartado'];
+}
+
+function db_membership_leads_list(PDO $pdo, string $status = ''): array {
+ $sql = "SELECT * FROM membership_leads";
+ $args = [];
+ if ($status !== '' && isset(membership_lead_statuses()[$status])) { $sql .= " WHERE status = ?"; $args[] = $status; }
+ $sql .= " ORDER BY created_at DESC, id DESC";
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute($args);
+ return $stmt->fetchAll(PDO::FETCH_ASSOC);
+}
+
+function db_membership_leads_count_new(PDO $pdo): int {
+ try { return (int) $pdo->query("SELECT COUNT(*) FROM membership_leads WHERE status = 'novo'")->fetchColumn(); }
+ catch (Throwable $e) { return 0; }
+}
+
+function db_membership_lead_create(PDO $pdo, array $d): int {
+ $stmt = $pdo->prepare("
+ INSERT INTO membership_leads (membership_id, membership_name, name, email, phone, message,
+ consent, consent_text, ip, user_agent, created_at, updated_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?, NOW(), NOW())
+ ");
+ $stmt->execute([
+ $d['membership_id'] ?: null,
+ mb_substr((string) ($d['membership_name'] ?? ''), 0, 100),
+ mb_substr(trim((string) ($d['name'] ?? '')), 0, 150),
+ mb_substr(trim((string) ($d['email'] ?? '')), 0, 190),
+ mb_substr(trim((string) ($d['phone'] ?? '')), 0, 40),
+ mb_substr(trim((string) ($d['message'] ?? '')), 0, 1000),
+ !empty($d['consent']) ? 1 : 0,
+ mb_substr((string) ($d['consent_text'] ?? ''), 0, 255),
+ mb_substr((string) ($d['ip'] ?? ''), 0, 45),
+ mb_substr((string) ($d['user_agent'] ?? ''), 0, 255),
+ ]);
+ return (int) $pdo->lastInsertId();
+}
+
+function db_membership_lead_update(PDO $pdo, int $id, string $status, string $notes): bool {
+ if (!isset(membership_lead_statuses()[$status])) return false;
+ $stmt = $pdo->prepare("UPDATE membership_leads SET status = ?, admin_notes = ?, updated_at = NOW() WHERE id = ?");
+ $stmt->execute([$status, $notes !== '' ? $notes : null, $id]);
+ return $stmt->rowCount() > 0;
+}
+
+function db_membership_lead_delete(PDO $pdo, int $id): bool {
+ $stmt = $pdo->prepare("DELETE FROM membership_leads WHERE id = ?");
+ $stmt->execute([$id]);
+ return $stmt->rowCount() > 0;
+}
diff --git a/config/functions_reviews.php b/config/functions_reviews.php
new file mode 100644
index 0000000..edc7413
--- /dev/null
+++ b/config/functions_reviews.php
@@ -0,0 +1,200 @@
+ setting_secret('google_places_api_key', 'GOOGLE_PLACES_API_KEY'),
+ 'place_id' => trim((string) setting('google_place_id', '')),
+ 'min_rating' => max(1, min(5, (int) setting('google_reviews_min_rating', 4))),
+ 'max' => max(1, min(5, (int) setting('google_reviews_max', 3))),
+ 'cache_hours' => max(1, (int) setting('google_reviews_cache_hours', 24)),
+ ];
+}
+
+function reviews_configured(): bool {
+ $c = reviews_config();
+ return $c['api_key'] !== '' && $c['place_id'] !== '';
+}
+
+function _reviews_cache_path(): string {
+ return __DIR__ . '/cache/google_reviews.cache.php';
+}
+
+function reviews_cache_read(): ?array {
+ $path = _reviews_cache_path();
+ if (!is_file($path)) return null;
+ $data = @include $path;
+ return is_array($data) ? $data : null;
+}
+
+function _reviews_cache_write(array $data): void {
+ $path = _reviews_cache_path();
+ if (!is_dir(dirname($path))) @mkdir(dirname($path), 0775, true);
+ $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
+ if (@file_put_contents($tmp, " $method,
+ CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_CONNECTTIMEOUT => 3,
+ CURLOPT_TIMEOUT => 5,
+ ]);
+ $raw = curl_exec($ch);
+ $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $cerr = curl_error($ch);
+ curl_close($ch);
+ if ($raw === false) return [0, null, 'Sem ligação à Google: ' . $cerr];
+ $json = json_decode((string) $raw, true);
+ if ($status !== 200) {
+ $msg = is_array($json) ? (string) ($json['error']['message'] ?? '') : '';
+ return [$status, $json, 'Google respondeu ' . $status . ($msg !== '' ? ': ' . $msg : '')];
+ }
+ return [$status, is_array($json) ? $json : null, is_array($json) ? '' : 'Resposta inválida da Google.'];
+}
+
+// Fetch Place Details now and rewrite the cache. Returns the cache array.
+function reviews_refresh(): array {
+ $c = reviews_config();
+ $old = reviews_cache_read() ?? [];
+ if ($c['api_key'] === '' || $c['place_id'] === '') {
+ return $old + ['error' => 'Falta a chave da API ou o Place ID.'];
+ }
+ $url = 'https://places.googleapis.com/v1/places/' . rawurlencode($c['place_id']) . '?languageCode=pt-PT';
+ [$status, $json, $err] = _places_request('GET', $url, $c['api_key'], REVIEWS_FIELD_MASK);
+
+ 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);
+ _reviews_cache_write($data);
+ error_log('[reviews] ' . $err);
+ return $data;
+ }
+
+ $reviews = [];
+ foreach ((array) ($json['reviews'] ?? []) as $r) {
+ $reviews[] = [
+ 'rating' => (int) ($r['rating'] ?? 0),
+ 'text' => (string) ($r['text']['text'] ?? $r['originalText']['text'] ?? ''),
+ 'relative' => (string) ($r['relativePublishTimeDescription'] ?? ''),
+ 'published' => (string) ($r['publishTime'] ?? ''),
+ 'author' => (string) ($r['authorAttribution']['displayName'] ?? ''),
+ 'author_uri' => (string) ($r['authorAttribution']['uri'] ?? ''),
+ 'author_photo' => (string) ($r['authorAttribution']['photoUri'] ?? ''),
+ 'uri' => (string) ($r['googleMapsUri'] ?? ''),
+ ];
+ }
+ $data = [
+ 'fetched_at' => time(),
+ 'name' => (string) ($json['displayName']['text'] ?? ''),
+ 'rating' => isset($json['rating']) ? (float) $json['rating'] : null,
+ 'total' => (int) ($json['userRatingCount'] ?? 0),
+ 'maps_uri' => (string) ($json['googleMapsUri'] ?? ''),
+ 'reviews' => $reviews,
+ 'error' => '',
+ ];
+ _reviews_cache_write($data);
+ return $data;
+}
+
+// Cached data, refreshed when older than the cache window. The refresh is
+// serialised with a non-blocking lock so a burst of visitors at expiry makes
+// one Google call, not one each — the others just serve the stale copy.
+function reviews_get(): array {
+ $c = reviews_config();
+ $cache = reviews_cache_read();
+ $fresh = $cache && (time() - (int) ($cache['fetched_at'] ?? 0)) < $c['cache_hours'] * 3600;
+ if ($fresh || !reviews_configured()) return $cache ?? [];
+
+ $lock = @fopen(_reviews_cache_path() . '.lock', 'c');
+ if ($lock && flock($lock, LOCK_EX | LOCK_NB)) {
+ $cache = reviews_refresh();
+ flock($lock, LOCK_UN);
+ }
+ if ($lock) fclose($lock);
+ return $cache ?? [];
+}
+
+// What the homepage renders: summary + the best qualifying reviews, or null
+// when there is nothing worth showing (not configured, no qualifying review).
+function site_reviews(): ?array {
+ try { $data = reviews_get(); } catch (Throwable $e) { error_log('[reviews] ' . $e->getMessage()); return null; }
+ if (!$data || empty($data['reviews'])) return null;
+ $c = reviews_config();
+ $picked = array_values(array_filter($data['reviews'], fn($r) => $r['rating'] >= $c['min_rating'] && trim($r['text']) !== ''));
+ // Highest rating first; Google's relevance order breaks ties (usort is stable).
+ usort($picked, fn($a, $b) => $b['rating'] <=> $a['rating']);
+ $picked = array_slice($picked, 0, $c['max']);
+ if (!$picked) return null;
+ return [
+ 'rating' => $data['rating'],
+ 'total' => $data['total'],
+ 'maps_uri' => $data['maps_uri'],
+ 'write_uri' => 'https://search.google.com/local/writereview?placeid=' . rawurlencode($c['place_id']),
+ 'reviews' => $picked,
+ ];
+}
+
+// Backoffice helper: find the Place ID from a free-text query (Text Search,
+// IDs + name + address only — the cheapest field set).
+function reviews_find_place(string $query, string $apiKey): array {
+ [$status, $json, $err] = _places_request(
+ 'POST', 'https://places.googleapis.com/v1/places:searchText', $apiKey,
+ 'places.id,places.displayName,places.formattedAddress,places.rating,places.userRatingCount',
+ ['textQuery' => $query, 'languageCode' => 'pt-PT', 'regionCode' => 'PT']
+ );
+ if ($err !== '') return ['error' => $err, 'places' => []];
+ $out = [];
+ foreach ((array) ($json['places'] ?? []) as $p) {
+ $out[] = [
+ 'id' => (string) ($p['id'] ?? ''),
+ 'name' => (string) ($p['displayName']['text'] ?? ''),
+ 'address' => (string) ($p['formattedAddress'] ?? ''),
+ 'rating' => $p['rating'] ?? null,
+ 'total' => (int) ($p['userRatingCount'] ?? 0),
+ ];
+ }
+ return ['error' => '', 'places' => $out];
+}
diff --git a/config/functions_site.php b/config/functions_site.php
index 5725525..b32b669 100644
--- a/config/functions_site.php
+++ b/config/functions_site.php
@@ -30,6 +30,7 @@ function site_navbar(): array {
'Equipa' => '#equipa',
'Modalidades' => '#modalidades',
'Horário' => '#horario',
+ 'Mensalidades' => '#mensalidades',
'Contactos' => '#contactos',
'CrossFit' => 'home/crossfit', // separate page, navbar styles it specially
];
diff --git a/config/functions_team.php b/config/functions_team.php
index f7737ba..3adf515 100644
--- a/config/functions_team.php
+++ b/config/functions_team.php
@@ -18,28 +18,35 @@ function db_team_get(PDO $pdo, int $id): ?array {
function db_team_create(PDO $pdo, array $data): int {
$stmt = $pdo->prepare("
- INSERT INTO team (name, birthday, `at`, status, description, photo, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())
+ INSERT INTO team (name, title, birthday, `at`, status, description, skills, certifications, photo, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
");
$stmt->execute([
$data['name'],
+ $data['title'] ?? '',
$data['birthday'] ?: null,
$data['at'] ?: null,
$data['status'] ?? 'ativo',
$data['description'] ?? null,
+ ($data['skills'] ?? '') !== '' ? $data['skills'] : null,
+ ($data['certifications'] ?? '') !== '' ? $data['certifications'] : null,
$data['photo'] ?? null,
]);
return (int) $pdo->lastInsertId();
}
function db_team_update(PDO $pdo, int $id, array $data, ?string $photo = null): bool {
- $sets = ["name = ?", "birthday = ?", "`at` = ?", "status = ?", "description = ?", "updated_at = NOW()"];
+ $sets = ["name = ?", "title = ?", "birthday = ?", "`at` = ?", "status = ?", "description = ?",
+ "skills = ?", "certifications = ?", "updated_at = NOW()"];
$params = [
$data['name'],
+ $data['title'] ?? '',
$data['birthday'] ?: null,
$data['at'] ?: null,
$data['status'] ?? 'ativo',
$data['description'] ?? null,
+ ($data['skills'] ?? '') !== '' ? $data['skills'] : null,
+ ($data['certifications'] ?? '') !== '' ? $data['certifications'] : null,
];
if ($photo !== null) {
$sets[] = "photo = ?";
diff --git a/database/migration_20260924_info_main_phone.sql b/database/migration_20260924_info_main_phone.sql
new file mode 100644
index 0000000..bd7bd07
--- /dev/null
+++ b/database/migration_20260924_info_main_phone.sql
@@ -0,0 +1,8 @@
+-- ─── Contactos: novo número principal ────────────────────────────────────────
+-- 964 201 137 passa a ser o telefone principal (phone), substituindo o número
+-- anterior, que sai do site. O telefone secundário (telephone) não muda.
+-- A partir daqui os contactos editam-se no backoffice (Gestão → Contactos).
+
+UPDATE `fitme3070_info`
+ SET `phone` = '964 201 137', `updated_at` = NOW()
+ WHERE `id` = 1;
diff --git a/database/migration_20260924_memberships.sql b/database/migration_20260924_memberships.sql
new file mode 100644
index 0000000..a015b79
--- /dev/null
+++ b/database/migration_20260924_memberships.sql
@@ -0,0 +1,60 @@
+-- ─── Mensalidades + pedidos de inscrição ────────────────────────────────────
+-- memberships: cartões de mensalidade editáveis no backoffice. Um só pode estar
+-- em destaque (garantido na aplicação). is_addon = acresce à mensalidade base
+-- (mostra "+X €").
+-- membership_leads: pedidos do modal "Inscrever". Guarda o consentimento RGPD
+-- (texto + IP + UA), o estado de acompanhamento e, reservado para a futura
+-- integração com o CRM do ginásio, crm_ref / crm_synced_at.
+-- Safe to re-run.
+
+CREATE TABLE IF NOT EXISTS `memberships` (
+ `id` INT(11) NOT NULL AUTO_INCREMENT,
+ `name` VARCHAR(100) NOT NULL,
+ `subtitle` VARCHAR(255) NOT NULL DEFAULT '',
+ `price` DECIMAL(8,2) DEFAULT NULL,
+ `is_addon` TINYINT(1) NOT NULL DEFAULT 0,
+ `price_suffix` VARCHAR(60) NOT NULL DEFAULT '/mês',
+ `features` TEXT DEFAULT NULL,
+ `is_featured` TINYINT(1) NOT NULL DEFAULT 0,
+ `featured_label` VARCHAR(40) NOT NULL DEFAULT 'Mais popular',
+ `cta_label` VARCHAR(40) NOT NULL DEFAULT 'Inscrever',
+ `item_order` INT(11) NOT NULL DEFAULT 0,
+ `is_active` TINYINT(1) NOT NULL DEFAULT 1,
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `deleted_at` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_memberships_list` (`deleted_at`, `is_active`, `item_order`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS `membership_leads` (
+ `id` INT(11) NOT NULL AUTO_INCREMENT,
+ `membership_id` INT(11) DEFAULT NULL,
+ `membership_name` VARCHAR(100) NOT NULL DEFAULT '',
+ `name` VARCHAR(150) NOT NULL,
+ `email` VARCHAR(190) NOT NULL DEFAULT '',
+ `phone` VARCHAR(40) NOT NULL DEFAULT '',
+ `message` VARCHAR(1000) NOT NULL DEFAULT '',
+ `status` ENUM('novo','contactado','inscrito','descartado') NOT NULL DEFAULT 'novo',
+ `admin_notes` TEXT DEFAULT NULL,
+ `consent` TINYINT(1) NOT NULL DEFAULT 0,
+ `consent_text` VARCHAR(255) NOT NULL DEFAULT '',
+ `ip` VARCHAR(45) NOT NULL DEFAULT '',
+ `user_agent` VARCHAR(255) NOT NULL DEFAULT '',
+ `crm_ref` VARCHAR(100) DEFAULT NULL,
+ `crm_synced_at` DATETIME DEFAULT NULL,
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_membership_leads_status` (`status`, `created_at`),
+ KEY `idx_membership_leads_crm` (`crm_synced_at`),
+ CONSTRAINT `fk_membership_leads_membership`
+ FOREIGN KEY (`membership_id`) REFERENCES `memberships` (`id`) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Cartão de exemplo (o preço fica por definir no backoffice).
+INSERT INTO `memberships` (`name`, `subtitle`, `price`, `is_addon`, `price_suffix`, `features`, `item_order`)
+SELECT 'Off-hours', 'Mensalidade para as horas baixas do ginásio.', NULL, 1, 'acresce à mensalidade',
+ 'Acesso nas horas de menor afluência\nSala de musculação e cardio', 10
+ FROM DUAL
+ WHERE NOT EXISTS (SELECT 1 FROM `memberships`);
diff --git a/database/migration_20260924_team_title_skills.sql b/database/migration_20260924_team_title_skills.sql
new file mode 100644
index 0000000..7f20f5c
--- /dev/null
+++ b/database/migration_20260924_team_title_skills.sql
@@ -0,0 +1,9 @@
+-- ─── Equipa: título, aptidões/funções e formações ───────────────────────────
+-- O cartão público mostra foto + nome + título; aptidões e formações (uma por
+-- linha) aparecem no cartão de detalhe ao passar o rato / tocar.
+-- Safe to re-run.
+
+ALTER TABLE `team`
+ ADD COLUMN IF NOT EXISTS `title` VARCHAR(100) NOT NULL DEFAULT '' AFTER `name`,
+ ADD COLUMN IF NOT EXISTS `skills` TEXT DEFAULT NULL AFTER `description`,
+ ADD COLUMN IF NOT EXISTS `certifications` TEXT DEFAULT NULL AFTER `skills`;
diff --git a/includes/header.php b/includes/header.php
index 897c448..677de01 100644
--- a/includes/header.php
+++ b/includes/header.php
@@ -130,6 +130,18 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
Horário de Aulas
+
+ Mensalidades
+
+
+ Reviews Google
+
+
+ Contactos
+
diff --git a/site/assets/css/style.css b/site/assets/css/style.css
index 8c4e0e8..289ea87 100644
--- a/site/assets/css/style.css
+++ b/site/assets/css/style.css
@@ -2204,6 +2204,11 @@ section[id] {
margin-bottom: .5rem;
}
+/* Short titles (e.g. "Modalidades") would otherwise sit on the eyebrow's line. */
+.section-title.deco .section-eyebrow {
+ display: block;
+}
+
.section-title.deco h2 {
position: relative;
display: inline-block;
@@ -2913,4 +2918,541 @@ section[id] {
.event-consent a { color: var(--fitme-accent-dark); text-decoration: underline; }
@media (max-width: 575px) {
.event-form-grid { grid-template-columns: 1fr; }
-}
\ No newline at end of file
+}
+
+/* ══════════════════════════════════════════════════════════════════════════
+ Equipa — título + cartão de detalhe horizontal
+ Default card = photo + name + title. The detail card is laid OVER the grid
+ (absolute), so opening it never reflows the team wall. Desktop (fine pointer)
+ opens it on hover / keyboard focus; touch opens it on tap (.is-open, main.js),
+ where it becomes a centred fixed card with its own close button.
+ ══════════════════════════════════════════════════════════════════════════ */
+/* content-visibility:auto implies paint containment, which would clip the
+ overlay at the section edge and trap position:fixed inside the section. */
+#equipa.trainers-section {
+ content-visibility: visible;
+ contain: none;
+ position: relative;
+ z-index: 3; /* hover card may overhang the next section */
+}
+
+/* While a card is open on touch, lift the whole section over the navbar and
+ the mobile CTA bar so the dimmed backdrop covers everything. */
+#equipa.trainers-section.has-open-detail {
+ z-index: 1100;
+}
+
+/* The global scroll-reveal keeps transform:translateY(0) applied (fill
+ forwards), and any transform makes an element the containing block for
+ position:fixed descendants — which would pin the mobile detail card inside
+ its own tile. Reveal the team with opacity only. */
+#equipa.revealed,
+#equipa .trainer-item.revealed {
+ animation-name: fitmeFadeOnly;
+}
+
+@keyframes fitmeFadeOnly {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+.trainer-item {
+ position: relative;
+}
+
+/* The name used to be the tile's last child and got its size from the
+ `.trainer-item>*:last-child` rule; pin it so it stays the same whether or
+ not a title / "Saber mais" follows it. */
+.trainers-section .trainer-item h4 {
+ font-size: .9rem;
+}
+
+/* …and keep that same rule (white, .9rem) off the detail card when it is the
+ last child. */
+.trainers-section .trainer-item>.trainer-detail {
+ color: var(--fitme-text);
+ font-size: 1rem;
+}
+
+.trainers-section .trainer-item p.trainer-title {
+ margin: 0;
+ font-size: .85rem;
+ font-weight: 600;
+ letter-spacing: .04em;
+ color: var(--fitme-accent);
+}
+
+.trainer-item.has-detail {
+ cursor: pointer;
+ outline: none;
+}
+
+.trainer-more {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: .55rem;
+ font-size: .72rem;
+ font-weight: 700;
+ letter-spacing: .12em;
+ text-transform: uppercase;
+ color: var(--fitme-ash);
+ transition: color var(--fitme-transition);
+}
+
+.trainer-more i { font-size: .65rem; }
+
+.trainer-item.has-detail:hover .trainer-more,
+.trainer-item.has-detail:focus-visible .trainer-more,
+.trainer-item.has-detail.is-open .trainer-more {
+ color: var(--fitme-accent);
+}
+
+.trainer-detail {
+ position: absolute;
+ z-index: 30;
+ top: -10px;
+ left: 50%;
+ width: min(560px, calc(100vw - 32px));
+ transform: translateX(calc(-50% + var(--td-shift, 0px))) translateY(8px);
+ display: flex;
+ text-align: left;
+ background: #fff;
+ color: var(--fitme-text);
+ border-radius: 6px;
+ border-left: 4px solid var(--fitme-accent);
+ box-shadow: 0 24px 60px rgba(0, 0, 0, .45);
+ overflow: hidden;
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+ transition: opacity .2s ease, transform .2s ease, visibility 0s linear .2s;
+}
+
+.trainer-item.is-open .trainer-detail {
+ opacity: 1;
+ visibility: visible;
+ pointer-events: auto;
+ transform: translateX(calc(-50% + var(--td-shift, 0px))) translateY(0);
+ transition: opacity .2s ease, transform .2s ease, visibility 0s;
+}
+
+@media (hover: hover) and (pointer: fine) {
+ .trainer-item.has-detail:hover .trainer-detail,
+ .trainer-item.has-detail:focus-within .trainer-detail {
+ opacity: 1;
+ visibility: visible;
+ pointer-events: auto;
+ transform: translateX(calc(-50% + var(--td-shift, 0px))) translateY(0);
+ transition: opacity .2s ease .12s, transform .2s ease .12s, visibility 0s .12s;
+ }
+ .trainer-item.has-detail:hover { z-index: 31; }
+}
+
+.trainer-item.is-open { z-index: 31; }
+
+.td-photo {
+ flex: 0 0 38%;
+ max-width: 210px;
+ background: var(--fitme-dark-2);
+}
+
+.td-photo img {
+ width: 100%;
+ height: 100%;
+ min-height: 220px;
+ object-fit: cover;
+ object-position: top;
+ display: block;
+}
+
+.td-body {
+ flex: 1 1 auto;
+ padding: 18px 22px 18px 20px;
+ max-height: 340px;
+ overflow-y: auto;
+}
+
+.td-name {
+ display: block;
+ font-size: 1.15rem;
+ font-weight: 800;
+ color: var(--fitme-dark);
+ padding-right: 20px;
+}
+
+.td-title {
+ display: block;
+ font-size: .82rem;
+ font-weight: 700;
+ color: var(--fitme-accent-dark);
+ margin-bottom: .6rem;
+}
+
+.td-bio {
+ font-size: .88rem;
+ color: var(--fitme-text-soft);
+ margin-bottom: .6rem;
+}
+
+.td-bio p:last-child { margin-bottom: 0; }
+
+.td-block + .td-block { margin-top: .55rem; }
+
+.td-label {
+ display: block;
+ font-size: .68rem;
+ font-weight: 800;
+ letter-spacing: .14em;
+ text-transform: uppercase;
+ color: var(--fitme-text-muted);
+ margin-bottom: .25rem;
+}
+
+.td-block ul {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.td-block li {
+ position: relative;
+ padding-left: 14px;
+ font-size: .87rem;
+ line-height: 1.45;
+ color: var(--fitme-text);
+}
+
+.td-block li:before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: .55em;
+ width: 6px;
+ height: 6px;
+ border-radius: 1px;
+ background: var(--fitme-accent);
+}
+
+.td-close {
+ position: absolute;
+ top: 6px;
+ right: 10px;
+ z-index: 2;
+ background: transparent;
+ border: 0;
+ font-size: 1.6rem;
+ line-height: 1;
+ color: var(--fitme-text-muted);
+ cursor: pointer;
+ display: none;
+}
+
+/* Touch / small screens: a centred fixed card over a dimmed page. */
+@media (max-width: 767px), (hover: none) {
+ .trainer-detail {
+ position: fixed;
+ top: 50%;
+ left: 16px;
+ right: 16px;
+ width: auto;
+ max-width: 560px;
+ margin: 0 auto;
+ transform: translateY(-46%);
+ max-height: calc(100vh - 48px);
+ }
+ .trainer-item.is-open .trainer-detail { transform: translateY(-50%); }
+ .trainer-item.is-open:after {
+ content: '';
+ position: fixed;
+ inset: 0;
+ z-index: 29;
+ background: rgba(0, 0, 0, .55);
+ }
+ .td-close { display: block; }
+ .td-photo { flex-basis: 34%; }
+ .td-photo img { min-height: 180px; }
+ .td-body { max-height: calc(100vh - 48px); padding: 16px 16px 16px 14px; }
+}
+
+@media (max-width: 339px) {
+ .trainer-detail { flex-direction: column; }
+ .td-photo { max-width: none; flex-basis: auto; }
+ .td-photo img { min-height: 0; height: 160px; }
+}
+
+/* ══════════════════════════════════════════════════════════════════════════
+ Mensalidades — cartões de preço
+ ══════════════════════════════════════════════════════════════════════════ */
+.pricing-section {
+ padding: 90px 0 70px;
+ background: var(--fitme-surface);
+}
+
+.pricing-grid {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ align-items: stretch;
+ gap: 24px;
+}
+
+.price-card {
+ position: relative;
+ flex: 0 1 270px;
+ display: flex;
+ flex-direction: column;
+ background: #fff;
+ border: 1px solid var(--fitme-surface-2);
+ border-radius: 6px;
+ padding: 30px 26px 26px;
+ box-shadow: var(--fitme-shadow);
+ transition: transform var(--fitme-transition), box-shadow var(--fitme-transition);
+}
+
+.price-card:hover {
+ transform: translateY(-4px);
+ box-shadow: var(--fitme-shadow-lg);
+}
+
+.price-card.is-featured {
+ background: var(--fitme-dark-2);
+ border: 2px solid var(--fitme-accent);
+ color: var(--fitme-ash);
+ box-shadow: 0 18px 50px rgba(var(--fitme-dark-rgb), .28);
+}
+
+@media (min-width: 992px) {
+ .price-card.is-featured { transform: scale(1.04); }
+ .price-card.is-featured:hover { transform: scale(1.04) translateY(-4px); }
+}
+
+.pc-badge {
+ position: absolute;
+ top: 0;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ white-space: nowrap;
+ background: var(--fitme-accent);
+ color: var(--fitme-dark);
+ font-size: .72rem;
+ font-weight: 800;
+ letter-spacing: .12em;
+ text-transform: uppercase;
+ padding: 6px 14px;
+ border-radius: 999px;
+}
+
+.pc-badge i { font-size: .65rem; }
+
+.price-card .pc-name {
+ font-size: 1.35rem;
+ font-weight: 900;
+ margin: 0 0 .35rem;
+ color: var(--fitme-dark);
+}
+
+.price-card.is-featured .pc-name { color: #fff; }
+
+.pc-sub {
+ font-size: .9rem;
+ line-height: 1.45;
+ color: var(--fitme-text-muted);
+ margin: 0 0 1rem;
+}
+
+.price-card.is-featured .pc-sub { color: var(--fitme-ash); }
+
+.pc-price {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 4px 8px;
+ padding: .9rem 0 1rem;
+ border-top: 1px solid var(--fitme-surface-2);
+ border-bottom: 1px solid var(--fitme-surface-2);
+ margin-bottom: 1rem;
+}
+
+.price-card.is-featured .pc-price { border-color: rgba(255, 255, 255, .12); }
+
+.pc-amount {
+ font-size: 2.3rem;
+ font-weight: 900;
+ line-height: 1;
+ color: var(--fitme-dark);
+}
+
+.pc-amount.is-text { font-size: 1.35rem; }
+
+.price-card.is-featured .pc-amount { color: var(--fitme-accent); }
+
+.pc-suffix {
+ font-size: .88rem;
+ color: var(--fitme-text-muted);
+}
+
+.price-card.is-featured .pc-suffix { color: var(--fitme-ash); }
+
+.pc-features {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 1.4rem;
+ flex: 1 1 auto;
+}
+
+.pc-features li {
+ display: flex;
+ gap: 10px;
+ font-size: .92rem;
+ line-height: 1.4;
+ padding: 4px 0;
+ color: var(--fitme-text-soft);
+}
+
+.price-card.is-featured .pc-features li { color: #fff; }
+
+.pc-features i {
+ color: var(--fitme-accent-dark);
+ margin-top: 3px;
+ font-size: .8rem;
+}
+
+.price-card.is-featured .pc-features i { color: var(--fitme-accent); }
+
+.price-card .pc-cta {
+ width: 100%;
+ border: none;
+ margin-top: auto;
+}
+
+.price-card:not(.is-featured) .pc-cta {
+ background: var(--fitme-dark-2);
+ color: var(--fitme-accent);
+}
+
+.price-card:not(.is-featured) .pc-cta:hover {
+ background: var(--fitme-dark-3);
+ box-shadow: none;
+}
+
+@media (max-width: 575px) {
+ .pricing-section { padding: 60px 0 50px; }
+ .price-card { flex-basis: 100%; }
+}
+
+/* ══════════════════════════════════════════════════════════════════════════
+ Reviews Google
+ ══════════════════════════════════════════════════════════════════════════ */
+.reviews-section {
+ padding: 90px 0 60px;
+ background: var(--fitme-dark);
+ color: var(--fitme-ash);
+}
+
+.reviews-section .section-title h2 { color: #fff; }
+
+.reviews-summary {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ align-items: center;
+ gap: 6px 12px;
+ margin: 1rem 0 0;
+ color: var(--fitme-ash);
+}
+
+.reviews-summary strong {
+ font-size: 1.8rem;
+ font-weight: 900;
+ color: #fff;
+ line-height: 1;
+}
+
+.rv-stars {
+ color: #FBBC04;
+ letter-spacing: 2px;
+ font-size: .95rem;
+}
+
+.reviews-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: 22px;
+}
+
+.review-card {
+ display: flex;
+ flex-direction: column;
+ margin: 0;
+ background: var(--fitme-dark-2);
+ border: 1px solid var(--fitme-dark-3);
+ border-radius: 6px;
+ padding: 24px;
+}
+
+.rv-text {
+ flex: 1 1 auto;
+ margin: .8rem 0 1.2rem;
+ font-size: .95rem;
+ line-height: 1.6;
+ color: #fff;
+ display: -webkit-box;
+ -webkit-line-clamp: 7;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.rv-author {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.rv-author img {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.rv-author span {
+ display: flex;
+ flex-direction: column;
+ font-weight: 700;
+ color: #fff;
+}
+
+.rv-author a { color: #fff; }
+.rv-author a:hover { color: var(--fitme-accent); }
+
+.rv-author small {
+ font-weight: 400;
+ color: var(--fitme-ash);
+}
+
+.reviews-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 12px;
+ margin-top: 2rem;
+}
+
+.reviews-section .site-btn.sb-line {
+ border-color: #fff;
+ color: #fff;
+}
+
+.reviews-section .site-btn.sb-line:hover {
+ background: #fff;
+ color: var(--fitme-dark);
+}
+
+.reviews-attribution {
+ margin: 1.2rem 0 0;
+ text-align: center;
+ font-size: .75rem;
+ color: var(--fitme-ash);
+ opacity: .8;
+}
diff --git a/site/assets/js/main.js b/site/assets/js/main.js
index 7eebf67..e92b8f6 100644
--- a/site/assets/js/main.js
+++ b/site/assets/js/main.js
@@ -222,3 +222,81 @@ document.addEventListener('DOMContentLoaded', function () {
var t;
window.addEventListener('resize', function () { clearTimeout(t); t = setTimeout(sync, 150); }, { passive: true });
});
+
+
+/* ── Equipa: horizontal detail card ─────────────────────────────────────────
+ Desktop (fine pointer) opens it on hover purely in CSS; this handles tap /
+ click / keyboard (.is-open, one at a time; Esc, the × and outside clicks
+ close) and nudges the absolutely-positioned card sideways (--td-shift) so an
+ edge column's card stays inside the viewport. On small / touch screens the
+ CSS turns it into a centred fixed card, so no shift is applied there. */
+document.addEventListener('DOMContentLoaded', function () {
+ var items = document.querySelectorAll('.trainer-item.has-detail');
+ if (!items.length) return;
+ var floating = window.matchMedia('(max-width: 767px), (hover: none)');
+
+ function place(item) {
+ var card = item.querySelector('.trainer-detail');
+ if (!card) return;
+ card.style.setProperty('--td-shift', '0px');
+ if (floating.matches) return;
+ var r = card.getBoundingClientRect();
+ var itemR = item.getBoundingClientRect();
+ // Where the card would sit centred on the item (ignoring its current transform).
+ var left = itemR.left + itemR.width / 2 - r.width / 2;
+ var right = left + r.width;
+ var gutter = 16, vw = document.documentElement.clientWidth, shift = 0;
+ if (left < gutter) shift = gutter - left;
+ else if (right > vw - gutter) shift = (vw - gutter) - right;
+ card.style.setProperty('--td-shift', Math.round(shift) + 'px');
+ }
+ var section = document.getElementById('equipa');
+ function setOpen(item, open) {
+ item.classList.toggle('is-open', open);
+ item.setAttribute('aria-expanded', open ? 'true' : 'false');
+ if (section) section.classList.toggle('has-open-detail', !!section.querySelector('.trainer-item.is-open'));
+ }
+ function closeAll(except) {
+ items.forEach(function (it) { if (it !== except) setOpen(it, false); });
+ }
+
+ items.forEach(function (item) {
+ item.addEventListener('mouseenter', function () { place(item); });
+ item.addEventListener('focus', function () { place(item); });
+ item.addEventListener('click', function (e) {
+ e.stopPropagation();
+ if (e.target.closest('.td-close')) { setOpen(item, false); return; }
+ // Clicks inside an open card (e.g. scrolling / selecting text) keep it open.
+ if (e.target.closest('.trainer-detail') && item.classList.contains('is-open')) return;
+ // Tapping the dimmed backdrop (the item's ::after) closes it.
+ if (floating.matches && item.classList.contains('is-open') && !e.target.closest('.trainer-detail')) {
+ setOpen(item, false); return;
+ }
+ closeAll(item);
+ place(item);
+ setOpen(item, !item.classList.contains('is-open'));
+ });
+ item.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
+ if (e.target !== item) return;
+ e.preventDefault();
+ closeAll(item); place(item);
+ setOpen(item, !item.classList.contains('is-open'));
+ } else if (e.key === 'Escape') {
+ setOpen(item, false);
+ item.blur();
+ }
+ });
+ });
+ document.addEventListener('click', function () { closeAll(null); });
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeAll(null); });
+});
+
+/* ── Mensalidades: pre-select the clicked card in the sign-up modal ───────── */
+document.addEventListener('DOMContentLoaded', function () {
+ var select = document.querySelector('#joinModal select[name="membership_id"]');
+ if (!select) return;
+ document.querySelectorAll('[data-target="#joinModal"][data-membership]').forEach(function (btn) {
+ btn.addEventListener('click', function () { select.value = btn.getAttribute('data-membership'); });
+ });
+});
diff --git a/site/index.php b/site/index.php
index fe38ff5..cce2034 100644
--- a/site/index.php
+++ b/site/index.php
@@ -11,6 +11,8 @@ $modalidades = site_modalidades($pdo);
$horario = site_horario($pdo);
$whours = site_work_hours($pdo);
$events = site_active_events($pdo); // time-limited events, banner below the hero
+$memberships = site_memberships($pdo);
+$reviews = site_reviews(); // null when not configured / nothing qualifies
// ── Hero section ────────────────────────────────────────────────────────────────
?>
@@ -118,15 +120,46 @@ if ($team):
-
+
-
+
>
-
+
= htmlspecialchars((string) $d['name']) ?>
+
= htmlspecialchars($t_title) ?>
+
+
Saber mais
+
+
×
+
+
+
= htmlspecialchars((string) $d['name']) ?>
+
= htmlspecialchars($t_title) ?>
+
= $t_bio ?>
+
+
Aptidões / Funções
+
= htmlspecialchars($x) ?>
+
+
+
Formações
+
= htmlspecialchars($x) ?>
+
+
+
+
- = htmlspecialchars((string) $d['description']) ?>
@@ -387,6 +420,108 @@ if (!in_array($default_day, $week_values, true)) { $default_day = $week_values[0
+
+
+
+
+
+
+
Mensalidades
+
Escolhe o teu plano
+
Encontra a mensalidade que encaixa na tua rotina.
+
+
+
+
+
Pedido de inscrição enviado! Vamos entrar em contacto contigo muito em breve.
+
+
Não foi possível enviar o pedido. Verifica os dados e tenta novamente.
+
+
+
+
+ = htmlspecialchars($m['featured_label']) ?>
+ = htmlspecialchars($m['name']) ?>
+ = htmlspecialchars($m['subtitle']) ?>
+
+ = htmlspecialchars(membership_price_label($m)) ?>
+ = htmlspecialchars($m['price_suffix']) ?>
+
+
+
+ = htmlspecialchars($f) ?>
+
+
+ = htmlspecialchars($m['cta_label']) ?>
+
+
+
+
+
+
+
+= $i ? 'fas fa-star' : ($r >= $i - 0.5 ? 'fas fa-star-half-alt' : 'far fa-star');
+ $out .= '
';
+ }
+ return $out;
+ };
+?>
+
+
+
+
+
+
Reviews
+
O que dizem de nós
+
+
+ = number_format((float) $reviews['rating'], 1, ',', '') ?>
+ = $stars((float) $reviews['rating']) ?>
+ = (int) $reviews['total'] ?> avaliações no Google
+
+
+
+
+
+
+
+
+ = $stars((float) $r['rating']) ?>
+ = nl2br(htmlspecialchars($r['text'])) ?>
+
+
+
+ = htmlspecialchars($r['author']) ?> = htmlspecialchars($r['author']) ?>
+ = htmlspecialchars($r['relative']) ?>
+
+
+
+
+
+
+
Reviews via Google Maps
+
+
+
+
">
= htmlspecialchars($info['phone'] ?? '') ?>
-
- = htmlspecialchars($info['telephone'] ?? '') ?>
+
+
+ = htmlspecialchars($info['telephone']) ?>
+
@@ -618,6 +755,63 @@ $work_hours_days = ['Segunda-Sexta'=>'segunda','Sábado'=>'sabado','Domingo/Feri
+
+
+
+
+
+
= htmlspecialchars($info['name'] !== '' ? $info['name'] : APP_NAME) ?>
+
Inscreve-te
+
Deixa os teus contactos e entramos em contacto para concluir a inscrição.
+
×
+
+
+
+
+
+
+
+
+
+
$membership ? (int) $membership['id'] : null,
+ 'membership_name' => $membership ? $membership['name'] : '',
+ 'name' => trim((string) ($_POST['nome'] ?? '')),
+ 'phone' => trim((string) ($_POST['telefone'] ?? '')),
+ 'email' => trim((string) ($_POST['email'] ?? '')),
+ 'message' => trim((string) ($_POST['mensagem'] ?? '')),
+ 'consent' => !empty($_POST['consent']) ? 1 : 0,
+ 'consent_text' => $consent_text,
+ // First hop of X-Forwarded-For (behind the reverse proxy), else peer.
+ 'ip' => trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''))[0]) ?: ($_SERVER['REMOTE_ADDR'] ?? ''),
+ 'user_agent' => (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''),
+];
+
+if ($lead['name'] === '' || $lead['phone'] === '' || !$lead['consent']
+ || ($lead['email'] !== '' && !filter_var($lead['email'], FILTER_VALIDATE_EMAIL))) {
+ inscricao_redirect('erro');
+}
+
+try {
+ db_membership_lead_create($pdo, $lead);
+} catch (Throwable $e) {
+ error_log('[inscricao] ' . $e->getMessage());
+ inscricao_redirect('erro');
+}
+
+// Notify the gym. Best effort: the request is already stored.
+try {
+ $info = site_info($pdo);
+ $to = $info['email'] !== '' ? $info['email'] : EMAIL_FROM;
+ $e = static fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
+ $rows = [
+ 'Mensalidade' => $lead['membership_name'],
+ 'Nome' => $lead['name'],
+ 'Telemóvel' => $lead['phone'],
+ 'E-mail' => $lead['email'],
+ 'Mensagem' => $lead['message'],
+ ];
+ $body = 'Novo pedido de inscrição ';
+ $alt = "Novo pedido de inscrição\n\n";
+ foreach ($rows as $label => $value) {
+ if (trim((string) $value) === '') continue;
+ $body .= '' . $e($label) . ': ' . nl2br($e($value)) . ' ';
+ $alt .= "$label: $value\n";
+ }
+ $body .= '
'
+ . 'Pedido guardado no backoffice (Mensalidades → Pedidos de inscrição).
';
+
+ $id = enqueue_email([
+ 'to_address' => $to,
+ 'to_name' => $info['name'] !== '' ? $info['name'] : APP_NAME,
+ 'subject' => 'Pedido de inscrição' . ($lead['membership_name'] !== '' ? ' (' . $lead['membership_name'] . ')' : '') . ' — ' . $lead['name'],
+ 'body' => $body,
+ 'alt_body' => $alt,
+ 'context_type' => 'fitme_inscricao',
+ ]);
+ flush_message($id);
+} catch (Throwable $e) {
+ error_log('[inscricao] email: ' . $e->getMessage());
+}
+
+inscricao_redirect('ok');
diff --git a/views/admin/dashboard.php b/views/admin/dashboard.php
index 0da3c66..49a92e5 100644
--- a/views/admin/dashboard.php
+++ b/views/admin/dashboard.php
@@ -7,6 +7,7 @@ $user = require_admin();
$counts = [
'clientes' => (int) $pdo->query("SELECT COUNT(*) FROM users WHERE role = 'client'")->fetchColumn(),
'equipa' => (int) $pdo->query("SELECT COUNT(*) FROM users WHERE role IN ('admin','staff')")->fetchColumn(),
+ 'leads_novos' => db_membership_leads_count_new($pdo),
];
$page_title = 'Painel';
@@ -81,6 +82,33 @@ include __DIR__ . '/../../includes/header.php';
+
+
+
diff --git a/views/admin/info/edit.php b/views/admin/info/edit.php
new file mode 100644
index 0000000..6459e19
--- /dev/null
+++ b/views/admin/info/edit.php
@@ -0,0 +1,52 @@
+setFlashdata($changed ? 'success' : 'warning', $changed ? 'Contactos atualizados.' : 'Nenhuma alteração.');
+ header('Location: ' . base_url('views/admin/info/edit.php'));
+ exit;
+ }
+}
+
+$page_title = 'Contactos';
+include __DIR__ . '/../../../includes/header.php';
+?>
+
+
+
Contactos
+
Ver no site
+
+
Estes dados alimentam a secção de contactos, o rodapé, o botão «Ligar» no telemóvel, o botão de WhatsApp e os dados para o Google (SEO). Campos vazios não aparecem no site.
+
= htmlspecialchars($e) ?>
+
+
+ = csrf_field() ?>
+
+ [$label, $max, $type]): $req = in_array($key, ['name', 'phone'], true); ?>
+
+
= htmlspecialchars($label) ?>
+
= htmlspecialchars((string) ($data[$key] ?? '')) ?>
+
No Google Maps: Partilhar → Incorporar um mapa → Copiar HTML. Só é aceite um iframe do Google Maps.
+
+
= htmlspecialchars($label) ?>= $req ? ' * ' : '' ?>
+ = $req ? ' required' : '' ?>
+ value="= htmlspecialchars((string) ($data[$key] ?? '')) ?>">
+
+
+
+
+
+
+
diff --git a/views/admin/memberships/_form.php b/views/admin/memberships/_form.php
new file mode 100644
index 0000000..422cfc4
--- /dev/null
+++ b/views/admin/memberships/_form.php
@@ -0,0 +1,63 @@
+ of create.php and
+// edit.php. Expects $data (see memberships_defaults()).
+$price_val = ($data['price'] ?? '') === null ? '' : (string) ($data['price'] ?? '');
+if ($price_val !== '' && is_numeric($price_val)) {
+ $p = (float) $price_val;
+ $price_val = number_format($p, floor($p) == $p ? 0 : 2, ',', '');
+}
+?>
+
+
+ Nome *
+
+
+
+
Ordem
+
+
Menor aparece primeiro.
+
+
+ Descrição curta
+
+
+
+
Preço (€)
+
+
Vazio = «Sob consulta».
+
+
+
Texto do preço
+
+
ex.: «/mês» ou «acresce à mensalidade».
+
+
+
+ >
+ Acresce à mensalidade (mostra «+»)
+
+
+
+ Incluído
+ = htmlspecialchars($data['features'] ?? '') ?>
+
+
+ Texto do botão
+
+
+
+ Selo do destaque
+
+
+
+
Só um cartão pode estar em destaque — ao marcar este, o anterior deixa de estar.
+
diff --git a/views/admin/memberships/create.php b/views/admin/memberships/create.php
new file mode 100644
index 0000000..2700076
--- /dev/null
+++ b/views/admin/memberships/create.php
@@ -0,0 +1,38 @@
+setFlashdata('success', 'Mensalidade criada.');
+ header('Location: ' . base_url('views/admin/memberships/index.php'));
+ exit;
+ }
+}
+
+$page_title = 'Nova Mensalidade';
+include __DIR__ . '/../../../includes/header.php';
+?>
+
+
+
Nova Mensalidade
+
Voltar
+
+
= htmlspecialchars($e) ?>
+
+ = csrf_field() ?>
+
+
+
+
+
diff --git a/views/admin/memberships/edit.php b/views/admin/memberships/edit.php
new file mode 100644
index 0000000..8724721
--- /dev/null
+++ b/views/admin/memberships/edit.php
@@ -0,0 +1,42 @@
+setFlashdata($changed ? 'success' : 'warning', $changed ? 'Mensalidade atualizada.' : 'Nenhuma alteração.');
+ header('Location: ' . base_url('views/admin/memberships/edit.php?id=' . $id));
+ exit;
+ }
+}
+
+$page_title = 'Editar Mensalidade';
+include __DIR__ . '/../../../includes/header.php';
+?>
+
+
+
Editar Mensalidade
+
Voltar
+
+
= htmlspecialchars($e) ?>
+
+ = csrf_field() ?>
+
+
+
+
+
diff --git a/views/admin/memberships/index.php b/views/admin/memberships/index.php
new file mode 100644
index 0000000..f0a0c78
--- /dev/null
+++ b/views/admin/memberships/index.php
@@ -0,0 +1,70 @@
+setFlashdata('success', 'Mensalidade apagada.');
+ } elseif (isset($_POST['restore'])) {
+ db_memberships_restore($pdo, (int) $_POST['restore']);
+ session()->setFlashdata('success', 'Mensalidade restaurada.');
+ }
+ header('Location: ' . base_url('views/admin/memberships/index.php'));
+ exit;
+}
+
+$items = db_memberships_list($pdo, true);
+$new_leads = db_membership_leads_count_new($pdo);
+$page_title = 'Mensalidades';
+include __DIR__ . '/../../../includes/header.php';
+?>
+
+
+
+
+
= csrf_field() ?>
+
+
+ Nome Preço Estado Ordem Ações
+
+
+
+ Nenhuma mensalidade.
+
+
+ = htmlspecialchars($m['name']) ?>
+ Destaque
+ = htmlspecialchars($m['subtitle']) ?>
+ = htmlspecialchars(membership_price_label($m)) ?> = htmlspecialchars($m['price_suffix']) ?>
+ Apagada
+ Visível
+ Oculta
+ = (int) $m['item_order'] ?>
+
+
+ Restaurar
+
+
+
+
+
+
+
+
+
+
+
+
+setFlashdata('success', 'Pedido apagado.');
+ } elseif (isset($_POST['save'])) {
+ $id = (int) $_POST['save'];
+ db_membership_lead_update($pdo, $id, (string) ($_POST['status'][$id] ?? ''), trim((string) ($_POST['notes'][$id] ?? '')));
+ session()->setFlashdata('success', 'Pedido atualizado.');
+ }
+ header('Location: ' . $self);
+ exit;
+}
+
+$leads = db_membership_leads_list($pdo, $filter);
+
+// CSV export of the current filter (Excel-friendly: UTF-8 BOM + ';').
+if (isset($_GET['csv'])) {
+ header('Content-Type: text/csv; charset=utf-8');
+ header('Content-Disposition: attachment; filename="pedidos-inscricao-' . date('Y-m-d') . '.csv"');
+ $out = fopen('php://output', 'w');
+ fwrite($out, "\xEF\xBB\xBF");
+ fputcsv($out, ['Data', 'Mensalidade', 'Nome', 'Telemóvel', 'E-mail', 'Mensagem', 'Estado', 'Notas', 'Consentimento'], ';', '"', '');
+ foreach ($leads as $l) {
+ fputcsv($out, [$l['created_at'], $l['membership_name'], $l['name'], $l['phone'], $l['email'], $l['message'],
+ $statuses[$l['status']] ?? $l['status'], (string) $l['admin_notes'], $l['consent'] ? 'sim' : 'não'], ';', '"', '');
+ }
+ exit;
+}
+
+$badge = ['novo' => 'bg-danger', 'contactado' => 'bg-info text-dark', 'inscrito' => 'bg-success', 'descartado' => 'bg-secondary'];
+$page_title = 'Pedidos de inscrição';
+include __DIR__ . '/../../../includes/header.php';
+?>
+
+
+
Pedidos de inscrição
+
+
+
Pedidos enviados pelo botão de inscrição dos cartões de mensalidade. Cada pedido também é enviado por e-mail para o endereço dos Contactos.
+
+
+
+
+ = csrf_field() ?>
+
+
+
+ Data Mensalidade Contacto Mensagem Estado / notas
+
+
+
+ Sem pedidos.
+
+
+ = htmlspecialchars(date('d/m/Y H:i', strtotime($l['created_at']))) ?>
+ = htmlspecialchars($l['membership_name'] !== '' ? $l['membership_name'] : '—') ?>
+
+ = htmlspecialchars($l['name']) ?>
+
+
+ RGPD
+
+ = $l['message'] !== '' ? nl2br(htmlspecialchars($l['message'])) : '— ' ?>
+
+
+
+ $label): ?>
+ >= $label ?>
+
+
+ = htmlspecialchars($statuses[$l['status']] ?? $l['status']) ?>
+
+ = htmlspecialchars((string) $l['admin_notes']) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+setFlashdata('success', 'Definições guardadas.');
+ header('Location: ' . base_url('views/admin/reviews/index.php'));
+ exit;
+ }
+ if ($action === 'refresh') {
+ $r = reviews_refresh();
+ session()->setFlashdata(!empty($r['error']) ? 'error' : 'success',
+ !empty($r['error']) ? 'Falhou: ' . $r['error'] : 'Reviews atualizadas (' . count($r['reviews'] ?? []) . ' recebidas da Google).');
+ header('Location: ' . base_url('views/admin/reviews/index.php'));
+ exit;
+ }
+ if ($action === 'search') {
+ $apiKey = reviews_config()['api_key'];
+ $q = trim((string) ($_POST['query'] ?? ''));
+ $search = ($apiKey === '') ? ['error' => 'Guarda primeiro a chave da API.', 'places' => []]
+ : ($q === '' ? ['error' => 'Escreve o nome do ginásio.', 'places' => []] : reviews_find_place($q, $apiKey));
+ }
+}
+
+$cfg = reviews_config();
+$env_key = (string) env('GOOGLE_PLACES_API_KEY', '') !== '';
+$cache = reviews_cache_read();
+$preview = site_reviews();
+$info = site_info($pdo);
+$page_title = 'Reviews Google';
+include __DIR__ . '/../../../includes/header.php';
+?>
+
+
+
Reviews Google
+
Ver no site
+
+
A secção mostra as melhores reviews do ginásio no Google, obtidas pela Places API. A Google devolve no máximo 5 reviews (escolhidas por relevância); o site mostra as que têm a classificação mínima definida. Se nenhuma se qualificar, a secção fica escondida.
+
+
+
Configuração
+
+ = csrf_field() ?>
+
+
+
+
Chave da API (Places API — New)
+
+
+
+
+
+
+ Remover chave guardada
+
+
Sem chave de encriptação no .env — a chave fica guardada sem encriptação.
+
+
Google Cloud Console → ativar «Places API (New)» → Credenciais. Restringe a chave a essa API.
+
+
+
Place ID
+
+
Não sabes? Usa a pesquisa abaixo.
+
+
+ Classificação mínima
+
+ = 1; $i--): ?>>= $i ?> ★= $i < 5 ? ' ou mais' : '' ?>
+
+
+
+ Número de reviews
+
+
+
+
Atualizar a cada (horas)
+
+
Cada atualização é um pedido pago à Google.
+
+
+ Guardar
+
+
+
+
+
Encontrar o Place ID
+
+ = csrf_field() ?>
+
+
+ Procurar
+
+
+
= htmlspecialchars($search['error']) ?>
+
Sem resultados.
+
+
+
+
+ = htmlspecialchars($p['name']) ?>
+
· = htmlspecialchars((string) $p['rating']) ?> ★ (= (int) $p['total'] ?>)
+
= htmlspecialchars($p['address']) ?>
+
= htmlspecialchars($p['id']) ?>
+ Usar
+
+
+
+
Depois de «Usar», carrega em Guardar.
+
+
+
+
+
+
+
Estado
+ = csrf_field() ?>
+ > Atualizar agora
+
+
+
Por configurar — a secção não aparece no site.
+
+
+ Última atualização: = !empty($cache['fetched_at']) ? date('d/m/Y H:i', (int) $cache['fetched_at']) : '—' ?>
+ · = htmlspecialchars(number_format((float) $cache['rating'], 1, ',', '')) ?> ★ em = (int) ($cache['total'] ?? 0) ?> avaliações
+
+
Último erro: = htmlspecialchars($cache['error']) ?>
+
+
No site aparecem = count($preview['reviews']) ?>:
+
+
+
= htmlspecialchars($r['author']) ?> · = str_repeat('★', $r['rating']) ?> ·
= htmlspecialchars($r['relative']) ?>
+
= nl2br(htmlspecialchars(mb_strimwidth($r['text'], 0, 240, '…'))) ?>
+
+
+
+
Nenhuma review qualifica neste momento — a secção está escondida.
+
+
+
+
+ '', 'birthday' => '', 'at' => '', 'status' => 'ativo', 'description' => ''];
+$data = ['name' => '', 'title' => '', 'birthday' => '', 'at' => '', 'status' => 'ativo', 'description' => '',
+ 'skills' => '', 'certifications' => ''];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
@@ -14,6 +15,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'at' => trim($_POST['at'] ?? ''),
'status' => $_POST['status'] ?? 'ativo',
'description' => trim($_POST['description'] ?? ''),
+ 'title' => mb_substr(trim($_POST['title'] ?? ''), 0, 100),
+ 'skills' => trim($_POST['skills'] ?? ''),
+ 'certifications' => trim($_POST['certifications'] ?? ''),
];
if ($data['name'] === '') $errors[] = 'O nome é obrigatório.';
@@ -70,7 +74,15 @@ include __DIR__ . '/../../../includes/header.php';
Foto
- Descrição
+
Título
+
+
Aparece no site por baixo do nome.
+
Aptidões / Funções
+ = htmlspecialchars($data['skills'] ?? '') ?>
+
Formações
+ = htmlspecialchars($data['certifications'] ?? '') ?>
+
Aptidões e formações aparecem no cartão de detalhe do site (hover no computador, toque no telemóvel).
+
Descrição (opcional — aparece no cartão de detalhe)
= htmlspecialchars($data['description']) ?>
diff --git a/views/admin/team/edit.php b/views/admin/team/edit.php
index 696764e..320f08f 100644
--- a/views/admin/team/edit.php
+++ b/views/admin/team/edit.php
@@ -29,6 +29,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'at' => trim($_POST['at'] ?? ''),
'status' => $_POST['status'] ?? 'ativo',
'description' => trim($_POST['description'] ?? ''),
+ 'title' => mb_substr(trim($_POST['title'] ?? ''), 0, 100),
+ 'skills' => trim($_POST['skills'] ?? ''),
+ 'certifications' => trim($_POST['certifications'] ?? ''),
];
if ($data['name'] === '') $errors[] = 'O nome é obrigatório.';
@@ -92,7 +95,15 @@ include __DIR__ . '/../../../includes/header.php';
- Descrição
+
Título
+
+
Aparece no site por baixo do nome.
+
Aptidões / Funções
+ = htmlspecialchars($data['skills'] ?? '') ?>
+
Formações
+ = htmlspecialchars($data['certifications'] ?? '') ?>
+
Aptidões e formações aparecem no cartão de detalhe do site (hover no computador, toque no telemóvel).
+
Descrição (opcional — aparece no cartão de detalhe)
= htmlspecialchars($data['description'] ?? '') ?>
diff --git a/views/admin/team/index.php b/views/admin/team/index.php
index 6d756ff..4c0b800 100644
--- a/views/admin/team/index.php
+++ b/views/admin/team/index.php
@@ -53,7 +53,7 @@ include __DIR__ . '/../../../includes/header.php';
- = htmlspecialchars($item['name']) ?>
+ = htmlspecialchars($item['name']) ?> = htmlspecialchars($item['title']) ?>
= htmlspecialchars($item['at'] ?? '—') ?>
= htmlspecialchars($item['status']) ?>
apagado