Diff -- /var/www/vhosts/3dshawn.com/abforge.3dshawn.com/TEMPLATES/ab_admin_traffic.html

O Operator
Diff

/var/www/vhosts/3dshawn.com/abforge.3dshawn.com/TEMPLATES/ab_admin_traffic.html

added on local at 2026-07-01 16:01:59

Added
+317
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 802ba8bf0350
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1<!-- =====================================================================
2 ContactForge -- Admin Traffic Reports.
3 Tabs: overview | sources | devices | geo | pages.
4===================================================================== -->
5<style>
6 .tr-grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap:12px; margin-bottom:22px; }
7 .tr-tile { background: var(--col-surface-1); border:1px solid var(--col-border); border-radius:12px; padding:14px 16px; }
8 .tr-tile .lbl { font-size:10px; letter-spacing:1.5px; text-transform:uppercase; color:var(--col-text-3); font-weight:700; }
9 .tr-tile .val { font-size:24px; font-weight:700; color:#fff; font-family: var(--font-mono, monospace); margin-top:4px; }
10
11 .tr-tabs { display:flex; gap:0; border-bottom:1px solid var(--col-border); margin-bottom:18px; flex-wrap:wrap; }
12 .tr-tab { padding:10px 18px; font-size:13px; font-weight:600; color:var(--col-text-2); text-decoration:none; border-bottom:2px solid transparent; letter-spacing:0.3px; transition:all 0.15s; }
13 .tr-tab:hover { color:var(--col-text); background:var(--col-surface-2); }
14 .tr-tab.is-active { color:#2dd4bf; border-bottom-color:#0d9488; }
15
16 .tr-table { width:100%; border-collapse: separate; border-spacing:0; background: var(--col-surface-1); border:1px solid var(--col-border); border-radius:12px; overflow:hidden; }
17 .tr-table thead th { background: var(--col-surface-2); padding:11px 14px; text-align:left; font-size:10px; letter-spacing:1.5px; text-transform:uppercase; color:var(--col-text-3); font-weight:700; border-bottom:1px solid var(--col-border); }
18 .tr-table tbody td { padding:12px 14px; border-bottom:1px solid var(--col-border); font-size:13px; color: var(--col-text-2); vertical-align:middle; }
19 .tr-table tbody tr:last-child td { border-bottom: none; }
20 .tr-table .num { text-align:right; font-family: var(--font-mono, monospace); }
21
22 .tr-bar-row { display:grid; grid-template-columns: 1fr 60px 50px; gap:10px; align-items:center; padding:8px 12px; border-bottom:1px solid var(--col-border); }
23 .tr-bar-row:last-child { border-bottom:none; }
24 .tr-bar-row .label { color:#fff; font-size:13px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
25 .tr-bar-row .count { color:var(--col-text-2); font-family:var(--font-mono, monospace); font-size:12px; text-align:right; }
26 .tr-bar-row .pct { color:var(--col-text-3); font-family:var(--font-mono, monospace); font-size:11px; text-align:right; }
27 .tr-bar { height:4px; border-radius:2px; background:rgba(45,212,191,0.10); overflow:hidden; margin-top:4px; grid-column:1 / -1; }
28 .tr-bar > div { height:100%; background:linear-gradient(90deg,#0d9488,#2dd4bf); }
29
30 .tr-section-head { display:flex; align-items:baseline; justify-content:space-between; margin: 22px 0 10px; }
31 .tr-section-head h2 { font-size:14px; letter-spacing:1.2px; text-transform:uppercase; color:var(--col-text-3); margin:0; font-weight:700; }
32
33 .tr-two-col { display:grid; grid-template-columns: 1fr 1fr; gap:18px; }
34 @media (max-width: 900px) { .tr-two-col { grid-template-columns:1fr; } }
35</style>
36
37<div class="page-head">
38 <div>
39 <span class="page-eyebrow"><span class="dot"></span> Admin &middot; ABForge analytics</span>
40 <h1 class="page-title">Traffic Reports</h1>
41 <p class="page-subtitle">Where ABForge visitors are coming from, what they're using, and what they're reading. Last $days days.</p>
42 </div>
43</div>
44
45[if:$schema_missing]
46<div class="banner warn mb-3">
47 <strong>Visitor-tracking schema not installed yet.</strong> Run db_schema.sql to enable the visitors / tracking_sessions / pageviews tables.
48</div>
49[/if]
50
51[if:$schema_ready]
52
53<!-- ============== Range picker ============== -->
54<form id="rangeForm" method="get" action="/admin_traffic.cgi"
55 style="display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:8px 0 14px;padding:10px 14px;background:var(--col-surface);border:1px solid var(--col-border);border-radius:12px">
56 <span style="font-size:11.5px;letter-spacing:1px;text-transform:uppercase;color:var(--col-text-muted);font-weight:700">Range</span>
57 <a class="tr-chip[if:$preset_24h] is-active[/if]" href="?days=1">24h</a>
58 <a class="tr-chip[if:$preset_7d] is-active[/if]" href="?days=7">7d</a>
59 <a class="tr-chip[if:$preset_30d] is-active[/if]" href="?days=30">30d</a>
60 <a class="tr-chip[if:$preset_90d] is-active[/if]" href="?days=90">90d</a>
61 <a class="tr-chip[if:$preset_1y] is-active[/if]" href="?days=365">1y</a>
62 <button type="button" class="tr-chip[if:$preset_custom] is-active[/if]" id="customBtn">Custom&hellip;</button>
63 <div id="customWrap" style="display:[if:$preset_custom]flex[/if][if:!$preset_custom]none[/if];gap:6px;align-items:center;margin-left:8px">
64 <input type="datetime-local" name="from" value="$range_from_input" data-fancy>
65 <span style="color:var(--col-text-muted);font-size:12px">to</span>
66 <input type="datetime-local" name="to" value="$range_to_input" data-fancy>
67 <button type="submit" class="btn btn-secondary btn-sm">Apply</button>
68 </div>
69 <span style="margin-left:auto;font-size:11.5px;color:var(--col-text-muted)">Showing: <strong style="color:var(--col-text)">$range_label</strong></span>
70</form>
71<style>
72.tr-chip{display:inline-flex;align-items:center;padding:5px 11px;border-radius:999px;font-size:12px;font-weight:600;color:var(--col-text-muted);text-decoration:none;background:transparent;border:1px solid var(--col-border);cursor:pointer;transition:all .15s ease}
73.tr-chip:hover{color:var(--col-text);border-color:var(--col-brand,#06b6d4)}
74.tr-chip.is-active{background:var(--col-brand,#06b6d4);color:#0c1018;border-color:var(--col-brand,#06b6d4)}
75</style>
76<script>
77(function(){
78 var btn = document.getElementById('customBtn');
79 var wrap = document.getElementById('customWrap');
80 if (!btn || !wrap) return;
81 btn.addEventListener('click', function(){
82 var open = wrap.style.display !== 'none';
83 wrap.style.display = open ? 'none' : 'flex';
84 if (!open) wrap.querySelector('input[type=datetime-local]').focus();
85 });
86})();
87</script>
88
89<!-- KPI strip: 4 large tiles (Meta-Admin-style layout) -->
90<style>
91.tr-grid-4 { display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:14px; margin-bottom:24px; }
92.tr-tile-lg { position:relative; background: var(--col-surface-1, var(--col-surface, #0f1a26)); border:1px solid var(--col-border); border-radius:14px; padding:18px 22px; overflow:hidden; }
93.tr-tile-lg::before { content:''; position:absolute; inset:0 0 auto 0; height:60px; background: linear-gradient(180deg, var(--col-brand, #06b6d4)33, transparent); pointer-events:none; }
94.tr-tile-lg .tile-head { display:flex; align-items:center; gap:8px; position:relative; z-index:1; }
95.tr-tile-lg .tile-ico { width:18px; height:18px; color: var(--col-brand, #06b6d4); flex:0 0 auto; }
96.tr-tile-lg .tile-name { font-size:12.5px; font-weight:700; color:#fff; letter-spacing:.2px; }
97.tr-tile-lg .val { font-size:30px; font-weight:700; color:#fff; font-variant-numeric: tabular-nums; margin:12px 0 4px; position:relative; z-index:1; }
98.tr-tile-lg .sub { font-size:10.5px; letter-spacing:1.4px; text-transform:uppercase; color: var(--col-text-muted); font-weight:600; position:relative; z-index:1; }
99.tr-tile-lg .foot { font-size:11px; color: var(--col-text-dim); margin-top:10px; position:relative; z-index:1; }
100</style>
101<div class="tr-grid-4">
102 <div class="tr-tile-lg">
103 <div class="tile-head"><svg class="tile-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z"/><circle cx="12" cy="12" r="3"/></svg><span class="tile-name">Page views</span></div>
104 <div class="val">$kpi_pageviews</div>
105 <div class="sub">across this site</div>
106 <div class="foot">$range_label</div>
107 </div>
108 <div class="tr-tile-lg">
109 <div class="tile-head"><svg class="tile-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg><span class="tile-name">Visitors</span></div>
110 <div class="val">$kpi_visitors</div>
111 <div class="sub">distinct sessions</div>
112 <div class="foot">$range_label</div>
113 </div>
114 <div class="tr-tile-lg">
115 <div class="tile-head"><svg class="tile-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg><span class="tile-name">Sessions</span></div>
116 <div class="val">$kpi_sessions</div>
117 <div class="sub">total session rows</div>
118 <div class="foot">$range_label</div>
119 </div>
120 <div class="tr-tile-lg">
121 <div class="tile-head"><svg class="tile-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg><span class="tile-name">Countries</span></div>
122 <div class="val">$kpi_countries</div>
123 <div class="sub">on the globe</div>
124 <div class="foot">$range_label</div>
125 </div>
126</div>
127<!-- (Old 6-tile strip removed; engagement metrics live in modal + per-country leaderboard now.) -->
128<div style="display:none">
129 <div class="tr-tile"><div class="lbl">Pageviews</div><div class="val">$kpi_pageviews</div></div>
130 <div class="tr-tile"><div class="lbl">Visitors</div><div class="val">$kpi_visitors</div></div>
131 <div class="tr-tile"><div class="lbl">Sessions</div><div class="val">$kpi_sessions</div></div>
132 <div class="tr-tile"><div class="lbl">Avg duration</div><div class="val">$kpi_avg_seconds<span style="font-size:13px">s</span></div></div>
133 <div class="tr-tile"><div class="lbl">Bounce</div><div class="val">$kpi_bounce_rate<span style="font-size:13px">%</span></div></div>
134 <div class="tr-tile"><div class="lbl">PVs / session</div><div class="val">$kpi_pv_per_session</div></div>
135</div>
136
137<!-- ============== Interactive 3D globe -- branded per site ============== -->
138[if:$has_globe]
139<div class="tr-section-head"><h2>Traffic by region (last $days days)</h2></div>
140<script>
141window.GLOBE_DATA = { countries: $globe_json };
142window.GLOBE_STATE_DATA = $state_json;
143window.GLOBE_FULL_TREE = $tree_json;
144window.GLOBE_COUNTRY_DIMENSIONS = $dims_json;
145window.GLOBE_STATE_DIMENSIONS = $sdims_json;
146window.SITE_OVERALL_DIMS = $overall_dims_json;
147</script>
148<div data-globe data-width="1200" data-height="600"
149 style="background:#0c1018;border:1px solid var(--col-border);border-radius:14px;overflow:hidden;position:relative;aspect-ratio:1200/600;margin-bottom:24px">
150 <noscript>
151 <div style="padding:24px;color:var(--col-text);font-size:13px">JavaScript is required to render the interactive globe.</div>
152 </noscript>
153</div>
154<!-- Country detail modal: opens on globe country click. -->
155<div id="siteModalBackdrop" style="position:fixed;inset:0;z-index:9998;display:none;background:rgba(3,8,31,0.78);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px)"></div>
156<div id="siteModal" style="position:fixed;z-index:9999;display:none;top:5vh;left:50%;transform:translateX(-50%);width:min(820px,92vw);max-height:90vh;overflow-y:auto;background:var(--col-surface,#0f1a26);border:1px solid var(--col-border);border-radius:16px;box-shadow:0 30px 80px rgba(0,0,0,0.7);color:var(--col-text,#e2e8f0)"></div>
157<script>
158(function(){
159 var modal = document.getElementById('siteModal');
160 var backdrop = document.getElementById('siteModalBackdrop');
161 if (!modal || !backdrop) return;
162 var stateData = window.GLOBE_STATE_DATA || {};
163 var fullTree = window.GLOBE_FULL_TREE || {};
164 var countryDims = window.GLOBE_COUNTRY_DIMENSIONS || {};
165 var stateDims = window.GLOBE_STATE_DIMENSIONS || {};
166 var nameMap = {};
167 (window.GLOBE_DATA && window.GLOBE_DATA.countries || []).forEach(function(c){ if (c.code) nameMap[c.code] = { name: c.name || c.code, count: c.count || 0 }; });
168 function fmt(n){ if(!n) return '0'; if (n<1000) return n+''; if(n<100000) return (n/1000).toFixed(1)+'k'; return Math.floor(n/1000)+'k'; }
169 function esc(s){ return String(s||'').replace(/[&<>"]/g, function(m){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m];}); }
170 function header(eyebrow, title){
171 return '<div style="padding:22px 26px 14px;border-bottom:1px solid var(--col-border);display:flex;align-items:center;justify-content:space-between;gap:16px">'
172 + '<div>'
173 + '<div style="font-size:10.5px;letter-spacing:1.6px;color:var(--col-text-muted);font-weight:700;text-transform:uppercase">'+eyebrow+'</div>'
174 + '<div style="font-size:24px;font-weight:700;color:#fff;margin-top:4px">'+title+'</div>'
175 + '</div>'
176 + '<button type="button" id="siteModalClose" style="background:var(--col-bg-2,#0e1822);border:1px solid var(--col-border);color:var(--col-text);width:36px;height:36px;border-radius:8px;cursor:pointer;font-size:18px;line-height:1">&times;</button>'
177 + '</div>';
178 }
179 function kpis(arr){
180 var html = '<div style="padding:18px 26px 8px;display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px">';
181 arr.forEach(function(k){
182 html += '<div style="background:var(--col-bg-2,#0e1822);border:1px solid var(--col-border);border-radius:10px;padding:12px 14px">'
183 + '<div style="font-size:9.5px;letter-spacing:1.5px;color:var(--col-text-muted);font-weight:700;text-transform:uppercase">'+k.lbl+'</div>'
184 + '<div style="font-size:20px;font-weight:700;color:#fff;margin-top:4px;font-variant-numeric:tabular-nums">'+k.val+'</div>'
185 + '</div>';
186 });
187 return html + '</div>';
188 }
189 function barList(items, valKey, heading, max){
190 if (!items.length) return '';
191 var html = '<div style="padding:14px 26px 22px;border-top:1px solid var(--col-border);margin-top:8px"><div style="font-size:10.5px;letter-spacing:1.4px;color:var(--col-text-muted);font-weight:700;text-transform:uppercase;margin-bottom:10px">'+heading+'</div>';
192 items.forEach(function(it){
193 var v = it[valKey] || 0;
194 var pct = max ? (v / max * 100) : 0;
195 html += '<div style="margin-bottom:8px">'
196 + '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:3px">'
197 + '<span style="color:#fff;font-size:12.5px">'+esc(it.name)+'</span>'
198 + '<span style="color:var(--col-text-muted);font-variant-numeric:tabular-nums;font-size:12px">'+fmt(v)+'</span>'
199 + '</div>'
200 + '<div style="height:4px;border-radius:2px;background:rgba(255,255,255,0.05);overflow:hidden">'
201 + '<div style="height:100%;width:'+pct.toFixed(1)+'%;background:var(--col-brand,#06b6d4)"></div>'
202 + '</div></div>';
203 });
204 return html + '</div>';
205 }
206 function renderDimension(label, rowsObj){
207 var entries = Object.keys(rowsObj || {}).map(function(k){return {name:k,count:rowsObj[k]};})
208 .sort(function(a,b){return b.count - a.count;});
209 if (!entries.length) return '';
210 var max = entries[0].count || 1;
211 var html = '<div style="background:var(--col-bg-2,#0e1822);border:1px solid var(--col-border);border-radius:10px;padding:14px 16px"><div style="font-size:10px;letter-spacing:1.5px;color:var(--col-text-muted);font-weight:700;text-transform:uppercase;margin-bottom:10px">'+label+'</div>';
212 entries.slice(0, 8).forEach(function(e){
213 var pct = (e.count / max * 100);
214 html += '<div style="margin-bottom:6px">'
215 + '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:2px">'
216 + '<span style="color:#fff;font-size:12px">'+esc(e.name)+'</span>'
217 + '<span style="color:var(--col-text-muted);font-variant-numeric:tabular-nums;font-size:11.5px">'+fmt(e.count)+'</span>'
218 + '</div>'
219 + '<div style="height:3px;border-radius:2px;background:rgba(255,255,255,0.05);overflow:hidden">'
220 + '<div style="height:100%;width:'+pct.toFixed(1)+'%;background:var(--col-brand,#06b6d4)"></div>'
221 + '</div></div>';
222 });
223 return html + '</div>';
224 }
225 function showCountry(code){
226 var meta = nameMap[code] || { name: code, count: 0 };
227 var stateRows = stateData[code] || {};
228 var dims = countryDims[code] || {};
229 var states = Object.keys(stateRows).map(function(s){return {name:s,visitors:stateRows[s]};}).sort(function(a,b){return b.visitors - a.visitors;});
230 var html = header('Country detail', esc(meta.name));
231 html += kpis([
232 { lbl:'Visitors', val: fmt(meta.count) },
233 { lbl:'States with traffic', val: states.length }
234 ]);
235 if (states.length) {
236 html += barList(states.slice(0, 15), 'visitors', 'Top states / regions', states[0].visitors || 1);
237 } else {
238 html += '<div style="padding:14px 26px 22px;color:var(--col-text-muted);font-size:13px;border-top:1px solid var(--col-border);margin-top:8px">No state-level data for this country in the selected window.</div>';
239 }
240 if (Object.keys(dims).length) {
241 html += '<div style="padding:18px 26px 22px;border-top:1px solid var(--col-border);margin-top:8px"><div style="font-size:10.5px;letter-spacing:1.4px;color:var(--col-text-muted);font-weight:700;text-transform:uppercase;margin-bottom:14px">Audience breakdown</div>'
242 + '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px">'
243 + renderDimension('Browser', dims.browsers)
244 + renderDimension('Operating system', dims.os)
245 + renderDimension('Device type', dims.devices)
246 + renderDimension('Screen resolution', dims.resolutions)
247 + renderDimension('Viewport', dims.viewports)
248 + renderDimension('Language', dims.languages)
249 + renderDimension('Timezone', dims.timezones)
250 + '</div></div>';
251 }
252 modal.innerHTML = html;
253 }
254 function showState(code, region){
255 var country = fullTree[code] || { states: {} };
256 var st = country.states[region] || { visitors: 0, sessions: 0, cities: [] };
257 var dims = (stateDims[code] && stateDims[code][region]) || {};
258 var countryName = (nameMap[code] || { name: code }).name;
259 var html = header('State / region detail', esc(region) + ' <span style="font-size:14px;color:var(--col-text-muted);font-weight:500"> &middot; ' + esc(countryName) + '</span>');
260 html += kpis([
261 { lbl:'Visitors', val: fmt(st.visitors) },
262 { lbl:'Sessions', val: fmt(st.sessions) },
263 { lbl:'Cities', val: st.cities.length },
264 { lbl:'Country', val: code }
265 ]);
266 var cityItems = (st.cities || []).map(function(c){ return { name: c.city, visitors: c.visitors }; });
267 if (cityItems.length) {
268 html += barList(cityItems.slice(0, 20), 'visitors', 'Top cities in ' + region, cityItems[0].visitors || 1);
269 } else {
270 html += '<div style="padding:14px 26px 22px;color:var(--col-text-muted);font-size:13px;border-top:1px solid var(--col-border);margin-top:8px">No city data for this state in the selected window.</div>';
271 }
272 if (Object.keys(dims).length) {
273 html += '<div style="padding:18px 26px 22px;border-top:1px solid var(--col-border);margin-top:8px"><div style="font-size:10.5px;letter-spacing:1.4px;color:var(--col-text-muted);font-weight:700;text-transform:uppercase;margin-bottom:14px">Audience breakdown - ' + esc(region) + '</div>'
274 + '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px">'
275 + renderDimension('Browser', dims.browsers)
276 + renderDimension('Operating system', dims.os)
277 + renderDimension('Device type', dims.devices)
278 + renderDimension('Screen resolution', dims.resolutions)
279 + renderDimension('Viewport', dims.viewports)
280 + renderDimension('Language', dims.languages)
281 + renderDimension('Timezone', dims.timezones)
282 + '</div></div>';
283 }
284 modal.innerHTML = html;
285 }
286 function show(code, region){
287 if (region) showState(code, region); else showCountry(code);
288 modal.style.display = 'block';
289 backdrop.style.display = 'block';
290 document.getElementById('siteModalClose').addEventListener('click', closeModal);
291 }
292 function closeModal(){ modal.style.display='none'; backdrop.style.display='none'; }
293 document.addEventListener('click', function(e){
294 var g = e.target && e.target.closest ? e.target.closest('.globe-country') : null;
295 if (!g) return;
296 var code = g.getAttribute('data-code');
297 if (!code) return;
298 var region = g.getAttribute('data-region') || '';
299 e.stopPropagation();
300 e.preventDefault();
301 show(code, region || null);
302 }, true);
303 backdrop.addEventListener('click', closeModal);
304 document.addEventListener('keydown', function(e){ if (e.key === 'Escape' && modal.style.display !== 'none') closeModal(); });
305})();
306</script>
307[/if]
308[if:!$has_globe]
309<div class="banner info" style="margin-bottom:18px">No geo data with ISO country codes yet. The globe lights up once visitors land with country_iso populated.</div>
310[/if]
311
312[if:$has_accordion]
313<div class="tr-section-head" style="margin-top:8px"><h2>By country / state / city <span style="font-size:12px;color:var(--col-text-muted);font-weight:400">- click any row with the chevron to expand</span></h2></div>
314$accordion_html
315[/if]
316
317[/if]