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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:28:13 +01:00

207 lines
9.3 KiB
PHP

<?php
// ─── Página de origem das visitas ───────────────────────────────────────────
//
// Contrato do stub, como o backups.php: carregar $pdo, correr a guarda e
// definir $repo_root.
//
// A leitura útil é a coluna das conversões, não a das visitas. Mil visitas de
// uma origem que nunca produz um pedido valem menos do que trinta de um cartaz
// — e é a diferença entre as duas que diz onde vale a pena gastar dinheiro.
if (!isset($pdo) || !$pdo instanceof PDO) {
http_response_code(500);
exit('tracking.php: sem PDO — o arranque do site (config/db.php) tem de abrir a ligação primeiro.');
}
require_once __DIR__ . '/functions_tracking.php';
// As tabelas nascem com a primeira visita registada, porque só as funções de
// escrita as criavam. Numa plataforma acabada de ligar isso punha esta página
// a rebentar com "Table 'visit_sources' doesn't exist" -- um 500 exactamente no
// sítio onde se ia ver se a analítica estava a funcionar, e que se lê como
// avaria e não como "ainda não houve visitas". A chamada é idempotente e esta
// página é de sysadmin, por isso o custo do DDL não conta para nada.
platform_tracking_ensure_tables($pdo);
$days = (int) ($_GET['days'] ?? 30);
if (!in_array($days, [7, 30, 90, 365], true)) {
$days = 30;
}
// O próprio caminho, e não um base_url('views/sysadmin/...') fixo: o design
// põe as páginas partilhadas em views/admin/, e um caminho cravado aqui mandava
// os links desta página para um 404 nesse site.
$selfUrl = strtok((string) ($_SERVER['REQUEST_URI'] ?? ''), '?') ?: '';
$report = platform_tracking_report($pdo, $days);
$byForm = platform_tracking_conversions_by_form($pdo, $days);
$devices = platform_tracking_breakdown($pdo, 'device_type', $days);
$os = platform_tracking_breakdown($pdo, 'os', $days);
$browsers = platform_tracking_breakdown($pdo, 'browser', $days);
$countries = platform_tracking_breakdown($pdo, 'country', $days);
$referrers = platform_tracking_breakdown($pdo, 'referrer_host', $days, 10);
$hasGeoIndex = geo_country_index_available();
$totalVisits = array_sum(array_column($report, 'visits'));
$totalConv = array_sum(array_column($report, 'conversions'));
$layout_header = $platform_layout_header ?? ($repo_root . '/includes/header.php');
$layout_navbar = $platform_layout_navbar ?? ($repo_root . '/includes/navbar.php');
$layout_footer = $platform_layout_footer ?? ($repo_root . '/includes/footer.php');
$pageTitle = 'Origem das visitas';
if (is_file($layout_header)) include $layout_header;
if (is_file($layout_navbar)) include $layout_navbar;
$h = fn($s) => htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
// Barra proporcional — comparar números numa coluna é mais lento do que
// comparar comprimentos, e a pergunta aqui é sempre "qual é o maior".
$bar = function (int $n, int $max) use ($h): string {
$pct = $max > 0 ? max(2, (int) round($n / $max * 100)) : 0;
return '<div style="background:rgba(125,125,125,.15);border-radius:3px;height:6px;overflow:hidden;">'
. '<div style="width:' . $pct . '%;height:100%;background:currentColor;opacity:.55;"></div></div>';
};
$table = function (string $title, array $rows, callable $label) use ($h, $bar): string {
if (!$rows) {
return '<div class="card shadow-sm h-100"><div class="card-body">'
. '<h6 class="fw-semibold mb-2">' . $h($title) . '</h6>'
. '<p class="text-muted small mb-0">Sem dados no período.</p></div></div>';
}
$max = max(array_map(fn($r) => (int) $r['visits'], $rows));
$out = '<div class="card shadow-sm h-100"><div class="card-body">'
. '<h6 class="fw-semibold mb-3">' . $h($title) . '</h6>'
. '<table class="table table-sm align-middle mb-0"><tbody>';
foreach ($rows as $r) {
$out .= '<tr>'
. '<td style="width:45%">' . $label($r) . '</td>'
. '<td class="text-end text-muted small" style="width:4rem">' . (int) $r['visits'] . '</td>'
. '<td>' . $bar((int) $r['visits'], $max) . '</td>'
. '</tr>';
}
return $out . '</tbody></table></div></div>';
};
?>
<div class="container py-4" style="max-width:1000px;">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-4">
<div>
<h3 class="fw-bold mb-1"><i class="bi bi-graph-up-arrow me-2"></i>Origem das visitas</h3>
<p class="text-muted mb-0">De onde vêm as pessoas, com que dispositivo e de que país.</p>
</div>
<div class="btn-group btn-group-sm">
<?php foreach ([7 => '7 dias', 30 => '30 dias', 90 => '90 dias', 365 => '1 ano'] as $d => $lbl): ?>
<a href="<?= $h($selfUrl) ?>?days=<?= $d ?>"
class="btn btn-outline-secondary <?= $days === $d ? 'active' : '' ?>"><?= $h($lbl) ?></a>
<?php endforeach; ?>
</div>
</div>
<?php if (!$hasGeoIndex): ?>
<div class="alert alert-warning py-2 small">
<strong>Sem índice de países.</strong> A coluna geográfica fica vazia até correr
<code>shared/platform-tools/bin/geo-build-index.php</code>. O resto funciona na mesma.
</div>
<?php endif; ?>
<div class="row g-3 mb-4">
<div class="col-sm-4">
<div class="card shadow-sm h-100"><div class="card-body">
<div class="text-muted small">Visitas</div>
<div class="fs-3 fw-bold"><?= (int) $totalVisits ?></div>
</div></div>
</div>
<div class="col-sm-4">
<div class="card shadow-sm h-100"><div class="card-body">
<div class="text-muted small">Conversões</div>
<div class="fs-3 fw-bold"><?= (int) $totalConv ?></div>
</div></div>
</div>
<div class="col-sm-4">
<div class="card shadow-sm h-100"><div class="card-body">
<div class="text-muted small">Taxa</div>
<div class="fs-3 fw-bold">
<?= $totalVisits > 0 ? number_format($totalConv / $totalVisits * 100, 1) . '%' : '—' ?>
</div>
</div></div>
</div>
</div>
<div class="card shadow-sm mb-4">
<div class="card-header fw-semibold"><i class="bi bi-signpost-split me-1"></i>Por origem</div>
<div class="card-body">
<?php if (!$report): ?>
<p class="text-muted mb-0">Ainda não há visitas registadas no período.</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Origem</th>
<th class="text-end">Visitas</th>
<th class="text-end">Visitantes</th>
<th class="text-end">Conversões</th>
<th class="text-end">Taxa</th>
<th>Última</th>
</tr>
</thead>
<tbody>
<?php foreach ($report as $r): ?>
<tr>
<td><code><?= $h($r['source']) ?></code></td>
<td class="text-end"><?= (int) $r['visits'] ?></td>
<td class="text-end text-muted"><?= (int) $r['visitors'] ?></td>
<td class="text-end fw-semibold"><?= (int) $r['conversions'] ?></td>
<td class="text-end text-muted">
<?= $r['visits'] > 0 ? number_format($r['conversions'] / $r['visits'] * 100, 1) . '%' : '—' ?>
</td>
<td class="text-muted small"><?= $h($r['last_seen'] ? substr((string) $r['last_seen'], 0, 16) : '—') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<p class="form-text mb-0 mt-2">
A origem é a de <strong>primeiro toque</strong>: quem chega por um QR code, sai e
volta pelo Google continua creditado ao QR code. Bots excluídos.
</p>
<?php endif; ?>
</div>
</div>
<?php if ($byForm): ?>
<div class="card shadow-sm mb-4">
<div class="card-header fw-semibold"><i class="bi bi-ui-checks me-1"></i>Conversões por formulário</div>
<div class="card-body">
<table class="table table-sm align-middle mb-0">
<tbody>
<?php foreach ($byForm as $f): ?>
<tr>
<td><code><?= $h($f['form']) ?></code></td>
<td class="text-end fw-semibold" style="width:5rem"><?= (int) $f['n'] ?></td>
<td class="text-muted small text-end"><?= $h(substr((string) $f['last_seen'], 0, 16)) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<div class="row g-3">
<div class="col-md-6"><?= $table('País', $countries, fn($r) => '<code>' . $h($r['label']) . '</code>') ?></div>
<div class="col-md-6"><?= $table('Dispositivo', $devices, fn($r) => $h($r['label'])) ?></div>
<div class="col-md-6"><?= $table('Sistema', $os, fn($r) => $h($r['label'])) ?></div>
<div class="col-md-6"><?= $table('Browser', $browsers, fn($r) => $h($r['label'])) ?></div>
<div class="col-12"><?= $table('Sites que enviaram tráfego', $referrers, fn($r) => '<code>' . $h($r['label']) . '</code>') ?></div>
</div>
<p class="text-muted small mt-4 mb-0">
Nenhum endereço IP é guardado. O <code>ip_hash</code> serve só para aproximar visitantes
únicos e é salgado com um segredo do servidor, por isso não se reverte.
</p>
</div>
<?php if (is_file($layout_footer)) include $layout_footer; ?>