Diff -- /var/www/vhosts/3dshawn.com/abforge.3dshawn.com/TEMPLATES/ab_admin_traffic.html
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
to 802ba8bf0350
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 · 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…</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> | |
| 141 | window.GLOBE_DATA = { countries: $globe_json }; | |
| 142 | window.GLOBE_STATE_DATA = $state_json; | |
| 143 | window.GLOBE_FULL_TREE = $tree_json; | |
| 144 | window.GLOBE_COUNTRY_DIMENSIONS = $dims_json; | |
| 145 | window.GLOBE_STATE_DIMENSIONS = $sdims_json; | |
| 146 | window.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 {'&':'&','<':'<','>':'>','"':'"'}[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">×</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"> · ' + 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] |