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:
1 parent
0e8b53f620
commit
04c8c83e66
29 files changed
+480
-29
No files matched your search
@@ -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_memberships.php'; // mensalidades + pedidos de inscrição
|
||||
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 ───────────────────────────────────
|
||||
// Resolved once from the settings cache so call sites read plain constants.
|
||||
|
||||
@@ -23,3 +23,37 @@ function audit_mask_email(string $email): string {
|
||||
[$u, $d] = explode('@', $email, 2);
|
||||
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,
|
||||
]);
|
||||
}
|
||||
@@ -128,10 +128,13 @@ function db_events_create(PDO $pdo, array $data): int {
|
||||
str_replace('T', ' ', (string) $d['publish_until']),
|
||||
(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 {
|
||||
$__before = audit_row($pdo, 'events', 'e_id', (int) $id);
|
||||
$d = array_merge(events_defaults(), $data);
|
||||
$stmt = $pdo->prepare("
|
||||
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'],
|
||||
$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).
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,18 @@ function db_gallery_get(PDO $pdo, int $id): ?array {
|
||||
function db_gallery_create(PDO $pdo, string $name): int {
|
||||
$stmt = $pdo->prepare("INSERT INTO galleries (name) VALUES (?)");
|
||||
$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 {
|
||||
$__before = audit_row($pdo, 'galleries', 'id', (int) $id);
|
||||
$stmt = $pdo->prepare("UPDATE galleries SET name = ? WHERE 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
|
||||
|
||||
@@ -199,10 +199,13 @@ function db_gyms_create(PDO $pdo, array $d): int {
|
||||
facebook, messenger, email, website, google_maps, google_maps_link)
|
||||
VALUES (?, ?, '', '', '', '', '', '', '', '', '', '', '')")->execute([$id, $d['name']]);
|
||||
$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 {
|
||||
$__before = audit_row($pdo, 'gyms', 'id', (int) $id);
|
||||
$pdo->beginTransaction();
|
||||
$params = _gyms_params($d); $params[] = $id;
|
||||
$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;
|
||||
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
|
||||
$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).
|
||||
|
||||
@@ -42,10 +42,13 @@ function db_horario_create(PDO $pdo, array $data): int {
|
||||
$data['hora_fim'],
|
||||
$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 {
|
||||
$__before = audit_row($pdo, 'horario', 'id', (int) $id);
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE horario
|
||||
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'] ?? '',
|
||||
$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).
|
||||
@@ -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
|
||||
// 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 {
|
||||
$__before = audit_row($pdo, 'horario', 'id', (int) $id);
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE horario SET dia_semana = ?, hora_inicio = ?, hora_fim = ? WHERE 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.
|
||||
|
||||
@@ -45,6 +45,7 @@ function db_info_save(PDO $pdo, array $data): bool {
|
||||
$cols = array_keys(info_fields());
|
||||
// One row per gym (id = gyms.id): the gym being edited in the backoffice.
|
||||
$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();
|
||||
$vals = array_map(fn($c) => $data[$c] ?? '', $cols);
|
||||
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->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;
|
||||
}
|
||||
@@ -93,10 +93,13 @@ function db_memberships_create(PDO $pdo, array $d): int {
|
||||
$id = (int) $pdo->lastInsertId();
|
||||
if ($d['is_featured']) _memberships_clear_featured($pdo, $id);
|
||||
$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 {
|
||||
$__before = audit_row($pdo, 'memberships', 'id', (int) $id);
|
||||
$pdo->beginTransaction();
|
||||
$stmt = $pdo->prepare("
|
||||
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;
|
||||
if ($d['is_featured']) _memberships_clear_featured($pdo, $id);
|
||||
$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
|
||||
@@ -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 {
|
||||
$__before = audit_row($pdo, 'membership_leads', 'id', (int) $id);
|
||||
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;
|
||||
$__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).
|
||||
|
||||
@@ -30,10 +30,13 @@ function db_modalidades_create(PDO $pdo, array $data): int {
|
||||
$data['descricao'] ?? '',
|
||||
(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 {
|
||||
$__before = audit_row($pdo, 'modalidades', 'm_id', (int) $id);
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE modalidades
|
||||
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),
|
||||
$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).
|
||||
|
||||
@@ -101,7 +101,7 @@ function db_update_setting(PDO $pdo, string $key, string $value, ?int $userId =
|
||||
if ($old !== $value && function_exists('platform_audit')) {
|
||||
platform_audit($pdo, 'settings.update', [
|
||||
'target_type' => 'setting', 'target_id' => $key, 'target_label' => $key,
|
||||
'changes' => [$key => ['antes' => $old, 'depois' => $value]],
|
||||
'changes' => [$key => ['from' => $old, 'to' => $value]],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -34,10 +34,13 @@ function db_team_create(PDO $pdo, array $data): int {
|
||||
($data['certifications'] ?? '') !== '' ? $data['certifications'] : 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 {
|
||||
$__before = audit_row($pdo, 'team', 'id', (int) $id);
|
||||
$sets = ["name = ?", "title = ?", "birthday = ?", "`at` = ?", "status = ?", "description = ?",
|
||||
"skills = ?", "certifications = ?", "updated_at = NOW()"];
|
||||
$params = [
|
||||
@@ -57,7 +60,9 @@ function db_team_update(PDO $pdo, int $id, array $data, ?string $photo = null):
|
||||
$params[] = $id;
|
||||
$stmt = $pdo->prepare("UPDATE team SET " . implode(', ', $sets) . " WHERE id = ?");
|
||||
$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).
|
||||
|
||||
@@ -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)), ''))",
|
||||
'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' => [
|
||||
'label' => 'Pedido de inscrição', 'table' => 'membership_leads', 'pk' => 'id',
|
||||
'title' => "CONCAT(t.name, IF(t.membership_name <> '', CONCAT(' — ', t.membership_name), ''))",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -19,13 +19,18 @@ function db_whours_get(PDO $pdo, int $id): ?array {
|
||||
function db_whours_create(PDO $pdo, array $data): int {
|
||||
$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']]);
|
||||
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 {
|
||||
$__before = audit_row($pdo, 'work_hours', 'id', (int) $id);
|
||||
$stmt = $pdo->prepare("UPDATE work_hours SET week_day = ?, open = ?, close = ? WHERE 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).
|
||||
|
||||
@@ -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;
|
||||
@@ -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' : '' ?>"
|
||||
href="<?= base_url('views/admin/memberships/index.php') ?>"><i class="bi bi-credit-card-2-front-fill"></i>Mensalidades</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/trials/') !== false) ? ' active' : '' ?>"
|
||||
href="<?= base_url('views/admin/trials/index.php') ?>"><i class="bi bi-person-check-fill"></i>Aulas experimentais<?php
|
||||
$__tn = function_exists('db_trial_leads_count_new') ? db_trial_leads_count_new($pdo, admin_gym_id()) : 0;
|
||||
if ($__tn): ?> <span class="badge bg-danger ms-1"><?= (int) $__tn ?></span><?php endif; ?></a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/reviews/') !== false) ? ' active' : '' ?>"
|
||||
href="<?= base_url('views/admin/reviews/index.php') ?>"><i class="bi bi-star-fill"></i>Reviews Google</a>
|
||||
|
||||
+36
-1
@@ -37,6 +37,12 @@ if ($nome === '' || $telefone === '' || !filter_var($email, FILTER_VALIDATE_EMAI
|
||||
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.
|
||||
$data_fmt = '';
|
||||
if ($data !== '') {
|
||||
@@ -44,6 +50,29 @@ if ($data !== '') {
|
||||
$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']);
|
||||
$to = $info['email'] !== '' ? $info['email'] : EMAIL_FROM;
|
||||
|
||||
@@ -81,4 +110,10 @@ $id = enqueue_email([
|
||||
'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
@@ -21,8 +21,10 @@ if ($can_view && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_check();
|
||||
$back = base_url('evento/' . rawurlencode($event['slug']));
|
||||
|
||||
// Honeypot: a filled "website" field means a bot — silently accept & drop.
|
||||
if (trim((string) ($_POST['website'] ?? '')) !== '') {
|
||||
// Anti-spam partilhado (config/functions_forms.php). Recusado = mesma
|
||||
// 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');
|
||||
exit;
|
||||
}
|
||||
@@ -197,8 +199,7 @@ require __DIR__ . '/_open.php';
|
||||
|
||||
<form method="POST" action="<?= base_url('evento/' . rawurlencode($event['slug'])) ?>#inscricao" class="event-form">
|
||||
<?= csrf_field() ?>
|
||||
<!-- honeypot: must stay empty -->
|
||||
<input type="text" name="website" tabindex="-1" autocomplete="off" class="event-hp" aria-hidden="true">
|
||||
<?= public_form_trap('evento') /* honeypot + carimbo + prova de JS */ ?>
|
||||
<div class="event-form-grid">
|
||||
<div class="event-field">
|
||||
<label>Nome *</label>
|
||||
|
||||
+3
-2
@@ -754,6 +754,7 @@ if ($on('personal_trainer')):
|
||||
</div>
|
||||
<form class="trial-form" method="post" action="<?= htmlspecialchars(gym_url($gym, 'agendar')) ?>">
|
||||
<?= csrf_field() ?>
|
||||
<?= public_form_trap('trial') /* honeypot + carimbo + prova de JS */ ?>
|
||||
<div class="form-row">
|
||||
<div class="col-12 col-md-6 mb-3">
|
||||
<input type="text" name="nome" required placeholder="Nome *" aria-label="Nome">
|
||||
@@ -780,7 +781,7 @@ if ($on('personal_trainer')):
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
@@ -813,7 +814,7 @@ if ($memberships):
|
||||
</div>
|
||||
<form class="trial-form" method="post" action="<?= htmlspecialchars(gym_url($gym, 'inscricao')) ?>">
|
||||
<?= 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="col-12 mb-3">
|
||||
<select name="membership_id" aria-label="Mensalidade" required>
|
||||
|
||||
+7
-2
@@ -27,8 +27,13 @@ function inscricao_redirect(string $status): void {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Honeypot: a filled "website" field means a bot — pretend success, store nothing.
|
||||
if (trim((string) ($_POST['website'] ?? '')) !== '') inscricao_redirect('ok');
|
||||
// Anti-spam partilhado (config/functions_forms.php): honeypot, carimbo com
|
||||
// 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.';
|
||||
$membership = db_memberships_get($pdo, (int) ($_POST['membership_id'] ?? 0));
|
||||
|
||||
@@ -30,6 +30,7 @@ include __DIR__ . '/../../../includes/header.php';
|
||||
|
||||
<div class="card shadow-sm"><div class="card-body p-0">
|
||||
<form method="POST" class="mb-0"><?= csrf_field() ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<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>
|
||||
@@ -63,6 +64,7 @@ include __DIR__ . '/../../../includes/header.php';
|
||||
<?php endforeach; endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ $dias = ['segunda'=>'Segunda','terça'=>'Terça','quarta'=>'Quarta','quinta'=>'Q
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm"><div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<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>
|
||||
@@ -56,6 +57,7 @@ $dias = ['segunda'=>'Segunda','terça'=>'Terça','quarta'=>'Quarta','quinta'=>'Q
|
||||
<?php endforeach; endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<?php include __DIR__ . '/../../../includes/footer.php';
|
||||
@@ -35,6 +35,7 @@ include __DIR__ . '/../../../includes/header.php';
|
||||
|
||||
<div class="card shadow-sm"><div class="card-body p-0">
|
||||
<form method="POST" class="mb-0"><?= csrf_field() ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<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>
|
||||
@@ -64,6 +65,7 @@ include __DIR__ . '/../../../includes/header.php';
|
||||
<?php endforeach; endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,10 @@ $leads = db_membership_leads_list($pdo, $filter);
|
||||
|
||||
// CSV export of the current filter (Excel-friendly: UTF-8 BOM + ';').
|
||||
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-Disposition: attachment; filename="pedidos-inscricao-' . date('Y-m-d') . '.csv"');
|
||||
$out = fopen('php://output', 'w');
|
||||
|
||||
@@ -45,6 +45,7 @@ include __DIR__ . '/../../../includes/header.php';
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm"><div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light"><tr>
|
||||
<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; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<?php include __DIR__ . '/../../../includes/footer.php';
|
||||
@@ -43,6 +43,7 @@ include __DIR__ . '/../../../includes/header.php';
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm"><div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<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>
|
||||
@@ -70,6 +71,7 @@ include __DIR__ . '/../../../includes/header.php';
|
||||
<?php endforeach; endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<?php include __DIR__ . '/../../../includes/footer.php';
|
||||
@@ -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';
|
||||
@@ -26,6 +26,7 @@ $dias = ['segunda'=>'Segunda','terça'=>'Terça','quarta'=>'Quarta','quinta'=>'Q
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm"><div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light"><tr>
|
||||
<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; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<?php include __DIR__ . '/../../../includes/footer.php';
|
||||
Reference in new issue
Block a user