Files
fitme3070.pt/shared/platform-tools/tracking.php
T
andreandClaude Opus 5 4d40adbf64 Vendor platform-tools: everything this site runs is now in one repo
shared/platform-tools was a git submodule. On this branch it is a plain
directory of tracked files, frozen at ea3616abae30106df283e73a3e2f9e5bb0e6ae90
— the same commit fotografia.andmore.pt pins. Contents are byte-identical to
that tree, verified blob by blob.

The submodule caused every deployment failure this site has had:

  - .gitmodules named no `branch`, so the update fell back to
    refs/remotes/origin/HEAD — a ref a plain server-side clone never writes.
    "Atualizar submódulo" failed with "Unable to find refs/remotes/origin/HEAD
    revision in submodule path".
  - Its URL was http://, which the origin answers with 523.
  - This install is deployed by uploading files, and a submodule is invisible
    to that. The tree moved forward while shared/platform-tools stayed on the
    old pin, config/functions_secrets.php died on a missing compat_secrets.php,
    and the whole site — public pages included — went down.

Each of those was separately fixable. Together they mean a second repository
nobody can see from the deployment path, gating whether the first one boots. A
plain directory deploys like every other file and cannot fall out of step.

The cost is that platform-tools updates no longer arrive by bumping a pin; they
are a copy-over-and-commit. shared/platform-tools/VENDORED.md records the
upstream SHA, the procedure, and which four files differ from upstream in line
endings only (social/*.js, .css, functions_publications.php — CRLF upstream,
normalised to LF here; content otherwise identical).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:06:23 +01:00

199 lines
8.8 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 stub tem de abrir a ligação primeiro.');
}
require_once __DIR__ . '/functions_tracking.php';
$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; ?>