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>
199 lines
8.8 KiB
PHP
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; ?>
|