feat: aulas experimentais guardadas, anti-spam partilhado nos formulários, audit das escritas

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
andreandClaude Opus 5.5 committed 2026-09-25 11:33:24 +01:00
1 parent 0e8b53f620
commit 04c8c83e66
29 files changed
+480 -29

No files matched your search

+2
View File
@@ -37,6 +37,8 @@ require_once __DIR__ . '/functions_events.php'; // time-limited events +
require_once __DIR__ . '/functions_info.php'; // contactos (fitme3070_info) editor 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_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_forms.php'; // anti-spam + bloqueio de IP dos formulários públicos
// ─── DB-backed settings → legacy constants ─────────────────────────────────── // ─── DB-backed settings → legacy constants ───────────────────────────────────
// Resolved once from the settings cache so call sites read plain constants. // Resolved once from the settings cache so call sites read plain constants.
+34
View File
@@ -23,3 +23,37 @@ function audit_mask_email(string $email): string {
[$u, $d] = explode('@', $email, 2); [$u, $d] = explode('@', $email, 2);
return mb_substr($u, 0, 1) . '…@' . $d; return mb_substr($u, 0, 1) . '…@' . $d;
} }
// ─── Escritas de conteúdo ────────────────────────────────────────────────────
// Os modelos (config/functions_*.php) chamam audit_row() antes de alterar e
// audit_change() depois: o registo leva o antes/depois campo a campo
// (platform_audit_diff) ou, numa criação, os campos criados. Tabela e chave vêm
// do código, nunca do pedido, e ainda assim são validadas.
function audit_row(PDO $pdo, string $table, string $pk, int $id): ?array {
if (!preg_match('/^[A-Za-z0-9_]+$/', $table) || !preg_match('/^[A-Za-z0-9_]+$/', $pk)) return null;
try {
$st = $pdo->prepare("SELECT * FROM `{$table}` WHERE `{$pk}` = ? LIMIT 1");
$st->execute([$id]);
$row = $st->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
} catch (Throwable $e) {
return null;
}
}
function audit_change(PDO $pdo, string $entity, string $table, string $pk, int $id, string $verb,
?array $before = null, string $labelCol = 'name'): void {
if (!function_exists('platform_audit') || $id < 1) return;
$after = audit_row($pdo, $table, $pk, $id) ?? [];
$changes = $before !== null
? platform_audit_diff($before, $after, ['updated_at', 'created_at', 'updated_by'])
: platform_audit_created(array_diff_key($after, array_flip(['created_at', 'updated_at'])));
if ($verb !== 'create' && $before !== null && !$changes) return; // gravou sem mudar nada
platform_audit($pdo, $entity . '.' . $verb, [
'target_type' => $entity,
'target_id' => $id,
'target_label' => (string) ($after[$labelCol] ?? $before[$labelCol] ?? ''),
'changes' => $changes,
]);
}
+7 -2
View File
@@ -128,10 +128,13 @@ function db_events_create(PDO $pdo, array $data): int {
str_replace('T', ' ', (string) $d['publish_until']), str_replace('T', ' ', (string) $d['publish_until']),
(int) $d['is_active'], $d['cta_label'], $d['cta_url'], (int) $d['show_lead_form'], (int) $d['is_active'], $d['cta_label'], $d['cta_url'], (int) $d['show_lead_form'],
]); ]);
return (int) $pdo->lastInsertId(); $__id = (int) $pdo->lastInsertId();
audit_change($pdo, 'event', 'events', 'e_id', (int) $__id, 'create', null, 'title');
return $__id;
} }
function db_events_update(PDO $pdo, int $id, array $data): bool { function db_events_update(PDO $pdo, int $id, array $data): bool {
$__before = audit_row($pdo, 'events', 'e_id', (int) $id);
$d = array_merge(events_defaults(), $data); $d = array_merge(events_defaults(), $data);
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
UPDATE events SET UPDATE events SET
@@ -150,7 +153,9 @@ function db_events_update(PDO $pdo, int $id, array $data): bool {
(int) $d['is_active'], $d['cta_label'], $d['cta_url'], (int) $d['show_lead_form'], (int) $d['is_active'], $d['cta_label'], $d['cta_url'], (int) $d['show_lead_form'],
$id, $id,
]); ]);
return $stmt->rowCount() > 0; $__r = $stmt->rowCount() > 0;
audit_change($pdo, 'event', 'events', 'e_id', (int) $id, 'update', $__before, 'title');
return $__r;
} }
// Soft delete para a lixeira (config/functions_trash.php). // Soft delete para a lixeira (config/functions_trash.php).
+47
View File
@@ -0,0 +1,47 @@
<?php
// ─── Formulários públicos: anti-spam e bloqueio de IP (platform-tools) ──────
// Aula experimental, inscrição e eventos usam as defesas partilhadas da frota
// em vez de um honeypot local por formulário:
// - platform_ip_guard(): um IP banido não chega ao formulário;
// - platform_antispam_check(): honeypot, carimbo assinado com tempo mínimo
// (time-trap), prova de JavaScript, limite por IP e pontuação de conteúdo.
// Uma submissão recusada recebe a mesma resposta de sucesso (o bot não aprende
// nada) e fica em `blocked_submissions`, para um falso positivo se poder ler.
//
// Falha aberto: se o módulo rebentar, o formulário continua a funcionar —
// segurança que derruba o formulário é uma avaria, não uma proteção.
require_once __DIR__ . '/../shared/platform-tools/functions_ip_ban.php';
require_once __DIR__ . '/../shared/platform-tools/functions_antispam_forms.php';
platform_antispam_config([
// NÃO mudar num site a correr: invalida os formulários abertos nesse momento.
'key_domain' => 'fitme-antispam|',
'log_table' => 'blocked_submissions',
'keywords' => ['seo', 'backlink', 'backlinks', 'crypto', 'bitcoin', 'casino', 'viagra', 'loan',
'guest post', 'web design', 'website traffic', 'ranking', 'forex', 'investment opportunity'],
// Marcas do português: quem as usa não é pontuado como «texto estrangeiro».
'native' => '/\b(ol[aá]|bom dia|boa (tarde|noite)|gostaria|queria|aulas?|gin[aá]sio|treino|obrigad[oa]|marcar|experimental|hor[aá]rio)\b/u',
]);
// Devolve o motivo da recusa (já registado) ou null para seguir.
function public_form_screen(PDO $pdo, string $form, string $table, string $name, string $text): ?string {
try {
platform_ip_guard($pdo); // sai com 403 se o IP estiver banido
return platform_antispam_check($pdo, $form, $table, ['name' => $name, 'text' => $text]);
} catch (Throwable $e) {
error_log('[antispam] ' . $form . ': ' . $e->getMessage());
return null;
}
}
// Os campos escondidos que o formulário tem de levar (honeypot + carimbo +
// prova de JS). Sem eles o carimbo falha e a submissão é recusada.
function public_form_trap(string $form): string {
try {
return platform_antispam_trap_field($form, $GLOBALS['pdo'] ?? null);
} catch (Throwable $e) {
error_log('[antispam] trap ' . $form . ': ' . $e->getMessage());
return '';
}
}
+7 -2
View File
@@ -17,13 +17,18 @@ function db_gallery_get(PDO $pdo, int $id): ?array {
function db_gallery_create(PDO $pdo, string $name): int { function db_gallery_create(PDO $pdo, string $name): int {
$stmt = $pdo->prepare("INSERT INTO galleries (name) VALUES (?)"); $stmt = $pdo->prepare("INSERT INTO galleries (name) VALUES (?)");
$stmt->execute([$name]); $stmt->execute([$name]);
return (int) $pdo->lastInsertId(); $__id = (int) $pdo->lastInsertId();
audit_change($pdo, 'gallery', 'galleries', 'id', (int) $__id, 'create', null, 'name');
return $__id;
} }
function db_gallery_update(PDO $pdo, int $id, string $name): bool { function db_gallery_update(PDO $pdo, int $id, string $name): bool {
$__before = audit_row($pdo, 'galleries', 'id', (int) $id);
$stmt = $pdo->prepare("UPDATE galleries SET name = ? WHERE id = ?"); $stmt = $pdo->prepare("UPDATE galleries SET name = ? WHERE id = ?");
$stmt->execute([$name, $id]); $stmt->execute([$name, $id]);
return $stmt->rowCount() > 0; $__r = $stmt->rowCount() > 0;
audit_change($pdo, 'gallery', 'galleries', 'id', (int) $id, 'update', $__before, 'name');
return $__r;
} }
// Soft delete para a lixeira (config/functions_trash.php). As imagens ficam // Soft delete para a lixeira (config/functions_trash.php). As imagens ficam
+7 -2
View File
@@ -199,10 +199,13 @@ function db_gyms_create(PDO $pdo, array $d): int {
facebook, messenger, email, website, google_maps, google_maps_link) facebook, messenger, email, website, google_maps, google_maps_link)
VALUES (?, ?, '', '', '', '', '', '', '', '', '', '', '')")->execute([$id, $d['name']]); VALUES (?, ?, '', '', '', '', '', '', '', '', '', '', '')")->execute([$id, $d['name']]);
$pdo->commit(); $pdo->commit();
return $id; $__id = $id;
audit_change($pdo, 'gym', 'gyms', 'id', (int) $__id, 'create', null, 'name');
return $__id;
} }
function db_gyms_update(PDO $pdo, int $id, array $d): bool { function db_gyms_update(PDO $pdo, int $id, array $d): bool {
$__before = audit_row($pdo, 'gyms', 'id', (int) $id);
$pdo->beginTransaction(); $pdo->beginTransaction();
$params = _gyms_params($d); $params[] = $id; $params = _gyms_params($d); $params[] = $id;
$st = $pdo->prepare("UPDATE gyms SET slug = ?, name = ?, locality = ?, postal_code = ?, is_published = ?, $st = $pdo->prepare("UPDATE gyms SET slug = ?, name = ?, locality = ?, postal_code = ?, is_published = ?,
@@ -214,7 +217,9 @@ function db_gyms_update(PDO $pdo, int $id, array $d): bool {
$changed = $st->rowCount() > 0; $changed = $st->rowCount() > 0;
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]); if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
$pdo->commit(); $pdo->commit();
return $changed; $__r = $changed;
audit_change($pdo, 'gym', 'gyms', 'id', (int) $id, 'update', $__before, 'name');
return $__r;
} }
// Soft delete. The default gym can't go (the root URL needs somewhere to land). // Soft delete. The default gym can't go (the root URL needs somewhere to land).
+11 -3
View File
@@ -42,10 +42,13 @@ function db_horario_create(PDO $pdo, array $data): int {
$data['hora_fim'], $data['hora_fim'],
$data['local'] ?? '', $data['local'] ?? '',
]); ]);
return (int) $pdo->lastInsertId(); $__id = (int) $pdo->lastInsertId();
audit_change($pdo, 'horario', 'horario', 'id', (int) $__id, 'create', null, 'dia_semana');
return $__id;
} }
function db_horario_update(PDO $pdo, int $id, array $data): bool { function db_horario_update(PDO $pdo, int $id, array $data): bool {
$__before = audit_row($pdo, 'horario', 'id', (int) $id);
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
UPDATE horario UPDATE horario
SET id_modalidade = ?, dia_semana = ?, hora_inicio = ?, hora_fim = ?, local = ? SET id_modalidade = ?, dia_semana = ?, hora_inicio = ?, hora_fim = ?, local = ?
@@ -59,7 +62,9 @@ function db_horario_update(PDO $pdo, int $id, array $data): bool {
$data['local'] ?? '', $data['local'] ?? '',
$id, $id,
]); ]);
return $stmt->rowCount() > 0; $__r = $stmt->rowCount() > 0;
audit_change($pdo, 'horario', 'horario', 'id', (int) $id, 'update', $__before, 'dia_semana');
return $__r;
} }
// Soft delete para a lixeira (config/functions_trash.php). // Soft delete para a lixeira (config/functions_trash.php).
@@ -71,11 +76,14 @@ function db_horario_delete(PDO $pdo, int $id): bool {
// Modalidade and local are left untouched. Times must be HH:MM[:SS]; the caller // Modalidade and local are left untouched. Times must be HH:MM[:SS]; the caller
// is responsible for validating the day against the allowed set. // is responsible for validating the day against the allowed set.
function db_horario_move(PDO $pdo, int $id, string $dia_semana, string $hora_inicio, string $hora_fim): bool { function db_horario_move(PDO $pdo, int $id, string $dia_semana, string $hora_inicio, string $hora_fim): bool {
$__before = audit_row($pdo, 'horario', 'id', (int) $id);
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
UPDATE horario SET dia_semana = ?, hora_inicio = ?, hora_fim = ? WHERE id = ? UPDATE horario SET dia_semana = ?, hora_inicio = ?, hora_fim = ? WHERE id = ?
"); ");
$stmt->execute([$dia_semana, $hora_inicio, $hora_fim, $id]); $stmt->execute([$dia_semana, $hora_inicio, $hora_fim, $id]);
return $stmt->rowCount() >= 0; $__r = $stmt->rowCount() >= 0;
audit_change($pdo, 'horario', 'horario', 'id', (int) $id, 'update', $__before, 'dia_semana');
return $__r;
} }
// Modalidades dropdown (id => nome + cor) for horario create/edit forms. // Modalidades dropdown (id => nome + cor) for horario create/edit forms.
+4 -1
View File
@@ -45,6 +45,7 @@ function db_info_save(PDO $pdo, array $data): bool {
$cols = array_keys(info_fields()); $cols = array_keys(info_fields());
// One row per gym (id = gyms.id): the gym being edited in the backoffice. // One row per gym (id = gyms.id): the gym being edited in the backoffice.
$gid = admin_gym_id(); $gid = admin_gym_id();
$__before = audit_row($pdo, 'fitme3070_info', 'id', (int) $gid);
$exists = (bool) $pdo->query("SELECT COUNT(*) FROM fitme3070_info WHERE id = " . $gid)->fetchColumn(); $exists = (bool) $pdo->query("SELECT COUNT(*) FROM fitme3070_info WHERE id = " . $gid)->fetchColumn();
$vals = array_map(fn($c) => $data[$c] ?? '', $cols); $vals = array_map(fn($c) => $data[$c] ?? '', $cols);
if ($exists) { if ($exists) {
@@ -56,5 +57,7 @@ function db_info_save(PDO $pdo, array $data): bool {
$stmt = $pdo->prepare("INSERT INTO fitme3070_info (id, $names, updated_at) VALUES (" . $gid . ", $marks, NOW())"); $stmt = $pdo->prepare("INSERT INTO fitme3070_info (id, $names, updated_at) VALUES (" . $gid . ", $marks, NOW())");
} }
$stmt->execute($vals); $stmt->execute($vals);
return $stmt->rowCount() > 0; $__r = $stmt->rowCount() > 0;
audit_change($pdo, 'info', 'fitme3070_info', 'id', (int) $gid, $__before ? 'update' : 'create', $__before, 'name');
return $__r;
} }
+11 -3
View File
@@ -93,10 +93,13 @@ function db_memberships_create(PDO $pdo, array $d): int {
$id = (int) $pdo->lastInsertId(); $id = (int) $pdo->lastInsertId();
if ($d['is_featured']) _memberships_clear_featured($pdo, $id); if ($d['is_featured']) _memberships_clear_featured($pdo, $id);
$pdo->commit(); $pdo->commit();
return $id; $__id = $id;
audit_change($pdo, 'membership', 'memberships', 'id', (int) $__id, 'create', null, 'name');
return $__id;
} }
function db_memberships_update(PDO $pdo, int $id, array $d): bool { function db_memberships_update(PDO $pdo, int $id, array $d): bool {
$__before = audit_row($pdo, 'memberships', 'id', (int) $id);
$pdo->beginTransaction(); $pdo->beginTransaction();
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
UPDATE memberships SET name = ?, subtitle = ?, price = ?, is_addon = ?, price_suffix = ?, UPDATE memberships SET name = ?, subtitle = ?, price = ?, is_addon = ?, price_suffix = ?,
@@ -110,7 +113,9 @@ function db_memberships_update(PDO $pdo, int $id, array $d): bool {
$changed = $stmt->rowCount() > 0; $changed = $stmt->rowCount() > 0;
if ($d['is_featured']) _memberships_clear_featured($pdo, $id); if ($d['is_featured']) _memberships_clear_featured($pdo, $id);
$pdo->commit(); $pdo->commit();
return $changed; $__r = $changed;
audit_change($pdo, 'membership', 'memberships', 'id', (int) $id, 'update', $__before, 'name');
return $__r;
} }
// Soft delete para a lixeira (config/functions_trash.php). Deixa de ser o // Soft delete para a lixeira (config/functions_trash.php). Deixa de ser o
@@ -187,10 +192,13 @@ function db_membership_lead_create(PDO $pdo, array $d): int {
} }
function db_membership_lead_update(PDO $pdo, int $id, string $status, string $notes): bool { function db_membership_lead_update(PDO $pdo, int $id, string $status, string $notes): bool {
$__before = audit_row($pdo, 'membership_leads', 'id', (int) $id);
if (!isset(membership_lead_statuses()[$status])) return false; if (!isset(membership_lead_statuses()[$status])) return false;
$stmt = $pdo->prepare("UPDATE membership_leads SET status = ?, admin_notes = ?, updated_at = NOW() WHERE id = ?"); $stmt = $pdo->prepare("UPDATE membership_leads SET status = ?, admin_notes = ?, updated_at = NOW() WHERE id = ?");
$stmt->execute([$status, $notes !== '' ? $notes : null, $id]); $stmt->execute([$status, $notes !== '' ? $notes : null, $id]);
return $stmt->rowCount() > 0; $__r = $stmt->rowCount() > 0;
audit_change($pdo, 'membership_lead', 'membership_leads', 'id', (int) $id, 'update', $__before, 'name');
return $__r;
} }
// Vai para a lixeira (config/functions_trash.php). // Vai para a lixeira (config/functions_trash.php).
+7 -2
View File
@@ -30,10 +30,13 @@ function db_modalidades_create(PDO $pdo, array $data): int {
$data['descricao'] ?? '', $data['descricao'] ?? '',
(int) ($data['item_order'] ?? 0), (int) ($data['item_order'] ?? 0),
]); ]);
return (int) $pdo->lastInsertId(); $__id = (int) $pdo->lastInsertId();
audit_change($pdo, 'modalidade', 'modalidades', 'm_id', (int) $__id, 'create', null, 'nome');
return $__id;
} }
function db_modalidades_update(PDO $pdo, int $id, array $data): bool { function db_modalidades_update(PDO $pdo, int $id, array $data): bool {
$__before = audit_row($pdo, 'modalidades', 'm_id', (int) $id);
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
UPDATE modalidades UPDATE modalidades
SET nome = ?, cor = ?, icon = ?, descricao = ?, item_order = ? SET nome = ?, cor = ?, icon = ?, descricao = ?, item_order = ?
@@ -47,7 +50,9 @@ function db_modalidades_update(PDO $pdo, int $id, array $data): bool {
(int) ($data['item_order'] ?? 0), (int) ($data['item_order'] ?? 0),
$id, $id,
]); ]);
return $stmt->rowCount() > 0; $__r = $stmt->rowCount() > 0;
audit_change($pdo, 'modalidade', 'modalidades', 'm_id', (int) $id, 'update', $__before, 'nome');
return $__r;
} }
// Soft delete para a lixeira (config/functions_trash.php). // Soft delete para a lixeira (config/functions_trash.php).
+1 -1
View File
@@ -101,7 +101,7 @@ function db_update_setting(PDO $pdo, string $key, string $value, ?int $userId =
if ($old !== $value && function_exists('platform_audit')) { if ($old !== $value && function_exists('platform_audit')) {
platform_audit($pdo, 'settings.update', [ platform_audit($pdo, 'settings.update', [
'target_type' => 'setting', 'target_id' => $key, 'target_label' => $key, 'target_type' => 'setting', 'target_id' => $key, 'target_label' => $key,
'changes' => [$key => ['antes' => $old, 'depois' => $value]], 'changes' => [$key => ['from' => $old, 'to' => $value]],
]); ]);
} }
} }
+7 -2
View File
@@ -34,10 +34,13 @@ function db_team_create(PDO $pdo, array $data): int {
($data['certifications'] ?? '') !== '' ? $data['certifications'] : null, ($data['certifications'] ?? '') !== '' ? $data['certifications'] : null,
$data['photo'] ?? null, $data['photo'] ?? null,
]); ]);
return (int) $pdo->lastInsertId(); $__id = (int) $pdo->lastInsertId();
audit_change($pdo, 'team', 'team', 'id', (int) $__id, 'create', null, 'name');
return $__id;
} }
function db_team_update(PDO $pdo, int $id, array $data, ?string $photo = null): bool { function db_team_update(PDO $pdo, int $id, array $data, ?string $photo = null): bool {
$__before = audit_row($pdo, 'team', 'id', (int) $id);
$sets = ["name = ?", "title = ?", "birthday = ?", "`at` = ?", "status = ?", "description = ?", $sets = ["name = ?", "title = ?", "birthday = ?", "`at` = ?", "status = ?", "description = ?",
"skills = ?", "certifications = ?", "updated_at = NOW()"]; "skills = ?", "certifications = ?", "updated_at = NOW()"];
$params = [ $params = [
@@ -57,7 +60,9 @@ function db_team_update(PDO $pdo, int $id, array $data, ?string $photo = null):
$params[] = $id; $params[] = $id;
$stmt = $pdo->prepare("UPDATE team SET " . implode(', ', $sets) . " WHERE id = ?"); $stmt = $pdo->prepare("UPDATE team SET " . implode(', ', $sets) . " WHERE id = ?");
$stmt->execute($params); $stmt->execute($params);
return $stmt->rowCount() > 0; $__r = $stmt->rowCount() > 0;
audit_change($pdo, 'team', 'team', 'id', (int) $id, 'update', $__before, 'name');
return $__r;
} }
// Soft delete para a lixeira (config/functions_trash.php). // Soft delete para a lixeira (config/functions_trash.php).
+5
View File
@@ -59,6 +59,11 @@ function trash_types(): array {
'title' => "CONCAT(t.name, COALESCE(CONCAT(' — ', (SELECT e.title FROM events e WHERE e.e_id = t.e_id)), ''))", 'title' => "CONCAT(t.name, COALESCE(CONCAT(' — ', (SELECT e.title FROM events e WHERE e.e_id = t.e_id)), ''))",
'edit' => null, 'personal' => true, 'edit' => null, 'personal' => true,
], ],
'trial_lead' => [
'label' => 'Pedido de aula experimental', 'table' => 'trial_leads', 'pk' => 'id',
'title' => "CONCAT(t.name, IF(t.phone <> '', CONCAT(' — ', t.phone), ''))",
'edit' => null, 'personal' => true,
],
'membership_lead' => [ 'membership_lead' => [
'label' => 'Pedido de inscrição', 'table' => 'membership_leads', 'pk' => 'id', 'label' => 'Pedido de inscrição', 'table' => 'membership_leads', 'pk' => 'id',
'title' => "CONCAT(t.name, IF(t.membership_name <> '', CONCAT(' — ', t.membership_name), ''))", 'title' => "CONCAT(t.name, IF(t.membership_name <> '', CONCAT(' — ', t.membership_name), ''))",
+80
View File
@@ -0,0 +1,80 @@
<?php
// ─── Pedidos de aula experimental ────────────────────────────────────────────
// Guardados por site/agendar.php ANTES de se tentar o email, para nenhum
// pedido depender do SMTP. Geridos em Gestão → Aulas experimentais
// (views/admin/trials/index.php), por ginásio. Apagar vai para a lixeira.
// Schema: database/migration_20260926_trial_leads.sql.
function trial_lead_statuses(): array {
return ['novo' => 'Novo', 'contactado' => 'Contactado', 'marcado' => 'Aula marcada', 'descartado' => 'Descartado'];
}
function db_trial_lead_create(PDO $pdo, array $d): int {
$stmt = $pdo->prepare("
INSERT INTO trial_leads (gym_id, name, email, phone, preferred_date, period, message,
consent, consent_text, ip, user_agent)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
");
$stmt->execute([
(int) ($d['gym_id'] ?? 1),
mb_substr(trim((string) ($d['name'] ?? '')), 0, 150),
mb_substr(trim((string) ($d['email'] ?? '')), 0, 190),
mb_substr(trim((string) ($d['phone'] ?? '')), 0, 40),
($d['preferred_date'] ?? '') !== '' ? $d['preferred_date'] : null,
mb_substr(trim((string) ($d['period'] ?? '')), 0, 40),
mb_substr(trim((string) ($d['message'] ?? '')), 0, 2000),
!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_trial_lead_mark_emailed(PDO $pdo, int $id): void {
$pdo->prepare("UPDATE trial_leads SET email_sent = 1 WHERE id = ?")->execute([$id]);
}
function db_trial_leads_list(PDO $pdo, int $gymId, string $status = ''): array {
$sql = "SELECT * FROM trial_leads WHERE deleted_at IS NULL AND gym_id = ?";
$args = [$gymId];
if ($status !== '' && isset(trial_lead_statuses()[$status])) { $sql .= " AND status = ?"; $args[] = $status; }
$sql .= " ORDER BY created_at DESC, id DESC LIMIT 1000";
$stmt = $pdo->prepare($sql);
$stmt->execute($args);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
function db_trial_leads_count_new(PDO $pdo, int $gymId): int {
try {
$st = $pdo->prepare("SELECT COUNT(*) FROM trial_leads WHERE status = 'novo' AND deleted_at IS NULL AND gym_id = ?");
$st->execute([$gymId]);
return (int) $st->fetchColumn();
} catch (Throwable $e) { return 0; }
}
function db_trial_lead_update(PDO $pdo, int $id, int $gymId, string $status, string $notes): bool {
if (!isset(trial_lead_statuses()[$status])) return false;
$before = $pdo->prepare("SELECT status, admin_notes, name FROM trial_leads WHERE id = ? AND gym_id = ? AND deleted_at IS NULL");
$before->execute([$id, $gymId]);
$old = $before->fetch(PDO::FETCH_ASSOC);
if (!$old) return false;
$stmt = $pdo->prepare("UPDATE trial_leads SET status = ?, admin_notes = ?, updated_by = ? WHERE id = ? AND gym_id = ?");
$stmt->execute([$status, $notes !== '' ? $notes : null, (int) ($_SESSION['user_id'] ?? 0) ?: null, $id, $gymId]);
audit('trial_lead.update', [
'target_type' => 'trial_lead', 'target_id' => $id, 'target_label' => (string) $old['name'],
'changes' => platform_audit_diff(
['status' => $old['status'], 'admin_notes' => (string) $old['admin_notes']],
['status' => $status, 'admin_notes' => $notes]
),
]);
return $stmt->rowCount() > 0;
}
// Vai para a lixeira — só se pertencer ao ginásio que se está a editar.
function db_trial_lead_delete(PDO $pdo, int $id, int $gymId): bool {
$own = $pdo->prepare("SELECT 1 FROM trial_leads WHERE id = ? AND gym_id = ?");
$own->execute([$id, $gymId]);
if (!$own->fetchColumn()) return false;
return trash_delete($pdo, 'trial_lead', $id);
}
+7 -2
View File
@@ -19,13 +19,18 @@ function db_whours_get(PDO $pdo, int $id): ?array {
function db_whours_create(PDO $pdo, array $data): int { function db_whours_create(PDO $pdo, array $data): int {
$stmt = $pdo->prepare("INSERT INTO work_hours (gym_id, week_day, open, close) VALUES (?, ?, ?, ?)"); $stmt = $pdo->prepare("INSERT INTO work_hours (gym_id, week_day, open, close) VALUES (?, ?, ?, ?)");
$stmt->execute([admin_gym_id(), $data['week_day'], $data['open'], $data['close']]); $stmt->execute([admin_gym_id(), $data['week_day'], $data['open'], $data['close']]);
return (int) $pdo->lastInsertId(); $__id = (int) $pdo->lastInsertId();
audit_change($pdo, 'work_hours', 'work_hours', 'id', (int) $__id, 'create', null, 'week_day');
return $__id;
} }
function db_whours_update(PDO $pdo, int $id, array $data): bool { function db_whours_update(PDO $pdo, int $id, array $data): bool {
$__before = audit_row($pdo, 'work_hours', 'id', (int) $id);
$stmt = $pdo->prepare("UPDATE work_hours SET week_day = ?, open = ?, close = ? WHERE id = ?"); $stmt = $pdo->prepare("UPDATE work_hours SET week_day = ?, open = ?, close = ? WHERE id = ?");
$stmt->execute([$data['week_day'], $data['open'], $data['close'], $id]); $stmt->execute([$data['week_day'], $data['open'], $data['close'], $id]);
return $stmt->rowCount() > 0; $__r = $stmt->rowCount() > 0;
audit_change($pdo, 'work_hours', 'work_hours', 'id', (int) $id, 'update', $__before, 'week_day');
return $__r;
} }
// Soft delete para a lixeira (config/functions_trash.php). // Soft delete para a lixeira (config/functions_trash.php).
@@ -0,0 +1,51 @@
-- ─── Pedidos de aula experimental + registo do anti-spam ────────────────────
-- Até aqui o pedido de aula experimental só existia como email: sem SMTP (ou
-- com o SMTP em baixo) o visitante via «erro» e o pedido perdia-se. Agora fica
-- guardado primeiro, como as inscrições, e o email é só um aviso.
--
-- blocked_submissions: onde o anti-spam partilhado (platform-tools,
-- functions_antispam_forms.php) guarda o que recusou — um falso positivo é uma
-- lead verdadeira, e tem de se poder ler. Esquema igual ao do dance.
--
-- Só cria tabelas. Safe to re-run.
CREATE TABLE IF NOT EXISTS `trial_leads` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`gym_id` INT NOT NULL DEFAULT 1,
`name` VARCHAR(150) NOT NULL,
`email` VARCHAR(190) NOT NULL DEFAULT '',
`phone` VARCHAR(40) NOT NULL DEFAULT '',
`preferred_date` DATE NULL DEFAULT NULL,
`period` VARCHAR(40) NOT NULL DEFAULT '',
`message` TEXT 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 '',
`status` VARCHAR(20) NOT NULL DEFAULT 'novo',
`admin_notes` TEXT NULL,
`email_sent` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`updated_by` INT UNSIGNED NULL DEFAULT NULL,
`deleted_at` DATETIME NULL DEFAULT NULL,
`deleted_by` INT UNSIGNED NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_trial_leads_gym` (`gym_id`, `deleted_at`, `status`),
KEY `idx_trial_leads_ip` (`ip`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `blocked_submissions` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`form` VARCHAR(40) NOT NULL COMMENT 'trial, inscricao, evento',
`reason` VARCHAR(190) NOT NULL COMMENT 'Regra que travou, ou os sinais somados',
`score` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Pontuação de conteúdo; 0 nas recusas diretas',
`ip` VARCHAR(45) NOT NULL DEFAULT '',
`user_agent` VARCHAR(250) NOT NULL DEFAULT '',
`payload` TEXT DEFAULT NULL COMMENT 'JSON do que foi submetido, sem o token CSRF',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_bs_form` (`form`),
KEY `idx_bs_ip` (`ip`),
KEY `idx_bs_created` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+6
View File
@@ -138,6 +138,12 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/memberships/') !== false) ? ' active' : '' ?>" <a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/memberships/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/memberships/index.php') ?>"><i class="bi bi-credit-card-2-front-fill"></i>Mensalidades</a> href="<?= base_url('views/admin/memberships/index.php') ?>"><i class="bi bi-credit-card-2-front-fill"></i>Mensalidades</a>
</li> </li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/trials/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/trials/index.php') ?>"><i class="bi bi-person-check-fill"></i>Aulas experimentais<?php
$__tn = function_exists('db_trial_leads_count_new') ? db_trial_leads_count_new($pdo, admin_gym_id()) : 0;
if ($__tn): ?> <span class="badge bg-danger ms-1"><?= (int) $__tn ?></span><?php endif; ?></a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/reviews/') !== false) ? ' active' : '' ?>" <a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/reviews/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/reviews/index.php') ?>"><i class="bi bi-star-fill"></i>Reviews Google</a> href="<?= base_url('views/admin/reviews/index.php') ?>"><i class="bi bi-star-fill"></i>Reviews Google</a>
+36 -1
View File
@@ -37,6 +37,12 @@ if ($nome === '' || $telefone === '' || !filter_var($email, FILTER_VALIDATE_EMAI
agendar_redirect('erro'); agendar_redirect('erro');
} }
// Anti-spam partilhado (config/functions_forms.php). Recusado = mesma resposta
// de sucesso; fica em blocked_submissions.
if (public_form_screen($pdo, 'trial', 'trial_leads', $nome, $mensagem) !== null) {
agendar_redirect('ok');
}
// Pretty-print the preferred date if it parses; otherwise leave it blank. // Pretty-print the preferred date if it parses; otherwise leave it blank.
$data_fmt = ''; $data_fmt = '';
if ($data !== '') { if ($data !== '') {
@@ -44,6 +50,29 @@ if ($data !== '') {
$data_fmt = ($d && $d->format('Y-m-d') === $data) ? $d->format('d/m/Y') : ''; $data_fmt = ($d && $d->format('Y-m-d') === $data) ? $d->format('d/m/Y') : '';
} }
// O pedido fica guardado ANTES do email: sem SMTP (ou com ele em baixo) o
// pedido já não se perde — aparece em Gestão → Aulas experimentais.
$consent_text = 'Li e aceito a Política de Privacidade.';
$leadId = 0;
try {
$leadId = db_trial_lead_create($pdo, [
'gym_id' => (int) $gym['id'],
'name' => $nome,
'email' => $email,
'phone' => $telefone,
'preferred_date' => $data_fmt !== '' ? $data : '',
'period' => $periodo,
'message' => $mensagem,
'consent' => !empty($_POST['consent']),
'consent_text' => !empty($_POST['consent']) ? $consent_text : '',
'ip' => client_ip(),
'user_agent' => (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''),
]);
} catch (Throwable $e) {
// Tabela por migrar: segue só por email, como antes.
error_log('[agendar] ' . $e->getMessage());
}
$info = site_info($pdo, (int) $gym['id']); $info = site_info($pdo, (int) $gym['id']);
$to = $info['email'] !== '' ? $info['email'] : EMAIL_FROM; $to = $info['email'] !== '' ? $info['email'] : EMAIL_FROM;
@@ -81,4 +110,10 @@ $id = enqueue_email([
'context_type' => 'fitme_trial', 'context_type' => 'fitme_trial',
]); ]);
agendar_redirect(flush_message($id) ? 'ok' : 'erro'); $sent = false;
try { $sent = flush_message($id); } catch (Throwable $e) { error_log('[agendar] email: ' . $e->getMessage()); }
if ($sent && $leadId) db_trial_lead_mark_emailed($pdo, $leadId);
// Guardado = sucesso para o visitante, mesmo que o email falhe (fica na fila e
// o pedido está no backoffice). Só é erro se não ficou em lado nenhum.
agendar_redirect(($leadId || $sent) ? 'ok' : 'erro');
+5 -4
View File
@@ -21,8 +21,10 @@ if ($can_view && $_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check(); csrf_check();
$back = base_url('evento/' . rawurlencode($event['slug'])); $back = base_url('evento/' . rawurlencode($event['slug']));
// Honeypot: a filled "website" field means a bot — silently accept & drop. // Anti-spam partilhado (config/functions_forms.php). Recusado = mesma
if (trim((string) ($_POST['website'] ?? '')) !== '') { // resposta de sucesso; fica em blocked_submissions.
if (public_form_screen($pdo, 'evento', 'event_leads',
(string) ($_POST['lead_name'] ?? ''), (string) ($_POST['lead_message'] ?? '')) !== null) {
header('Location: ' . $back . '?ok=1#inscricao'); header('Location: ' . $back . '?ok=1#inscricao');
exit; exit;
} }
@@ -197,8 +199,7 @@ require __DIR__ . '/_open.php';
<form method="POST" action="<?= base_url('evento/' . rawurlencode($event['slug'])) ?>#inscricao" class="event-form"> <form method="POST" action="<?= base_url('evento/' . rawurlencode($event['slug'])) ?>#inscricao" class="event-form">
<?= csrf_field() ?> <?= csrf_field() ?>
<!-- honeypot: must stay empty --> <?= public_form_trap('evento') /* honeypot + carimbo + prova de JS */ ?>
<input type="text" name="website" tabindex="-1" autocomplete="off" class="event-hp" aria-hidden="true">
<div class="event-form-grid"> <div class="event-form-grid">
<div class="event-field"> <div class="event-field">
<label>Nome *</label> <label>Nome *</label>
+3 -2
View File
@@ -754,6 +754,7 @@ if ($on('personal_trainer')):
</div> </div>
<form class="trial-form" method="post" action="<?= htmlspecialchars(gym_url($gym, 'agendar')) ?>"> <form class="trial-form" method="post" action="<?= htmlspecialchars(gym_url($gym, 'agendar')) ?>">
<?= csrf_field() ?> <?= csrf_field() ?>
<?= public_form_trap('trial') /* honeypot + carimbo + prova de JS */ ?>
<div class="form-row"> <div class="form-row">
<div class="col-12 col-md-6 mb-3"> <div class="col-12 col-md-6 mb-3">
<input type="text" name="nome" required placeholder="Nome *" aria-label="Nome"> <input type="text" name="nome" required placeholder="Nome *" aria-label="Nome">
@@ -780,7 +781,7 @@ if ($on('personal_trainer')):
</div> </div>
<div class="col-12"> <div class="col-12">
<label class="consent-check"> <label class="consent-check">
<input type="checkbox" required> <input type="checkbox" name="consent" value="1" required>
<span>Li e aceito a <a href="<?= base_url('home/privacidade') ?>" target="_blank" rel="noopener">Política de Privacidade</a>.</span> <span>Li e aceito a <a href="<?= base_url('home/privacidade') ?>" target="_blank" rel="noopener">Política de Privacidade</a>.</span>
</label> </label>
</div> </div>
@@ -813,7 +814,7 @@ if ($memberships):
</div> </div>
<form class="trial-form" method="post" action="<?= htmlspecialchars(gym_url($gym, 'inscricao')) ?>"> <form class="trial-form" method="post" action="<?= htmlspecialchars(gym_url($gym, 'inscricao')) ?>">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="text" name="website" class="event-hp" tabindex="-1" autocomplete="off" aria-hidden="true"> <?= public_form_trap('inscricao') /* honeypot + carimbo + prova de JS */ ?>
<div class="form-row"> <div class="form-row">
<div class="col-12 mb-3"> <div class="col-12 mb-3">
<select name="membership_id" aria-label="Mensalidade" required> <select name="membership_id" aria-label="Mensalidade" required>
+7 -2
View File
@@ -27,8 +27,13 @@ function inscricao_redirect(string $status): void {
exit; exit;
} }
// Honeypot: a filled "website" field means a bot — pretend success, store nothing. // Anti-spam partilhado (config/functions_forms.php): honeypot, carimbo com
if (trim((string) ($_POST['website'] ?? '')) !== '') inscricao_redirect('ok'); // tempo mínimo, prova de JS, limite por IP e conteúdo. Recusado = mesma
// resposta de sucesso; fica em blocked_submissions.
if (public_form_screen($pdo, 'inscricao', 'membership_leads',
(string) ($_POST['nome'] ?? ''), (string) ($_POST['mensagem'] ?? '')) !== null) {
inscricao_redirect('ok');
}
$consent_text = 'Autorizo o contacto e o tratamento dos meus dados para efeitos desta inscrição, nos termos da Política de Privacidade.'; $consent_text = 'Autorizo o contacto e o tratamento dos meus dados para efeitos desta inscrição, nos termos da Política de Privacidade.';
$membership = db_memberships_get($pdo, (int) ($_POST['membership_id'] ?? 0)); $membership = db_memberships_get($pdo, (int) ($_POST['membership_id'] ?? 0));
+2
View File
@@ -30,6 +30,7 @@ include __DIR__ . '/../../../includes/header.php';
<div class="card shadow-sm"><div class="card-body p-0"> <div class="card shadow-sm"><div class="card-body p-0">
<form method="POST" class="mb-0"><?= csrf_field() ?> <form method="POST" class="mb-0"><?= csrf_field() ?>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
<thead class="table-light"><tr> <thead class="table-light"><tr>
<th>Ginásio</th><th>Endereço</th><th>Estado</th><th>Secções</th><th style="width:150px">Ações</th> <th>Ginásio</th><th>Endereço</th><th>Estado</th><th>Secções</th><th style="width:150px">Ações</th>
@@ -63,6 +64,7 @@ include __DIR__ . '/../../../includes/header.php';
<?php endforeach; endif; ?> <?php endforeach; endif; ?>
</tbody> </tbody>
</table> </table>
</div>
</form> </form>
</div></div> </div></div>
</div> </div>
+2
View File
@@ -30,6 +30,7 @@ $dias = ['segunda'=>'Segunda','terça'=>'Terça','quarta'=>'Quarta','quinta'=>'Q
</div> </div>
<div class="card shadow-sm"><div class="card-body p-0"> <div class="card shadow-sm"><div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
<thead class="table-light"><tr> <thead class="table-light"><tr>
<th>Modalidade</th><th>Dia</th><th>Início</th><th>Fim</th><th>Sala</th><th style="width:120px">Ações</th> <th>Modalidade</th><th>Dia</th><th>Início</th><th>Fim</th><th>Sala</th><th style="width:120px">Ações</th>
@@ -56,6 +57,7 @@ $dias = ['segunda'=>'Segunda','terça'=>'Terça','quarta'=>'Quarta','quinta'=>'Q
<?php endforeach; endif; ?> <?php endforeach; endif; ?>
</tbody> </tbody>
</table> </table>
</div>
</div></div> </div></div>
</div> </div>
<?php include __DIR__ . '/../../../includes/footer.php'; <?php include __DIR__ . '/../../../includes/footer.php';
+2
View File
@@ -35,6 +35,7 @@ include __DIR__ . '/../../../includes/header.php';
<div class="card shadow-sm"><div class="card-body p-0"> <div class="card shadow-sm"><div class="card-body p-0">
<form method="POST" class="mb-0"><?= csrf_field() ?> <form method="POST" class="mb-0"><?= csrf_field() ?>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
<thead class="table-light"><tr> <thead class="table-light"><tr>
<th>Nome</th><th>Preço</th><th>Estado</th><th>Ordem</th><th style="width:120px">Ações</th> <th>Nome</th><th>Preço</th><th>Estado</th><th>Ordem</th><th style="width:120px">Ações</th>
@@ -64,6 +65,7 @@ include __DIR__ . '/../../../includes/header.php';
<?php endforeach; endif; ?> <?php endforeach; endif; ?>
</tbody> </tbody>
</table> </table>
</div>
</form> </form>
</div></div> </div></div>
</div> </div>
+4
View File
@@ -26,6 +26,10 @@ $leads = db_membership_leads_list($pdo, $filter);
// CSV export of the current filter (Excel-friendly: UTF-8 BOM + ';'). // CSV export of the current filter (Excel-friendly: UTF-8 BOM + ';').
if (isset($_GET['csv'])) { if (isset($_GET['csv'])) {
audit('export.membership_leads', [
'target_type' => 'membership_lead', 'target_label' => 'CSV (' . count($leads) . ' registos)',
'summary' => 'Exportação CSV de pedidos de inscrição' . ($filter !== '' ? ' — estado: ' . $filter : ''),
]);
header('Content-Type: text/csv; charset=utf-8'); header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="pedidos-inscricao-' . date('Y-m-d') . '.csv"'); header('Content-Disposition: attachment; filename="pedidos-inscricao-' . date('Y-m-d') . '.csv"');
$out = fopen('php://output', 'w'); $out = fopen('php://output', 'w');
+2
View File
@@ -45,6 +45,7 @@ include __DIR__ . '/../../../includes/header.php';
</div> </div>
<div class="card shadow-sm"><div class="card-body p-0"> <div class="card shadow-sm"><div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
<thead class="table-light"><tr> <thead class="table-light"><tr>
<th>Ícone</th><th>Nome</th><th>Cor</th><th>Ordem</th><th style="width:120px">Ações</th> <th>Ícone</th><th>Nome</th><th>Cor</th><th>Ordem</th><th style="width:120px">Ações</th>
@@ -71,6 +72,7 @@ include __DIR__ . '/../../../includes/header.php';
<?php endforeach; endif; ?> <?php endforeach; endif; ?>
</tbody> </tbody>
</table> </table>
</div>
</div></div> </div></div>
</div> </div>
<?php include __DIR__ . '/../../../includes/footer.php'; <?php include __DIR__ . '/../../../includes/footer.php';
+2
View File
@@ -43,6 +43,7 @@ include __DIR__ . '/../../../includes/header.php';
</div> </div>
<div class="card shadow-sm"><div class="card-body p-0"> <div class="card shadow-sm"><div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
<thead class="table-light"><tr> <thead class="table-light"><tr>
<th style="width:60px">Foto</th><th>Nome</th><th>Área</th><th>Estado</th><th style="width:120px">Ações</th> <th style="width:60px">Foto</th><th>Nome</th><th>Área</th><th>Estado</th><th style="width:120px">Ações</th>
@@ -70,6 +71,7 @@ include __DIR__ . '/../../../includes/header.php';
<?php endforeach; endif; ?> <?php endforeach; endif; ?>
</tbody> </tbody>
</table> </table>
</div>
</div></div> </div></div>
</div> </div>
<?php include __DIR__ . '/../../../includes/footer.php'; <?php include __DIR__ . '/../../../includes/footer.php';
+119
View File
@@ -0,0 +1,119 @@
<?php
require __DIR__ . '/../../../config/db.php';
require __DIR__ . '/../../../config/functions.php';
$user = require_admin();
// Pedidos de aula experimental do ginásio que se está a editar (seletor
// «A editar» no topo). Guardados por site/agendar.php antes do email.
$gymId = admin_gym_id();
$statuses = trial_lead_statuses();
$filter = (string) ($_GET['status'] ?? '');
if (!isset($statuses[$filter])) $filter = '';
$self = base_url('views/admin/trials/index.php' . ($filter !== '' ? '?status=' . $filter : ''));
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
if (isset($_POST['delete'])) {
db_trial_lead_delete($pdo, (int) $_POST['delete'], $gymId)
? session()->setFlashdata('success', 'Pedido apagado — fica na lixeira 30 dias.')
: session()->setFlashdata('error', 'Pedido não encontrado neste ginásio.');
} elseif (isset($_POST['save'])) {
$id = (int) $_POST['save'];
db_trial_lead_update($pdo, $id, $gymId, (string) ($_POST['status'][$id] ?? ''), trim((string) ($_POST['notes'][$id] ?? '')));
session()->setFlashdata('success', 'Pedido atualizado.');
}
header('Location: ' . $self);
exit;
}
$leads = db_trial_leads_list($pdo, $gymId, $filter);
// CSV do filtro atual (Excel: BOM UTF-8 + ';'). Dados pessoais — fica no audit.
if (isset($_GET['csv'])) {
audit('export.trial_leads', [
'target_type' => 'trial_lead', 'target_label' => 'CSV (' . count($leads) . ' registos)',
'summary' => 'Exportação CSV de pedidos de aula experimental' . ($filter !== '' ? ' — estado: ' . $filter : ''),
]);
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="aulas-experimentais-' . date('Y-m-d') . '.csv"');
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, ['Data do pedido', 'Nome', 'Telemóvel', 'E-mail', 'Dia preferido', 'Período', 'Mensagem', 'Estado', 'Notas', 'Consentimento'], ';', '"', '');
foreach ($leads as $l) {
fputcsv($out, [
date('d/m/Y H:i', strtotime($l['created_at'])), $l['name'], $l['phone'], $l['email'],
$l['preferred_date'] ? date('d/m/Y', strtotime($l['preferred_date'])) : '', $l['period'], (string) $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', 'marcado' => 'bg-success', 'descartado' => 'bg-secondary'];
$page_title = 'Aulas experimentais';
include __DIR__ . '/../../../includes/header.php';
?>
<div class="container">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<h1 class="h3 mb-0">Aulas experimentais</h1>
<a class="btn btn-outline-secondary" href="<?= $self . ($filter !== '' ? '&' : '?') ?>csv=1"><i class="bi bi-filetype-csv me-1"></i>Exportar CSV</a>
</div>
<p class="text-muted small">Pedidos feitos no botão «Marca a tua aula experimental». Ficam guardados aqui mesmo que o email de aviso não chegue.</p>
<ul class="nav nav-pills mb-3 flex-wrap">
<li class="nav-item"><a class="nav-link<?= $filter === '' ? ' active' : '' ?>" href="?">Todos</a></li>
<?php foreach ($statuses as $k => $label): ?>
<li class="nav-item"><a class="nav-link<?= $filter === $k ? ' active' : '' ?>" href="?status=<?= htmlspecialchars($k) ?>"><?= htmlspecialchars($label) ?></a></li>
<?php endforeach; ?>
</ul>
<form method="POST" class="card shadow-sm"><div class="card-body p-0">
<?= csrf_field() ?>
<div class="table-responsive">
<table class="table align-middle mb-0">
<thead class="table-light"><tr>
<th>Pedido</th><th>Contacto</th><th>Preferência</th><th>Mensagem</th><th style="min-width:220px">Estado / notas</th><th></th>
</tr></thead>
<tbody>
<?php if (!$leads): ?>
<tr><td colspan="6" class="text-center text-muted py-4">Sem pedidos.</td></tr>
<?php else: foreach ($leads as $l): $id = (int) $l['id']; ?>
<tr>
<td class="small text-nowrap">
<?= htmlspecialchars(date('d/m/Y H:i', strtotime($l['created_at']))) ?>
<?php if (!$l['email_sent']): ?><div><span class="badge bg-warning text-dark" title="O email de aviso não saiu — o pedido só existe aqui">sem email</span></div><?php endif; ?>
</td>
<td class="small">
<strong><?= htmlspecialchars($l['name']) ?></strong>
<?php if ($l['phone'] !== ''): ?><div><a href="tel:<?= htmlspecialchars(preg_replace('/\s+/', '', $l['phone'])) ?>"><?= htmlspecialchars($l['phone']) ?></a></div><?php endif; ?>
<?php if ($l['email'] !== ''): ?><div><a href="mailto:<?= htmlspecialchars($l['email']) ?>"><?= htmlspecialchars($l['email']) ?></a></div><?php endif; ?>
<?php if ($l['consent']): ?><span class="badge bg-success mt-1" title="<?= htmlspecialchars($l['consent_text'] . ' · IP ' . $l['ip']) ?>">RGPD <i class="bi bi-check-lg"></i></span><?php endif; ?>
</td>
<td class="small text-nowrap">
<?= $l['preferred_date'] ? htmlspecialchars(date('d/m/Y', strtotime($l['preferred_date']))) : '<span class="text-muted">—</span>' ?>
<?php if ($l['period'] !== ''): ?><div class="text-muted"><?= htmlspecialchars($l['period']) ?></div><?php endif; ?>
</td>
<td class="small" style="max-width:260px;"><?= (string) $l['message'] !== '' ? nl2br(htmlspecialchars((string) $l['message'])) : '<span class="text-muted">—</span>' ?></td>
<td>
<div class="d-flex gap-1 mb-1">
<select class="form-select form-select-sm" name="status[<?= $id ?>]" aria-label="Estado">
<?php foreach ($statuses as $k => $label): ?>
<option value="<?= htmlspecialchars($k) ?>" <?= $l['status'] === $k ? 'selected' : '' ?>><?= htmlspecialchars($label) ?></option>
<?php endforeach; ?>
</select>
<span class="badge <?= $badge[$l['status']] ?? 'bg-secondary' ?> align-self-center"><?= htmlspecialchars($statuses[$l['status']] ?? $l['status']) ?></span>
</div>
<textarea class="form-control form-control-sm" name="notes[<?= $id ?>]" rows="2" placeholder="Notas internas" aria-label="Notas internas"><?= htmlspecialchars((string) $l['admin_notes']) ?></textarea>
</td>
<td class="text-nowrap">
<button class="btn btn-sm btn-outline-primary" name="save" value="<?= $id ?>" title="Guardar" aria-label="Guardar"><i class="bi bi-check-lg"></i></button>
<button class="btn btn-sm btn-outline-danger" name="delete" value="<?= $id ?>" title="Apagar" aria-label="Apagar" onclick="return confirm('Apagar este pedido? Fica na lixeira durante 30 dias.')"><i class="bi bi-trash"></i></button>
</td>
</tr>
<?php endforeach; endif; ?>
</tbody>
</table>
</div>
</div></form>
</div>
<?php include __DIR__ . '/../../../includes/footer.php';
+2
View File
@@ -26,6 +26,7 @@ $dias = ['segunda'=>'Segunda','terça'=>'Terça','quarta'=>'Quarta','quinta'=>'Q
</div> </div>
<div class="card shadow-sm"><div class="card-body p-0"> <div class="card shadow-sm"><div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
<thead class="table-light"><tr> <thead class="table-light"><tr>
<th>Dia</th><th>Abertura</th><th>Fecho</th><th style="width:120px">Ações</th> <th>Dia</th><th>Abertura</th><th>Fecho</th><th style="width:120px">Ações</th>
@@ -47,6 +48,7 @@ $dias = ['segunda'=>'Segunda','terça'=>'Terça','quarta'=>'Quarta','quinta'=>'Q
<?php endforeach; endif; ?> <?php endforeach; endif; ?>
</tbody> </tbody>
</table> </table>
</div>
</div></div> </div></div>
</div> </div>
<?php include __DIR__ . '/../../../includes/footer.php'; <?php include __DIR__ . '/../../../includes/footer.php';