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>
483 lines
20 KiB
PHP
483 lines
20 KiB
PHP
<?php
|
|
// ─── Origem das visitas ─────────────────────────────────────────────────────
|
|
//
|
|
// Responde a "de onde veio esta pessoa?" em três eixos:
|
|
//
|
|
// URL /?source=montra, /?utm_source=instagram&utm_campaign=natal,
|
|
// ou o domínio de quem enviou o link
|
|
// dispositivo telemóvel/tablet/desktop, sistema, browser, idioma
|
|
// geografia país, pelo índice de shared/platform-tools/data/geo
|
|
//
|
|
// Nasceu na fotografia (config/functions_tracking.php) e veio para aqui para os
|
|
// outros sites o usarem. As mudanças em relação ao original estão assinaladas
|
|
// onde aparecem; a que mais importa é o IP.
|
|
//
|
|
// Contar visitas por origem é a metade fácil e a menos útil. O que diz se vale
|
|
// a pena reimprimir um QR code é quantos PEDIDOS produziu, por isso a origem de
|
|
// primeiro toque fica guardada e é carimbada na conversão que a pessoa venha a
|
|
// submeter — ver platform_track_conversion().
|
|
//
|
|
// Primeiro toque ganha durante toda a janela de atribuição: quem chega por um
|
|
// QR code, sai, e volta dias depois pelo Google continua a ser creditado ao QR
|
|
// code que fez o trabalho.
|
|
//
|
|
// Nunca se guarda o endereço IP. O hash existe só para aproximar visitantes
|
|
// únicos e é salgado com um segredo do servidor, por isso não se reverte.
|
|
|
|
require_once __DIR__ . '/functions_client_ip.php';
|
|
require_once __DIR__ . '/functions_geo.php';
|
|
|
|
const PLATFORM_TRACKING_COOKIE = 'am_src';
|
|
|
|
/**
|
|
* Cria as tabelas se faltarem. Chamada pelas funções de escrita, para um site
|
|
* poder adoptar isto sem migração à mão.
|
|
*/
|
|
function platform_tracking_ensure_tables(PDO $pdo): void
|
|
{
|
|
static $done = false;
|
|
if ($done) {
|
|
return;
|
|
}
|
|
$done = true;
|
|
|
|
$pdo->exec("
|
|
CREATE TABLE IF NOT EXISTS `visit_sources` (
|
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
|
`source` VARCHAR(60) NOT NULL,
|
|
`medium` VARCHAR(60) NULL,
|
|
`campaign` VARCHAR(80) NULL,
|
|
`referrer_host` VARCHAR(120) NULL,
|
|
`landing_path` VARCHAR(190) NULL,
|
|
`ip_hash` CHAR(64) NULL,
|
|
`user_agent` VARCHAR(255) NULL,
|
|
`is_bot` TINYINT(1) NOT NULL DEFAULT 0,
|
|
`device_type` VARCHAR(12) NULL,
|
|
`os` VARCHAR(24) NULL,
|
|
`browser` VARCHAR(24) NULL,
|
|
`language` VARCHAR(5) NULL,
|
|
`country` CHAR(2) NULL,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
KEY `idx_vs_source` (`source`),
|
|
KEY `idx_vs_bot` (`is_bot`, `created_at`),
|
|
KEY `idx_vs_country` (`country`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
");
|
|
|
|
// Conversões numa tabela só, com o nome do formulário como chave, em vez de
|
|
// uma coluna `source` em cada tabela de destino. Um site com contactos,
|
|
// aulas experimentais e registos precisaria de três migrações e três sítios
|
|
// por onde a atribuição se pode perder; assim é uma linha por handler e o
|
|
// relatório é o mesmo em toda a quinta.
|
|
$pdo->exec("
|
|
CREATE TABLE IF NOT EXISTS `tracking_conversions` (
|
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
|
`form` VARCHAR(40) NOT NULL,
|
|
`source` VARCHAR(60) NOT NULL,
|
|
`campaign` VARCHAR(80) NULL,
|
|
`country` CHAR(2) NULL,
|
|
`ip_hash` CHAR(64) NULL,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
KEY `idx_tc_form` (`form`, `created_at`),
|
|
KEY `idx_tc_source` (`source`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
");
|
|
|
|
// Instalações que já tinham a tabela antes de haver país.
|
|
try {
|
|
$pdo->exec("ALTER TABLE `visit_sources` ADD COLUMN `country` CHAR(2) NULL AFTER `language`");
|
|
$pdo->exec("ALTER TABLE `visit_sources` ADD KEY `idx_vs_country` (`country`)");
|
|
} catch (PDOException) { /* já existe */ }
|
|
}
|
|
|
|
/**
|
|
* Regista este pedido como visita, quando é o primeiro da sessão.
|
|
*
|
|
* Seguro em qualquer página pública: escreve no máximo uma linha por sessão e
|
|
* nunca lança — a analítica não vale uma página em baixo.
|
|
*/
|
|
function platform_track_visit(PDO $pdo): void
|
|
{
|
|
try {
|
|
if (!empty($_SESSION['_visit_tracked'])) {
|
|
return;
|
|
}
|
|
$_SESSION['_visit_tracked'] = 1;
|
|
|
|
platform_tracking_ensure_tables($pdo);
|
|
|
|
$parsed = platform_tracking_parse_request();
|
|
|
|
$existing = platform_tracking_stored_source();
|
|
if ($parsed['explicit'] || $existing === null) {
|
|
platform_tracking_store_source($pdo, $parsed['source'], $parsed['campaign']);
|
|
}
|
|
|
|
$ua = substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255);
|
|
$dev = platform_tracking_parse_user_agent($ua);
|
|
$lang = platform_tracking_parse_language((string) ($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? ''));
|
|
|
|
$pdo->prepare("
|
|
INSERT INTO visit_sources
|
|
(source, medium, campaign, referrer_host, landing_path, ip_hash, user_agent, is_bot,
|
|
device_type, os, browser, language, country)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
")->execute([
|
|
$parsed['source'],
|
|
$parsed['medium'],
|
|
$parsed['campaign'],
|
|
$parsed['referrer_host'],
|
|
$parsed['landing_path'],
|
|
platform_tracking_ip_hash(),
|
|
$ua,
|
|
platform_tracking_is_bot($ua) ? 1 : 0,
|
|
$dev['device_type'],
|
|
$dev['os'],
|
|
$dev['browser'],
|
|
$lang,
|
|
geo_ip_country(platform_client_ip()),
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
error_log('[tracking] ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Carimba uma conversão com a origem que a pessoa trouxe.
|
|
*
|
|
* `$form` é o nome do formulário ('contacto', 'trial', 'registo'…). Chamar
|
|
* depois de a submissão ter sido aceite, nunca antes: uma tentativa recusada
|
|
* pelo antispam não é uma conversão, e contá-la faria uma campanha parecer
|
|
* melhor por estar a atrair bots.
|
|
*/
|
|
function platform_track_conversion(PDO $pdo, string $form): void
|
|
{
|
|
try {
|
|
platform_tracking_ensure_tables($pdo);
|
|
$attr = platform_tracking_current_source();
|
|
$pdo->prepare("
|
|
INSERT INTO tracking_conversions (form, source, campaign, country, ip_hash)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
")->execute([
|
|
substr($form, 0, 40),
|
|
$attr['source'],
|
|
$attr['campaign'],
|
|
geo_ip_country(platform_client_ip()),
|
|
platform_tracking_ip_hash(),
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
error_log('[tracking] conversão: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* De onde veio este pedido.
|
|
*
|
|
* `explicit` diz se o visitante trazia um parâmetro de campanha, que é o que
|
|
* decide se deve sobrepor-se a uma origem já guardada.
|
|
*/
|
|
function platform_tracking_parse_request(): array
|
|
{
|
|
$get = fn(string $k): string => trim((string) ($_GET[$k] ?? ''));
|
|
|
|
$source = $get('source') ?: $get('utm_source') ?: $get('ref');
|
|
$medium = $get('utm_medium') ?: ($get('source') ? 'campaign' : null);
|
|
$campaign = $get('utm_campaign') ?: null;
|
|
$explicit = $source !== '';
|
|
|
|
$refHost = null;
|
|
if (!empty($_SERVER['HTTP_REFERER'])) {
|
|
$host = parse_url((string) $_SERVER['HTTP_REFERER'], PHP_URL_HOST);
|
|
if ($host) {
|
|
$refHost = strtolower(preg_replace('/^www\./', '', $host));
|
|
// Um referrer do nosso próprio site não é uma origem de aquisição.
|
|
$base = function_exists('env') ? (string) env('APP_BASE_URL', '') : '';
|
|
$self = strtolower((string) parse_url($base, PHP_URL_HOST));
|
|
if ($self !== '' && $refHost === preg_replace('/^www\./', '', $self)) {
|
|
$refHost = null;
|
|
}
|
|
// O host actual também conta como próprio: o APP_BASE_URL da quinta
|
|
// aponta para o domínio de serviço e não para o público, por isso
|
|
// sozinho deixava passar navegação interna como se fosse referral.
|
|
$here = strtolower(preg_replace('/^www\./', '', (string) ($_SERVER['HTTP_HOST'] ?? '')));
|
|
if ($here !== '' && $refHost === $here) {
|
|
$refHost = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($source === '') {
|
|
$source = $refHost ?: 'direct';
|
|
$medium = $medium ?: ($refHost ? 'referral' : 'direct');
|
|
}
|
|
|
|
return [
|
|
'source' => platform_tracking_normalise($source),
|
|
'medium' => $medium ? platform_tracking_normalise($medium, 60) : null,
|
|
'campaign' => $campaign ? platform_tracking_normalise($campaign, 80) : null,
|
|
'referrer_host' => $refHost ? substr($refHost, 0, 120) : null,
|
|
'landing_path' => substr((string) (parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: '/'), 0, 190),
|
|
'explicit' => $explicit,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Reduz um valor cru a uma chave estável, para "Montra", "montra " e "MONTRA"
|
|
* não darem três linhas no relatório.
|
|
*/
|
|
function platform_tracking_normalise(string $v, int $max = 60): string
|
|
{
|
|
$v = trim($v);
|
|
// Transliterar em vez de remover: tirar os acentos à bruta transformava
|
|
// "café" em "caf" e "são-joão" em "so-joo", que é exactamente o género de
|
|
// nome que uma campanha portuguesa usa.
|
|
if (function_exists('iconv')) {
|
|
$t = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $v);
|
|
if ($t !== false && $t !== '') $v = $t;
|
|
}
|
|
$v = strtolower($v);
|
|
// O TRANSLIT do iconv pode emitir formas como "c'e" ou "~a", conforme o locale.
|
|
$v = preg_replace("/['\"^~`]/", '', $v) ?? '';
|
|
$v = preg_replace('/[^a-z0-9._\- ]+/', '', $v) ?? '';
|
|
$v = preg_replace('/\s+/', '-', trim($v)) ?? '';
|
|
$v = trim(preg_replace('/-+/', '-', $v) ?? '', '-');
|
|
return substr($v, 0, $max) ?: 'direct';
|
|
}
|
|
|
|
/**
|
|
* Hash salgado e não reversível do visitante. O endereço nunca é guardado.
|
|
*
|
|
* Usa platform_client_ip() e não REMOTE_ADDR. Era essa a versão original, e
|
|
* atrás do NPM o REMOTE_ADDR é sempre o proxy: todos os visitantes ficavam com
|
|
* o mesmo hash, o que fazia o COUNT(DISTINCT ip_hash) do relatório -- a coluna
|
|
* "visitantes" -- dar sempre 1.
|
|
*/
|
|
function platform_tracking_ip_hash(): ?string
|
|
{
|
|
$ip = platform_client_ip();
|
|
if ($ip === null || $ip === '') {
|
|
return null;
|
|
}
|
|
$env = fn(string $k) => function_exists('env') ? (string) env($k, '') : (string) (getenv($k) ?: '');
|
|
$salt = $env('ANTISPAM_SECRET') ?: $env('APP_SECRET_KEY') ?: $env('BACKUP_TOKEN') ?: 'am-tracking';
|
|
return hash('sha256', $salt . '|' . $ip);
|
|
}
|
|
|
|
/**
|
|
* Categorias grosseiras de dispositivo e plataforma a partir do User-Agent.
|
|
*
|
|
* A ordem importa em todo o lado: praticamente todos os browsers mentem no
|
|
* User-Agent por compatibilidade. O Edge e o Opera dizem-se Chrome, o Chrome
|
|
* diz-se Safari, e qualquer tablet Android diz-se Android. Cada teste tem de
|
|
* excluir os impostores antes de o mais geral correr.
|
|
*
|
|
* Grosseiro de propósito: a pergunta é "desktop ou Android?", não "que versão
|
|
* de correcção?". Números de versão estreitariam o visitante sem acrescentar
|
|
* nada à resposta.
|
|
*/
|
|
function platform_tracking_parse_user_agent(string $ua): array
|
|
{
|
|
$out = ['device_type' => 'other', 'os' => 'Desconhecido', 'browser' => 'Desconhecido'];
|
|
if ($ua === '') return $out;
|
|
|
|
// O iPadOS 13+ diz-se "Macintosh", por isso o teste do iPad precisa da
|
|
// pista de toque antes de o do macOS poder ser acreditado.
|
|
if (preg_match('/iPad/i', $ua) || (preg_match('/Macintosh/i', $ua) && preg_match('/Mobile\/|Touch/i', $ua))) {
|
|
$out['os'] = 'iPadOS'; $out['device_type'] = 'tablet';
|
|
} elseif (preg_match('/iPhone|iPod/i', $ua)) {
|
|
$out['os'] = 'iOS'; $out['device_type'] = 'mobile';
|
|
} elseif (preg_match('/Android/i', $ua)) {
|
|
$out['os'] = 'Android';
|
|
// Os tablets Android omitem "Mobile"; os telemóveis incluem-no.
|
|
$out['device_type'] = preg_match('/Mobile/i', $ua) ? 'mobile' : 'tablet';
|
|
} elseif (preg_match('/Windows Phone/i', $ua)) {
|
|
$out['os'] = 'Windows Phone'; $out['device_type'] = 'mobile';
|
|
} elseif (preg_match('/Windows NT/i', $ua)) {
|
|
$out['os'] = 'Windows'; $out['device_type'] = 'desktop';
|
|
} elseif (preg_match('/CrOS/i', $ua)) {
|
|
$out['os'] = 'ChromeOS'; $out['device_type'] = 'desktop';
|
|
} elseif (preg_match('/Mac OS X|Macintosh/i', $ua)) {
|
|
$out['os'] = 'macOS'; $out['device_type'] = 'desktop';
|
|
} elseif (preg_match('/Linux/i', $ua)) {
|
|
$out['os'] = 'Linux'; $out['device_type'] = 'desktop';
|
|
}
|
|
|
|
// Edge antes do Chrome (Edg/ contém Chrome/), Opera antes do Chrome, Chrome
|
|
// antes do Safari, e os browsers embutidos antes de todos — quem toca num
|
|
// link dentro do Instagram está a navegar na webview do Instagram, e contar
|
|
// isso como Safari esconderia quanto tráfego as redes realmente mandam.
|
|
if (preg_match('/FBAN|FBAV|FB_IAB/i', $ua)) $out['browser'] = 'Facebook (in-app)';
|
|
elseif (preg_match('/Instagram/i', $ua)) $out['browser'] = 'Instagram (in-app)';
|
|
elseif (preg_match('/\bEdgA?\//i', $ua)) $out['browser'] = 'Edge';
|
|
elseif (preg_match('/OPR\/|Opera/i', $ua)) $out['browser'] = 'Opera';
|
|
elseif (preg_match('/SamsungBrowser/i', $ua)) $out['browser'] = 'Samsung Internet';
|
|
elseif (preg_match('/Firefox\/|FxiOS/i', $ua)) $out['browser'] = 'Firefox';
|
|
elseif (preg_match('/CriOS/i', $ua)) $out['browser'] = 'Chrome';
|
|
elseif (preg_match('/Chrome\//i', $ua)) $out['browser'] = 'Chrome';
|
|
elseif (preg_match('/Safari\//i', $ua)) $out['browser'] = 'Safari';
|
|
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* Idioma principal que o browser pede, em duas letras ('pt', 'en'…). Serve para
|
|
* distinguir visitantes locais de estrangeiros; nada mais granular é guardado.
|
|
*/
|
|
function platform_tracking_parse_language(string $header): ?string
|
|
{
|
|
if ($header === '') return null;
|
|
// "pt-PT,pt;q=0.9,en;q=0.8" → pt
|
|
if (!preg_match('/^\s*([a-zA-Z]{2})/', $header, $m)) return null;
|
|
return strtolower($m[1]);
|
|
}
|
|
|
|
/** Filtro de crawlers, rude mas eficaz — mantém o relatório sobre pessoas. */
|
|
function platform_tracking_is_bot(string $ua): bool
|
|
{
|
|
if ($ua === '') return true;
|
|
return (bool) preg_match(
|
|
'/bot|crawl|spider|slurp|bingpreview|facebookexternalhit|whatsapp|telegram|preview|monitor|curl|wget|python-requests|headless|lighthouse|pingdom|uptime/i',
|
|
$ua
|
|
);
|
|
}
|
|
|
|
// ─── Guardar o primeiro toque ───────────────────────────────────────────────
|
|
|
|
function platform_tracking_store_source(PDO $pdo, string $source, ?string $campaign): void
|
|
{
|
|
$days = 30;
|
|
if (function_exists('platform_settings_get')) {
|
|
$days = max(1, (int) (platform_settings_get($pdo, 'tracking_attribution_days', '30') ?: 30));
|
|
}
|
|
$payload = $source . '|' . ($campaign ?? '');
|
|
|
|
$_SESSION['_visit_source'] = $payload;
|
|
// Cookie para quem volta dias depois continuar a ser creditado à campanha
|
|
// que o trouxe da primeira vez.
|
|
@setcookie(PLATFORM_TRACKING_COOKIE, $payload, [
|
|
'expires' => time() + ($days * 86400),
|
|
'path' => '/',
|
|
'secure' => (($_SERVER['HTTPS'] ?? '') !== '' || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'),
|
|
'httponly' => true,
|
|
'samesite' => 'Lax',
|
|
]);
|
|
}
|
|
|
|
/** "source|campaign" guardado, ou null quando não há nada. */
|
|
function platform_tracking_stored_source(): ?string
|
|
{
|
|
$raw = $_SESSION['_visit_source'] ?? $_COOKIE[PLATFORM_TRACKING_COOKIE] ?? null;
|
|
return is_string($raw) && $raw !== '' ? $raw : null;
|
|
}
|
|
|
|
/**
|
|
* A origem a creditar a uma conversão, como ['source' => …, 'campaign' => …].
|
|
* Cai para o pedido actual, para um formulário submetido logo na primeira
|
|
* página continuar a ser atribuído.
|
|
*/
|
|
function platform_tracking_current_source(): array
|
|
{
|
|
$raw = platform_tracking_stored_source();
|
|
if ($raw !== null) {
|
|
[$source, $campaign] = array_pad(explode('|', $raw, 2), 2, '');
|
|
return [
|
|
'source' => platform_tracking_normalise($source ?: 'direct'),
|
|
'campaign' => $campaign !== '' ? platform_tracking_normalise($campaign, 80) : null,
|
|
];
|
|
}
|
|
$p = platform_tracking_parse_request();
|
|
return ['source' => $p['source'], 'campaign' => $p['campaign']];
|
|
}
|
|
|
|
// ─── Relatórios ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Visitas e conversões por origem, nos últimos N dias.
|
|
*
|
|
* As duas metades são contadas numa passagem por tabela e juntas em PHP, e não
|
|
* com um JOIN — uma visita e uma conversão não têm relação ao nível da linha,
|
|
* só a chave de origem em comum.
|
|
*/
|
|
function platform_tracking_report(PDO $pdo, int $days = 30): array
|
|
{
|
|
$days = max(1, $days);
|
|
|
|
$visits = $pdo->prepare("
|
|
SELECT source,
|
|
COUNT(*) AS visits,
|
|
COUNT(DISTINCT ip_hash) AS visitors,
|
|
MAX(created_at) AS last_seen
|
|
FROM visit_sources
|
|
WHERE is_bot = 0 AND created_at >= NOW() - INTERVAL ? DAY
|
|
GROUP BY source
|
|
");
|
|
$visits->execute([$days]);
|
|
$visits = $visits->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
$conv = $pdo->prepare("
|
|
SELECT source, COUNT(*) AS conversions
|
|
FROM tracking_conversions
|
|
WHERE created_at >= NOW() - INTERVAL ? DAY
|
|
GROUP BY source
|
|
");
|
|
$conv->execute([$days]);
|
|
$conv = $conv->fetchAll(PDO::FETCH_KEY_PAIR);
|
|
|
|
$rows = [];
|
|
foreach ($visits as $v) {
|
|
$src = (string) $v['source'];
|
|
$rows[$src] = [
|
|
'source' => $src,
|
|
'visits' => (int) $v['visits'],
|
|
'visitors' => (int) $v['visitors'],
|
|
'conversions' => (int) ($conv[$src] ?? 0),
|
|
'last_seen' => $v['last_seen'],
|
|
];
|
|
unset($conv[$src]);
|
|
}
|
|
// Conversões cuja origem já não tem visitas na janela — uma pessoa que
|
|
// chegou há dois meses e só agora submeteu. Continuam a contar.
|
|
foreach ($conv as $src => $n) {
|
|
$rows[$src] = ['source' => (string) $src, 'visits' => 0, 'visitors' => 0,
|
|
'conversions' => (int) $n, 'last_seen' => null];
|
|
}
|
|
|
|
uasort($rows, fn($a, $b) => [$b['conversions'], $b['visits']] <=> [$a['conversions'], $a['visits']]);
|
|
return array_values($rows);
|
|
}
|
|
|
|
/** Contagem por uma coluna qualquer de visit_sources (device_type, os, country…). */
|
|
function platform_tracking_breakdown(PDO $pdo, string $column, int $days = 30, int $limit = 12): array
|
|
{
|
|
$allowed = ['device_type', 'os', 'browser', 'language', 'country', 'referrer_host'];
|
|
if (!in_array($column, $allowed, true)) {
|
|
return []; // o nome entra numa query -- só os da lista
|
|
}
|
|
$st = $pdo->prepare("
|
|
SELECT COALESCE(NULLIF(`$column`, ''), '—') AS label,
|
|
COUNT(*) AS visits,
|
|
COUNT(DISTINCT ip_hash) AS visitors
|
|
FROM visit_sources
|
|
WHERE is_bot = 0 AND created_at >= NOW() - INTERVAL ? DAY
|
|
GROUP BY label
|
|
ORDER BY visits DESC
|
|
LIMIT " . max(1, min(100, $limit))
|
|
);
|
|
$st->execute([$days]);
|
|
return $st->fetchAll(PDO::FETCH_ASSOC);
|
|
}
|
|
|
|
/** Conversões por formulário, nos últimos N dias. */
|
|
function platform_tracking_conversions_by_form(PDO $pdo, int $days = 30): array
|
|
{
|
|
$st = $pdo->prepare("
|
|
SELECT form, COUNT(*) AS n, MAX(created_at) AS last_seen
|
|
FROM tracking_conversions
|
|
WHERE created_at >= NOW() - INTERVAL ? DAY
|
|
GROUP BY form
|
|
ORDER BY n DESC
|
|
");
|
|
$st->execute([$days]);
|
|
return $st->fetchAll(PDO::FETCH_ASSOC);
|
|
}
|