Diff -- /var/www/vhosts/3dshawn.com/crm.3dshawn.com/TEMPLATES/cf_admin_traffic.html
Diff

/var/www/vhosts/3dshawn.com/crm.3dshawn.com/TEMPLATES/cf_admin_traffic.html

added on local at 2026-07-06 01:46:58

Added
+316
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to e81eb0723296
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; ContactForge analytics</span>
40 <h1 class="page-title">Traffic Reports</h1>
41 <p class="page-subtitle">Where ContactForge 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<div style="display:none">
128 <div class="tr-tile"><div class="lbl">Pageviews</div><div class="val">$kpi_pageviews</div></div>
129 <div class="tr-tile"><div class="lbl">Visitors</div><div class="val">$kpi_visitors</div></div>
130 <div class="tr-tile"><div class="lbl">Sessions</div><div class="val">$kpi_sessions</div></div>
131 <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>
132 <div class="tr-tile"><div class="lbl">Bounce</div><div class="val">$kpi_bounce_rate<span style="font-size:13px">%</span></div></div>
133 <div class="tr-tile"><div class="lbl">PVs / session</div><div class="val">$kpi_pv_per_session</div></div>
134</div>
135
136<!-- ============== Interactive 3D globe -- branded per site ============== -->
137[if:$has_globe]
138<div class="tr-section-head"><h2>Traffic by region (last $days days)</h2></div>
139<script>
140window.GLOBE_DATA = { countries: $globe_json };
141window.GLOBE_STATE_DATA = $state_json;
142window.GLOBE_FULL_TREE = $tree_json;
143window.GLOBE_COUNTRY_DIMENSIONS = $dims_json;
144window.GLOBE_STATE_DIMENSIONS = $sdims_json;
145window.SITE_OVERALL_DIMS = $overall_dims_json;
146</script>
147<div data-globe data-width="1200" data-height="600"
148 style="background:#0c1018;border:1px solid var(--col-border);border-radius:14px;overflow:hidden;position:relative;aspect-ratio:1200/600;margin-bottom:24px">
149 <noscript>
150 <div style="padding:24px;color:var(--col-text);font-size:13px">JavaScript is required to render the interactive globe.</div>
151 </noscript>
152</div>
153<!-- Country detail modal opens on globe country click. -->
154<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>
155<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>
156<script>
157(function(){
158 var modal = document.getElementById('siteModal');
159 var backdrop = document.getElementById('siteModalBackdrop');
160 if (!modal || !backdrop) return;
161 var stateData = window.GLOBE_STATE_DATA || {};
162 var fullTree = window.GLOBE_FULL_TREE || {};
163 var countryDims = window.GLOBE_COUNTRY_DIMENSIONS || {};
164 var stateDims = window.GLOBE_STATE_DIMENSIONS || {};
165 var nameMap = {};
166 (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 }; });
167 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'; }
168 function esc(s){ return String(s||'').replace(/[&<>"]/g, function(m){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m];}); }
169 function header(eyebrow, title){
170 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">'
171 + '<div>'
172 + '<div style="font-size:10.5px;letter-spacing:1.6px;color:var(--col-text-muted);font-weight:700;text-transform:uppercase">'+eyebrow+'</div>'
173 + '<div style="font-size:24px;font-weight:700;color:#fff;margin-top:4px">'+title+'</div>'
174 + '</div>'
175 + '<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>'
176 + '</div>';
177 }
178 function kpis(arr){
179 var html = '<div style="padding:18px 26px 8px;display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px">';
180 arr.forEach(function(k){
181 html += '<div style="background:var(--col-bg-2,#0e1822);border:1px solid var(--col-border);border-radius:10px;padding:12px 14px">'
182 + '<div style="font-size:9.5px;letter-spacing:1.5px;color:var(--col-text-muted);font-weight:700;text-transform:uppercase">'+k.lbl+'</div>'
183 + '<div style="font-size:20px;font-weight:700;color:#fff;margin-top:4px;font-variant-numeric:tabular-nums">'+k.val+'</div>'
184 + '</div>';
185 });
186 return html + '</div>';
187 }
188 function barList(items, valKey, heading, max){
189 if (!items.length) return '';
190 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>';
191 items.forEach(function(it){
192 var v = it[valKey] || 0;
193 var pct = max ? (v / max * 100) : 0;
194 html += '<div style="margin-bottom:8px">'
195 + '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:3px">'
196 + '<span style="color:#fff;font-size:12.5px">'+esc(it.name)+'</span>'
197 + '<span style="color:var(--col-text-muted);font-variant-numeric:tabular-nums;font-size:12px">'+fmt(v)+'</span>'
198 + '</div>'
199 + '<div style="height:4px;border-radius:2px;background:rgba(255,255,255,0.05);overflow:hidden">'
200 + '<div style="height:100%;width:'+pct.toFixed(1)+'%;background:var(--col-brand,#06b6d4)"></div>'
201 + '</div></div>';
202 });
203 return html + '</div>';
204 }
205 function renderDimension(label, rowsObj){
206 var entries = Object.keys(rowsObj || {}).map(function(k){return {name:k,count:rowsObj[k]};})
207 .sort(function(a,b){return b.count - a.count;});
208 if (!entries.length) return '';
209 var max = entries[0].count || 1;
210 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>';
211 entries.slice(0, 8).forEach(function(e){
212 var pct = (e.count / max * 100);
213 html += '<div style="margin-bottom:6px">'
214 + '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:2px">'
215 + '<span style="color:#fff;font-size:12px">'+esc(e.name)+'</span>'
216 + '<span style="color:var(--col-text-muted);font-variant-numeric:tabular-nums;font-size:11.5px">'+fmt(e.count)+'</span>'
217 + '</div>'
218 + '<div style="height:3px;border-radius:2px;background:rgba(255,255,255,0.05);overflow:hidden">'
219 + '<div style="height:100%;width:'+pct.toFixed(1)+'%;background:var(--col-brand,#06b6d4)"></div>'
220 + '</div></div>';
221 });
222 return html + '</div>';
223 }
224 function showCountry(code){
225 var meta = nameMap[code] || { name: code, count: 0 };
226 var stateRows = stateData[code] || {};
227 var dims = countryDims[code] || {};
228 var states = Object.keys(stateRows).map(function(s){return {name:s,visitors:stateRows[s]};}).sort(function(a,b){return b.visitors - a.visitors;});
229 var html = header('Country detail', esc(meta.name));
230 html += kpis([
231 { lbl:'Visitors', val: fmt(meta.count) },
232 { lbl:'States with traffic', val: states.length }
233 ]);
234 if (states.length) {
235 html += barList(states.slice(0, 15), 'visitors', 'Top states / regions', states[0].visitors || 1);
236 } else {
237 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>';
238 }
239 if (Object.keys(dims).length) {
240 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>'
241 + '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px">'
242 + renderDimension('Browser', dims.browsers)
243 + renderDimension('Operating system', dims.os)
244 + renderDimension('Device type', dims.devices)
245 + renderDimension('Screen resolution', dims.resolutions)
246 + renderDimension('Viewport', dims.viewports)
247 + renderDimension('Language', dims.languages)
248 + renderDimension('Timezone', dims.timezones)
249 + '</div></div>';
250 }
251 modal.innerHTML = html;
252 }
253 function showState(code, region){
254 var country = fullTree[code] || { states: {} };
255 var st = country.states[region] || { visitors: 0, sessions: 0, cities: [] };
256 var countryName = (nameMap[code] || { name: code }).name;
257 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>');
258 html += kpis([
259 { lbl:'Visitors', val: fmt(st.visitors) },
260 { lbl:'Sessions', val: fmt(st.sessions) },
261 { lbl:'Cities', val: st.cities.length },
262 { lbl:'Country', val: code }
263 ]);
264 var cityItems = (st.cities || []).map(function(c){ return { name: c.city, visitors: c.visitors }; });
265 if (cityItems.length) {
266 html += barList(cityItems.slice(0, 20), 'visitors', 'Top cities in ' + region, cityItems[0].visitors || 1);
267 } else {
268 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>';
269 }
270 var dims = (stateDims[code] && stateDims[code][region]) || {};
271 if (Object.keys(dims).length) {
272 html += '<div style="padding:18px 26px 22px;border-top:1px solid var(--col-border);margin-top:8px">'
273 + '<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[/if]
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help