2026-09-20 17:00:59 +03:00
|
|
|
{% extends "admin/layout.html" %}
|
|
|
|
|
{% let active_page = "analytics" %}
|
|
|
|
|
|
|
|
|
|
{% block title %}{{ t.analytics_title }}{% endblock %}
|
|
|
|
|
|
|
|
|
|
{% block content %}
|
|
|
|
|
<div class="page-head">
|
|
|
|
|
<div>
|
|
|
|
|
<h1>{{ t.analytics_title }}</h1>
|
|
|
|
|
<p>{{ t.analytics_intro }}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="analytics-controls"
|
|
|
|
|
data-labels='{
|
|
|
|
|
"landing_views": "{{ t.analytics_metric_landing_views }}",
|
|
|
|
|
"landing_unique": "{{ t.analytics_metric_landing_unique }}",
|
|
|
|
|
"portal_opens": "{{ t.analytics_metric_portal_opens }}",
|
|
|
|
|
"media_views": "{{ t.analytics_metric_media_views }}",
|
|
|
|
|
"no_data": "{{ t.analytics_no_data }}",
|
|
|
|
|
"loading": "{{ t.analytics_loading }}",
|
|
|
|
|
"error": "{{ t.analytics_load_error }}",
|
|
|
|
|
"log_empty": "{{ t.analytics_log_empty }}",
|
|
|
|
|
"log_end": "{{ t.analytics_log_end }}",
|
|
|
|
|
"visitor": "{{ t.analytics_log_visitor }}",
|
|
|
|
|
"landing_view": "{{ t.analytics_ev_landing_view }}",
|
|
|
|
|
"portal_open": "{{ t.analytics_ev_portal_open }}",
|
2026-09-20 21:00:03 +03:00
|
|
|
"media_view": "{{ t.analytics_ev_media_view }}",
|
|
|
|
|
"media_prefix": "{{ t.analytics_log_media_prefix }}"
|
2026-09-20 17:00:59 +03:00
|
|
|
}'>
|
|
|
|
|
<div class="analytics-presets">
|
|
|
|
|
<button type="button" class="button is-small preset-btn" data-days="7">{{ t.analytics_preset_7 }}</button>
|
|
|
|
|
<button type="button" class="button is-small preset-btn" data-days="30">{{ t.analytics_preset_30 }}</button>
|
|
|
|
|
<button type="button" class="button is-small preset-btn" data-days="90">{{ t.analytics_preset_90 }}</button>
|
|
|
|
|
<button type="button" class="button is-small preset-btn" data-days="365">{{ t.analytics_preset_365 }}</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="analytics-range">
|
|
|
|
|
<label>{{ t.analytics_range_from }}
|
|
|
|
|
<input type="date" id="anFrom" class="input is-small">
|
|
|
|
|
</label>
|
|
|
|
|
<label>{{ t.analytics_range_to }}
|
|
|
|
|
<input type="date" id="anTo" class="input is-small">
|
|
|
|
|
</label>
|
|
|
|
|
<label>{{ t.analytics_granularity }}
|
|
|
|
|
<select id="anGran" class="input is-small">
|
|
|
|
|
<option value="day">{{ t.analytics_gran_day }}</option>
|
|
|
|
|
<option value="week">{{ t.analytics_gran_week }}</option>
|
|
|
|
|
<option value="month">{{ t.analytics_gran_month }}</option>
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="analytics-kpis" id="anKpis"></div>
|
|
|
|
|
|
|
|
|
|
<div class="analytics-chart-wrap">
|
|
|
|
|
<div class="analytics-chart-box">
|
|
|
|
|
<canvas id="anChart"></canvas>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="analytics-tops">
|
|
|
|
|
<div class="analytics-top-card">
|
|
|
|
|
<h2>{{ t.analytics_top_clients }}</h2>
|
|
|
|
|
<table class="analytics-top-table" id="anTopClients">
|
|
|
|
|
<thead><tr><th>{{ t.analytics_top_clients }}</th><th>{{ t.analytics_count }}</th></tr></thead>
|
|
|
|
|
<tbody></tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="analytics-top-card">
|
|
|
|
|
<h2>{{ t.analytics_top_media }}</h2>
|
|
|
|
|
<table class="analytics-top-table" id="anTopMedia">
|
|
|
|
|
<thead><tr><th>{{ t.analytics_top_media }}</th><th>{{ t.analytics_count }}</th></tr></thead>
|
|
|
|
|
<tbody></tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="analytics-log-card">
|
2026-09-20 20:11:18 +03:00
|
|
|
<div class="analytics-log-head">
|
|
|
|
|
<h2>{{ t.analytics_log_title }}</h2>
|
2026-09-20 21:00:03 +03:00
|
|
|
<div class="analytics-log-filters">
|
|
|
|
|
<label class="log-chip" title="{{ t.analytics_ev_landing_view }}">
|
|
|
|
|
<input type="checkbox" class="log-type-toggle" data-type="landing_view">
|
|
|
|
|
<span>🌐 {{ t.analytics_ev_landing_short }}</span>
|
|
|
|
|
</label>
|
|
|
|
|
<label class="log-chip" title="{{ t.analytics_ev_portal_open }}">
|
|
|
|
|
<input type="checkbox" class="log-type-toggle" data-type="portal_open" checked>
|
|
|
|
|
<span>👤 {{ t.analytics_ev_portal_short }}</span>
|
|
|
|
|
</label>
|
|
|
|
|
<label class="log-chip" title="{{ t.analytics_ev_media_view }}">
|
|
|
|
|
<input type="checkbox" class="log-type-toggle" data-type="media_view" checked>
|
|
|
|
|
<span>🖼️ {{ t.analytics_ev_media_short }}</span>
|
|
|
|
|
</label>
|
2026-09-20 20:11:18 +03:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-09-20 17:00:59 +03:00
|
|
|
<div class="analytics-log" id="anLog"></div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
.analytics-controls { display:flex; flex-wrap:wrap; gap:16px; align-items:flex-end; margin-bottom:18px; }
|
|
|
|
|
.analytics-presets { display:flex; gap:6px; flex-wrap:wrap; }
|
|
|
|
|
.analytics-range { display:flex; gap:12px; flex-wrap:wrap; }
|
|
|
|
|
.analytics-range label { display:flex; flex-direction:column; font-size:.8rem; color:#666; gap:3px; }
|
|
|
|
|
.analytics-kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin-bottom:18px; }
|
|
|
|
|
.analytics-kpi { background:#fff; border-radius:12px; padding:14px 16px; box-shadow:0 1px 4px rgba(0,0,0,.06); border-left:4px solid var(--kpi-color,#7c6ed4); }
|
|
|
|
|
.analytics-kpi .kpi-value { font-size:1.7rem; font-weight:700; line-height:1.1; }
|
|
|
|
|
.analytics-kpi .kpi-label { font-size:.8rem; color:#777; margin-top:2px; }
|
|
|
|
|
.analytics-metric-toggles { display:flex; flex-wrap:wrap; gap:14px; align-items:center; margin-bottom:12px; font-size:.85rem; }
|
|
|
|
|
.analytics-metric-toggles .toggles-label { color:#777; font-weight:600; }
|
|
|
|
|
.analytics-metric-toggles label { display:flex; align-items:center; gap:5px; cursor:pointer; }
|
|
|
|
|
.analytics-chart-wrap { background:#fff; border-radius:12px; padding:16px; box-shadow:0 1px 4px rgba(0,0,0,.06); margin-bottom:20px; }
|
|
|
|
|
.analytics-chart-box { position:relative; height:340px; width:100%; }
|
|
|
|
|
@media (max-width:600px) { .analytics-chart-box { height:260px; } }
|
|
|
|
|
.analytics-tops { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:16px; }
|
|
|
|
|
.analytics-top-card { background:#fff; border-radius:12px; padding:16px; box-shadow:0 1px 4px rgba(0,0,0,.06); }
|
|
|
|
|
.analytics-top-card h2 { font-size:1rem; margin:0 0 10px; }
|
|
|
|
|
.analytics-top-table { width:100%; border-collapse:collapse; font-size:.9rem; }
|
|
|
|
|
.analytics-top-table th { text-align:left; color:#888; font-weight:600; font-size:.78rem; border-bottom:1px solid #eee; padding:6px 4px; }
|
|
|
|
|
.analytics-top-table td { padding:6px 4px; border-bottom:1px solid #f4f4f4; }
|
|
|
|
|
.analytics-top-table td:last-child, .analytics-top-table th:last-child { text-align:right; width:70px; }
|
|
|
|
|
.analytics-top-empty { color:#aaa; padding:10px 4px; }
|
|
|
|
|
.an-media-link { display:flex; align-items:center; gap:8px; color:inherit; text-decoration:none; }
|
|
|
|
|
.an-media-link:hover { text-decoration:underline; }
|
|
|
|
|
.an-media-thumb { width:40px; height:40px; border-radius:6px; object-fit:cover; background:#eceaf5; flex:0 0 auto; }
|
|
|
|
|
.an-media-missing { width:40px; height:40px; border-radius:6px; background:#f0f0f3; display:inline-flex; align-items:center; justify-content:center; color:#bbb; flex:0 0 auto; }
|
|
|
|
|
|
|
|
|
|
.analytics-log-card { background:#fff; border-radius:12px; padding:16px; box-shadow:0 1px 4px rgba(0,0,0,.06); margin-top:20px; }
|
2026-09-20 20:11:18 +03:00
|
|
|
.analytics-log-card h2 { font-size:1rem; margin:0; }
|
|
|
|
|
.analytics-log-head { display:flex; flex-wrap:wrap; gap:10px 18px; align-items:center; justify-content:space-between; margin-bottom:10px; }
|
2026-09-20 21:00:03 +03:00
|
|
|
.analytics-log-filters { display:flex; flex-wrap:wrap; gap:6px; }
|
|
|
|
|
.log-chip { display:inline-flex; align-items:center; gap:5px; padding:4px 11px; border-radius:999px; border:1px solid var(--admin-border,#e0e0e6); background:#f5f5f7; color:#8a8a95; font-size:.8rem; line-height:1.3; cursor:pointer; user-select:none; white-space:nowrap; transition:background .12s,color .12s,border-color .12s; }
|
|
|
|
|
.log-chip input { position:absolute; opacity:0; width:0; height:0; pointer-events:none; }
|
|
|
|
|
.log-chip:has(input:checked) { background:var(--admin-accent-soft,#eef0fb); border-color:#c9c6ee; color:#5b4fc4; font-weight:600; }
|
|
|
|
|
@media (max-width:600px) {
|
|
|
|
|
.analytics-log-head { gap:8px; margin-bottom:8px; }
|
|
|
|
|
.log-chip { padding:5px 12px; font-size:.82rem; }
|
|
|
|
|
}
|
2026-09-20 20:30:28 +03:00
|
|
|
.analytics-log { height:460px; overflow-y:auto; overflow-x:hidden; border:1px solid #f0f0f0; border-radius:8px; }
|
|
|
|
|
.an-log-row { display:grid; grid-template-columns:40px 1fr; gap:10px; align-items:start; padding:11px 12px; border-bottom:1px solid #f2f2f4; font-size:.85rem; }
|
2026-09-20 17:00:59 +03:00
|
|
|
.an-log-row:last-child { border-bottom:none; }
|
2026-09-20 20:30:28 +03:00
|
|
|
.an-log-figure { width:40px; height:40px; border-radius:8px; display:flex; align-items:center; justify-content:center; font-size:1.15rem; background:var(--admin-accent-soft,#eef0fb); overflow:hidden; }
|
|
|
|
|
.an-log-figure img { width:100%; height:100%; object-fit:cover; display:block; }
|
|
|
|
|
.an-log-main { min-width:0; }
|
|
|
|
|
.an-log-head { display:flex; flex-wrap:wrap; gap:2px 8px; align-items:baseline; }
|
|
|
|
|
.an-log-event { font-weight:650; }
|
2026-09-20 21:00:03 +03:00
|
|
|
.an-log-client { color:#7c6ed4; font-weight:600; text-decoration:none; }
|
|
|
|
|
.an-log-client:hover { text-decoration:underline; }
|
|
|
|
|
.an-log-num { color:#7c6ed4; font-weight:600; text-decoration:none; }
|
|
|
|
|
a.an-log-num:hover { text-decoration:underline; }
|
2026-09-20 20:30:28 +03:00
|
|
|
.an-log-time { color:#9a9aa6; font-size:.75rem; white-space:nowrap; margin-left:auto; }
|
|
|
|
|
.an-log-meta { color:#8a8a95; font-size:.76rem; margin-top:4px; display:flex; flex-direction:column; gap:2px; }
|
|
|
|
|
.an-log-meta .an-log-line { display:flex; flex-wrap:wrap; gap:4px 12px; }
|
|
|
|
|
.an-log-meta code { background:rgba(0,0,0,.045); padding:1px 5px; border-radius:4px; word-break:break-all; overflow-wrap:anywhere; max-width:100%; }
|
|
|
|
|
.an-log-ref { overflow-wrap:anywhere; word-break:break-word; }
|
|
|
|
|
.an-log-status { text-align:center; color:#aaa; padding:16px; font-size:.85rem; }
|
|
|
|
|
@media (max-width:600px) {
|
|
|
|
|
.an-log-row { grid-template-columns:34px 1fr; gap:9px; padding:10px; }
|
|
|
|
|
.an-log-figure { width:34px; height:34px; font-size:1rem; }
|
|
|
|
|
.an-log-time { flex-basis:100%; margin-left:0; }
|
|
|
|
|
}
|
2026-09-20 17:00:59 +03:00
|
|
|
</style>
|
|
|
|
|
|
|
|
|
|
<script src="/static/chart.min.js?v={{ t.app_version() }}"></script>
|
|
|
|
|
<script>
|
|
|
|
|
(function () {
|
|
|
|
|
var controls = document.querySelector('.analytics-controls');
|
|
|
|
|
var L = JSON.parse(controls.getAttribute('data-labels'));
|
|
|
|
|
|
|
|
|
|
var META = [
|
2026-09-20 21:00:03 +03:00
|
|
|
{ key: 'landing_views', color: '#7c6ed4', hidden: true },
|
2026-09-20 17:00:59 +03:00
|
|
|
{ key: 'landing_unique', color: '#00b496' },
|
|
|
|
|
{ key: 'portal_opens', color: '#ff8c26' },
|
|
|
|
|
{ key: 'media_views', color: '#ff5287' }
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
var fromInput = document.getElementById('anFrom');
|
|
|
|
|
var toInput = document.getElementById('anTo');
|
|
|
|
|
var granSelect = document.getElementById('anGran');
|
|
|
|
|
var kpisEl = document.getElementById('anKpis');
|
|
|
|
|
var chart = null;
|
|
|
|
|
|
|
|
|
|
// Draws a centered status message ("loading", "no data", error) directly on
|
|
|
|
|
// the chart canvas, so state changes never shift the page layout.
|
|
|
|
|
var messageOverlay = {
|
|
|
|
|
id: 'messageOverlay',
|
|
|
|
|
afterDraw: function (c) {
|
|
|
|
|
var msg = c.$message;
|
|
|
|
|
if (!msg) { return; }
|
|
|
|
|
var ctx = c.ctx;
|
|
|
|
|
var a = c.chartArea;
|
|
|
|
|
ctx.save();
|
|
|
|
|
ctx.fillStyle = 'rgba(255,255,255,0.72)';
|
|
|
|
|
ctx.fillRect(a.left, a.top, a.right - a.left, a.bottom - a.top);
|
|
|
|
|
ctx.fillStyle = '#8a8a8a';
|
|
|
|
|
ctx.font = '600 14px system-ui, sans-serif';
|
|
|
|
|
ctx.textAlign = 'center';
|
|
|
|
|
ctx.textBaseline = 'middle';
|
|
|
|
|
ctx.fillText(msg, (a.left + a.right) / 2, (a.top + a.bottom) / 2);
|
|
|
|
|
ctx.restore();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function setMessage(msg) {
|
|
|
|
|
if (!chart) { return; }
|
|
|
|
|
chart.$message = msg;
|
|
|
|
|
chart.update('none');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fmt(d) {
|
|
|
|
|
return d.getFullYear() + '-' +
|
|
|
|
|
String(d.getMonth() + 1).padStart(2, '0') + '-' +
|
|
|
|
|
String(d.getDate()).padStart(2, '0');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setRange(days) {
|
|
|
|
|
var to = new Date();
|
|
|
|
|
var from = new Date();
|
|
|
|
|
from.setDate(from.getDate() - (days - 1));
|
|
|
|
|
fromInput.value = fmt(from);
|
|
|
|
|
toInput.value = fmt(to);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function metricLabel(key) { return L[key] || key; }
|
|
|
|
|
|
2026-09-20 21:00:03 +03:00
|
|
|
var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
|
|
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
|
|
|
|
|
|
|
|
// Turns an ISO bucket label into a short display date:
|
|
|
|
|
// "2026-04-11" -> "11 Apr", "2026-04" -> "Apr 2026".
|
|
|
|
|
function formatLabel(raw) {
|
|
|
|
|
if (!raw) { return raw; }
|
|
|
|
|
var parts = String(raw).split('-');
|
|
|
|
|
if (parts.length === 3) {
|
|
|
|
|
return parseInt(parts[2], 10) + ' ' + MONTHS[parseInt(parts[1], 10) - 1];
|
|
|
|
|
}
|
|
|
|
|
if (parts.length === 2) {
|
|
|
|
|
return MONTHS[parseInt(parts[1], 10) - 1] + ' ' + parts[0];
|
|
|
|
|
}
|
|
|
|
|
return raw;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 17:00:59 +03:00
|
|
|
function hexToRgba(hex, a) {
|
|
|
|
|
var n = parseInt(hex.slice(1), 16);
|
|
|
|
|
return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + a + ')';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderKpis(report) {
|
|
|
|
|
kpisEl.replaceChildren();
|
|
|
|
|
META.forEach(function (m) {
|
|
|
|
|
var metric = report.metrics.find(function (x) { return x.key === m.key; });
|
|
|
|
|
var total = metric ? metric.total : 0;
|
|
|
|
|
var card = document.createElement('div');
|
|
|
|
|
card.className = 'analytics-kpi';
|
|
|
|
|
card.style.setProperty('--kpi-color', m.color);
|
|
|
|
|
var v = document.createElement('div');
|
|
|
|
|
v.className = 'kpi-value';
|
|
|
|
|
v.textContent = total.toLocaleString();
|
|
|
|
|
var lbl = document.createElement('div');
|
|
|
|
|
lbl.className = 'kpi-label';
|
|
|
|
|
lbl.textContent = metricLabel(m.key);
|
|
|
|
|
card.appendChild(v);
|
|
|
|
|
card.appendChild(lbl);
|
|
|
|
|
kpisEl.appendChild(card);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function initChart() {
|
|
|
|
|
var ctx = document.getElementById('anChart').getContext('2d');
|
|
|
|
|
var datasets = META.map(function (m) {
|
|
|
|
|
return {
|
|
|
|
|
label: metricLabel(m.key),
|
|
|
|
|
data: [],
|
|
|
|
|
borderColor: m.color,
|
|
|
|
|
backgroundColor: hexToRgba(m.color, 0.12),
|
|
|
|
|
borderWidth: 2,
|
|
|
|
|
pointRadius: 2,
|
|
|
|
|
tension: 0.25,
|
|
|
|
|
fill: true,
|
2026-09-20 21:00:03 +03:00
|
|
|
hidden: !!m.hidden,
|
2026-09-20 17:00:59 +03:00
|
|
|
_key: m.key
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
chart = new Chart(ctx, {
|
|
|
|
|
type: 'line',
|
|
|
|
|
data: { labels: [], datasets: datasets },
|
|
|
|
|
options: {
|
|
|
|
|
responsive: true,
|
|
|
|
|
maintainAspectRatio: false,
|
|
|
|
|
animation: false,
|
|
|
|
|
interaction: { mode: 'index', intersect: false },
|
2026-09-20 21:00:03 +03:00
|
|
|
plugins: {
|
|
|
|
|
legend: {
|
|
|
|
|
display: true,
|
|
|
|
|
position: 'top',
|
|
|
|
|
labels: {
|
|
|
|
|
usePointStyle: true,
|
|
|
|
|
pointStyle: 'rectRounded',
|
|
|
|
|
boxWidth: 12,
|
|
|
|
|
boxHeight: 12,
|
|
|
|
|
padding: 12,
|
|
|
|
|
font: { size: 12 }
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
tooltip: {
|
|
|
|
|
callbacks: {
|
|
|
|
|
title: function (items) {
|
|
|
|
|
return items.length ? formatLabel(items[0].label) : '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
scales: {
|
|
|
|
|
x: {
|
|
|
|
|
ticks: {
|
|
|
|
|
autoSkip: true,
|
|
|
|
|
maxRotation: 0,
|
|
|
|
|
callback: function (value) {
|
|
|
|
|
return formatLabel(this.getLabelForValue(value));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
y: { beginAtZero: true, ticks: { precision: 0 } }
|
|
|
|
|
}
|
2026-09-20 17:00:59 +03:00
|
|
|
},
|
|
|
|
|
plugins: [messageOverlay]
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyData(report) {
|
|
|
|
|
chart.data.labels = report.labels;
|
|
|
|
|
chart.data.datasets.forEach(function (ds) {
|
|
|
|
|
var metric = report.metrics.find(function (x) { return x.key === ds._key; });
|
|
|
|
|
ds.data = metric ? metric.values : [];
|
|
|
|
|
});
|
|
|
|
|
chart.update('none');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderTop(tableId, rows) {
|
|
|
|
|
var tbody = document.querySelector('#' + tableId + ' tbody');
|
|
|
|
|
tbody.replaceChildren();
|
|
|
|
|
if (!rows || rows.length === 0) {
|
|
|
|
|
var tr = document.createElement('tr');
|
|
|
|
|
var td = document.createElement('td');
|
|
|
|
|
td.colSpan = 2;
|
|
|
|
|
td.className = 'analytics-top-empty';
|
|
|
|
|
td.textContent = L.no_data;
|
|
|
|
|
tr.appendChild(td);
|
|
|
|
|
tbody.appendChild(tr);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
rows.forEach(function (row) {
|
|
|
|
|
var tr = document.createElement('tr');
|
|
|
|
|
var name = document.createElement('td');
|
|
|
|
|
name.textContent = row.label;
|
|
|
|
|
var cnt = document.createElement('td');
|
|
|
|
|
cnt.textContent = row.count.toLocaleString();
|
|
|
|
|
tr.appendChild(name);
|
|
|
|
|
tr.appendChild(cnt);
|
|
|
|
|
tbody.appendChild(tr);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mediaLabel(m) {
|
|
|
|
|
if (!m.exists) { return '#' + m.media_id; }
|
|
|
|
|
var base = m.client_name || '';
|
|
|
|
|
if (m.caption) { return base ? base + ': ' + m.caption : m.caption; }
|
|
|
|
|
return base ? base + ' #' + m.media_id : '#' + m.media_id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Builds a clickable preview (opens the shared lightbox) for a media ref,
|
|
|
|
|
// or a placeholder if the file was deleted.
|
|
|
|
|
function mediaLink(m, thumbClass) {
|
|
|
|
|
if (m && m.exists && m.url) {
|
|
|
|
|
var a = document.createElement('a');
|
|
|
|
|
a.href = m.url;
|
|
|
|
|
a.setAttribute('data-lightbox', m.file_type === 'video' ? 'video' : 'photo');
|
|
|
|
|
a.className = 'an-media-link';
|
|
|
|
|
var img = document.createElement('img');
|
|
|
|
|
img.className = thumbClass;
|
|
|
|
|
img.src = m.thumbnail_url;
|
|
|
|
|
img.alt = '';
|
|
|
|
|
img.loading = 'lazy';
|
|
|
|
|
a.appendChild(img);
|
|
|
|
|
var span = document.createElement('span');
|
|
|
|
|
span.textContent = mediaLabel(m);
|
|
|
|
|
a.appendChild(span);
|
|
|
|
|
return a;
|
|
|
|
|
}
|
|
|
|
|
var wrap = document.createElement('span');
|
|
|
|
|
wrap.className = 'an-media-link';
|
|
|
|
|
var ph = document.createElement('span');
|
|
|
|
|
ph.className = 'an-media-missing';
|
|
|
|
|
ph.textContent = '🗑';
|
|
|
|
|
wrap.appendChild(ph);
|
|
|
|
|
var lbl = document.createElement('span');
|
|
|
|
|
lbl.textContent = mediaLabel(m);
|
|
|
|
|
wrap.appendChild(lbl);
|
|
|
|
|
return wrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderTopMedia(rows) {
|
|
|
|
|
var tbody = document.querySelector('#anTopMedia tbody');
|
|
|
|
|
tbody.replaceChildren();
|
|
|
|
|
if (!rows || rows.length === 0) {
|
|
|
|
|
var tr = document.createElement('tr');
|
|
|
|
|
var td = document.createElement('td');
|
|
|
|
|
td.colSpan = 2;
|
|
|
|
|
td.className = 'analytics-top-empty';
|
|
|
|
|
td.textContent = L.no_data;
|
|
|
|
|
tr.appendChild(td);
|
|
|
|
|
tbody.appendChild(tr);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
rows.forEach(function (m) {
|
|
|
|
|
var tr = document.createElement('tr');
|
|
|
|
|
var td1 = document.createElement('td');
|
|
|
|
|
td1.appendChild(mediaLink(m, 'an-media-thumb'));
|
|
|
|
|
var td2 = document.createElement('td');
|
|
|
|
|
td2.textContent = (m.count || 0).toLocaleString();
|
|
|
|
|
tr.appendChild(td1);
|
|
|
|
|
tr.appendChild(td2);
|
|
|
|
|
tbody.appendChild(tr);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hasData(report) {
|
|
|
|
|
return report.metrics.some(function (m) { return m.total > 0; });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function load() {
|
|
|
|
|
var params = new URLSearchParams({
|
|
|
|
|
from: fromInput.value,
|
|
|
|
|
to: toInput.value,
|
|
|
|
|
granularity: granSelect.value
|
|
|
|
|
});
|
|
|
|
|
setMessage(L.loading);
|
|
|
|
|
fetch('/admin/analytics/data?' + params.toString(), { credentials: 'same-origin' })
|
|
|
|
|
.then(function (r) { return r.json(); })
|
|
|
|
|
.then(function (report) {
|
|
|
|
|
renderKpis(report);
|
|
|
|
|
applyData(report);
|
|
|
|
|
renderTop('anTopClients', report.top_clients);
|
|
|
|
|
renderTopMedia(report.top_media);
|
|
|
|
|
setMessage(hasData(report) ? '' : L.no_data);
|
|
|
|
|
})
|
|
|
|
|
.catch(function () { setMessage(L.error); });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll('.preset-btn').forEach(function (btn) {
|
|
|
|
|
btn.addEventListener('click', function () {
|
|
|
|
|
setRange(parseInt(btn.getAttribute('data-days'), 10));
|
|
|
|
|
load();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
fromInput.addEventListener('change', load);
|
|
|
|
|
toInput.addEventListener('change', load);
|
|
|
|
|
granSelect.addEventListener('change', load);
|
|
|
|
|
|
|
|
|
|
// --- Event log (newest first, infinite scroll of older events) ---------
|
|
|
|
|
var logEl = document.getElementById('anLog');
|
|
|
|
|
var logCursor = null;
|
|
|
|
|
var logLoading = false;
|
|
|
|
|
var logDone = false;
|
|
|
|
|
|
|
|
|
|
function logStatus(text) {
|
|
|
|
|
var s = document.createElement('div');
|
|
|
|
|
s.className = 'an-log-status';
|
|
|
|
|
s.textContent = text;
|
|
|
|
|
return s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function eventIcon(type) {
|
|
|
|
|
if (type === 'landing_view') { return '🌐'; }
|
|
|
|
|
if (type === 'portal_open') { return '👤'; }
|
|
|
|
|
if (type === 'media_view') { return '🖼️'; }
|
|
|
|
|
return '•';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function shortUa(ua) {
|
|
|
|
|
if (!ua) { return ''; }
|
|
|
|
|
var browser = '';
|
|
|
|
|
if (/Edg\//.test(ua)) { browser = 'Edge'; }
|
|
|
|
|
else if (/OPR\/|Opera/.test(ua)) { browser = 'Opera'; }
|
|
|
|
|
else if (/Chrome\//.test(ua)) { browser = 'Chrome'; }
|
|
|
|
|
else if (/Firefox\//.test(ua)) { browser = 'Firefox'; }
|
|
|
|
|
else if (/Safari\//.test(ua)) { browser = 'Safari'; }
|
|
|
|
|
var os = '';
|
|
|
|
|
if (/Windows/.test(ua)) { os = 'Windows'; }
|
|
|
|
|
else if (/iPhone|iPad|iPod/.test(ua)) { os = 'iOS'; }
|
|
|
|
|
else if (/Android/.test(ua)) { os = 'Android'; }
|
|
|
|
|
else if (/Mac OS X|Macintosh/.test(ua)) { os = 'macOS'; }
|
|
|
|
|
else if (/Linux/.test(ua)) { os = 'Linux'; }
|
|
|
|
|
return [browser, os].filter(Boolean).join(' · ');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function metaSpan(text) {
|
|
|
|
|
var s = document.createElement('span');
|
|
|
|
|
s.textContent = text;
|
|
|
|
|
return s;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 20:30:28 +03:00
|
|
|
function metaLine() {
|
|
|
|
|
var line = document.createElement('div');
|
|
|
|
|
line.className = 'an-log-line';
|
|
|
|
|
return line;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 17:00:59 +03:00
|
|
|
function renderLogRow(e) {
|
|
|
|
|
var row = document.createElement('div');
|
|
|
|
|
row.className = 'an-log-row';
|
|
|
|
|
|
2026-09-20 20:30:28 +03:00
|
|
|
// Single leading figure: media preview (clickable) or a type emoji.
|
|
|
|
|
var figure;
|
|
|
|
|
if (e.media && e.media.exists && e.media.url && e.media.thumbnail_url) {
|
|
|
|
|
figure = document.createElement('a');
|
|
|
|
|
figure.href = e.media.url;
|
|
|
|
|
figure.setAttribute('data-lightbox', e.media.file_type === 'video' ? 'video' : 'photo');
|
2026-09-20 17:00:59 +03:00
|
|
|
var img = document.createElement('img');
|
|
|
|
|
img.src = e.media.thumbnail_url;
|
|
|
|
|
img.alt = '';
|
|
|
|
|
img.loading = 'lazy';
|
2026-09-20 20:30:28 +03:00
|
|
|
figure.appendChild(img);
|
|
|
|
|
} else {
|
|
|
|
|
figure = document.createElement('div');
|
|
|
|
|
figure.textContent = eventIcon(e.event_type);
|
2026-09-20 17:00:59 +03:00
|
|
|
}
|
2026-09-20 20:30:28 +03:00
|
|
|
figure.className = 'an-log-figure';
|
|
|
|
|
row.appendChild(figure);
|
2026-09-20 17:00:59 +03:00
|
|
|
|
|
|
|
|
var main = document.createElement('div');
|
|
|
|
|
main.className = 'an-log-main';
|
|
|
|
|
|
|
|
|
|
var head = document.createElement('div');
|
|
|
|
|
head.className = 'an-log-head';
|
2026-09-20 21:00:03 +03:00
|
|
|
|
2026-09-20 17:00:59 +03:00
|
|
|
var ev = document.createElement('span');
|
|
|
|
|
ev.className = 'an-log-event';
|
2026-09-20 21:00:03 +03:00
|
|
|
if (e.event_type === 'media_view') {
|
|
|
|
|
ev.textContent = L.media_prefix;
|
|
|
|
|
head.appendChild(ev);
|
|
|
|
|
if (e.media && e.media.media_id != null) {
|
|
|
|
|
var num = document.createElement(e.client_id != null ? 'a' : 'span');
|
|
|
|
|
num.className = 'an-log-num';
|
|
|
|
|
num.textContent = '№' + e.media.media_id;
|
|
|
|
|
if (e.client_id != null) { num.href = '/admin/media?client_id=' + e.client_id; }
|
|
|
|
|
head.appendChild(num);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
ev.textContent = L[e.event_type] || e.event_type;
|
|
|
|
|
head.appendChild(ev);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 17:00:59 +03:00
|
|
|
if (e.client_name) {
|
2026-09-20 21:00:03 +03:00
|
|
|
var cl;
|
|
|
|
|
if (e.client_id != null) {
|
|
|
|
|
cl = document.createElement('a');
|
|
|
|
|
cl.href = '/admin/clients/' + e.client_id + '/edit';
|
|
|
|
|
} else {
|
|
|
|
|
cl = document.createElement('span');
|
|
|
|
|
}
|
2026-09-20 17:00:59 +03:00
|
|
|
cl.className = 'an-log-client';
|
|
|
|
|
cl.textContent = e.client_name;
|
|
|
|
|
head.appendChild(cl);
|
|
|
|
|
}
|
2026-09-20 21:00:03 +03:00
|
|
|
|
2026-09-20 17:00:59 +03:00
|
|
|
var tm = document.createElement('span');
|
|
|
|
|
tm.className = 'an-log-time';
|
|
|
|
|
tm.textContent = e.created_at;
|
|
|
|
|
head.appendChild(tm);
|
|
|
|
|
main.appendChild(head);
|
|
|
|
|
|
|
|
|
|
var meta = document.createElement('div');
|
|
|
|
|
meta.className = 'an-log-meta';
|
2026-09-20 20:30:28 +03:00
|
|
|
|
|
|
|
|
var line1 = metaLine();
|
|
|
|
|
if (e.visitor) { line1.appendChild(metaSpan(L.visitor + ': ' + e.visitor)); }
|
2026-09-20 17:00:59 +03:00
|
|
|
var ua = shortUa(e.user_agent);
|
2026-09-20 20:30:28 +03:00
|
|
|
if (ua) { line1.appendChild(metaSpan(ua)); }
|
|
|
|
|
if (line1.childNodes.length) { meta.appendChild(line1); }
|
|
|
|
|
|
|
|
|
|
if (e.referer) {
|
|
|
|
|
var line3 = metaLine();
|
|
|
|
|
var ref = document.createElement('span');
|
|
|
|
|
ref.className = 'an-log-ref';
|
|
|
|
|
ref.textContent = '← ' + e.referer;
|
|
|
|
|
line3.appendChild(ref);
|
|
|
|
|
meta.appendChild(line3);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 17:00:59 +03:00
|
|
|
if (meta.childNodes.length) { main.appendChild(meta); }
|
|
|
|
|
|
|
|
|
|
row.appendChild(main);
|
|
|
|
|
return row;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 20:11:18 +03:00
|
|
|
function selectedLogTypes() {
|
|
|
|
|
var types = [];
|
|
|
|
|
document.querySelectorAll('.log-type-toggle').forEach(function (cb) {
|
|
|
|
|
if (cb.checked) { types.push(cb.getAttribute('data-type')); }
|
|
|
|
|
});
|
|
|
|
|
return types;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resetLog() {
|
|
|
|
|
logEl.replaceChildren();
|
|
|
|
|
logCursor = null;
|
|
|
|
|
logLoading = false;
|
|
|
|
|
logDone = false;
|
|
|
|
|
loadLog();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 17:00:59 +03:00
|
|
|
function loadLog() {
|
|
|
|
|
if (logLoading || logDone) { return; }
|
2026-09-20 20:11:18 +03:00
|
|
|
var types = selectedLogTypes();
|
|
|
|
|
if (types.length === 0) {
|
|
|
|
|
logEl.replaceChildren(logStatus(L.log_empty));
|
|
|
|
|
logDone = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-09-20 17:00:59 +03:00
|
|
|
logLoading = true;
|
|
|
|
|
var busy = logStatus(L.loading);
|
|
|
|
|
logEl.appendChild(busy);
|
|
|
|
|
var params = new URLSearchParams({ limit: '40' });
|
|
|
|
|
if (logCursor) { params.set('before', logCursor); }
|
2026-09-20 20:11:18 +03:00
|
|
|
types.forEach(function (t) { params.append('types', t); });
|
2026-09-20 17:00:59 +03:00
|
|
|
fetch('/admin/analytics/events?' + params.toString(), { credentials: 'same-origin' })
|
|
|
|
|
.then(function (r) { return r.json(); })
|
|
|
|
|
.then(function (data) {
|
|
|
|
|
busy.remove();
|
|
|
|
|
(data.events || []).forEach(function (e) { logEl.appendChild(renderLogRow(e)); });
|
|
|
|
|
logCursor = data.next_cursor;
|
|
|
|
|
logLoading = false;
|
|
|
|
|
if (!logEl.querySelector('.an-log-row')) {
|
|
|
|
|
logEl.appendChild(logStatus(L.log_empty));
|
|
|
|
|
logDone = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!logCursor) {
|
|
|
|
|
logDone = true;
|
|
|
|
|
logEl.appendChild(logStatus(L.log_end));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// Keep loading until the scroll container is actually scrollable.
|
|
|
|
|
if (logEl.scrollHeight <= logEl.clientHeight + 20) { loadLog(); }
|
|
|
|
|
})
|
|
|
|
|
.catch(function () {
|
|
|
|
|
busy.remove();
|
|
|
|
|
logLoading = false;
|
|
|
|
|
logEl.appendChild(logStatus(L.error));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logEl.addEventListener('scroll', function () {
|
|
|
|
|
if (logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 40) { loadLog(); }
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-20 20:11:18 +03:00
|
|
|
document.querySelectorAll('.log-type-toggle').forEach(function (cb) {
|
|
|
|
|
cb.addEventListener('change', resetLog);
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-20 17:00:59 +03:00
|
|
|
initChart();
|
|
|
|
|
setRange(30);
|
|
|
|
|
load();
|
|
|
|
|
loadLog();
|
|
|
|
|
})();
|
|
|
|
|
</script>
|
|
|
|
|
{% endblock %}
|