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>
This commit is contained in:
1 parent
0e034232e9
commit
4d40adbf64
67 files changed
+17478
-5
No files matched your search
@@ -0,0 +1,198 @@
|
||||
<?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; ?>
|
||||
Reference in new issue
Block a user