Diff -- /var/www/vhosts/3dshawn.com/admin.3dshawn.com/visitors.cgi

O Operator
Diff

/var/www/vhosts/3dshawn.com/admin.3dshawn.com/visitors.cgi

added on local at 2026-07-11 08:10:02

Added
+596
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to d1ae7348f46b
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1#!/usr/bin/perl
2#======================================================================
3# Meta-Admin -- /visitors.cgi
4# Portfolio-wide visitor rollup. Aggregates from all 7 sister sites'
5# tracking_sessions tables via MODS::MetaAdmin::Visitors (read-only).
6# KPIs + globe + per-site breakdown.
7#======================================================================
8use strict;
9use warnings;
10use lib '/var/www/vhosts/3dshawn.com/admin.3dshawn.com';
11use CGI;
12use MODS::Login;
13use MODS::MetaAdmin::Config;
14use MODS::MetaAdmin::Wrapper;
15use MODS::MetaAdmin::Visitors;
16
17my $q = CGI->new;
18my $form = $q->Vars;
19my $auth = MODS::Login->new;
20my $u = $auth->login_verify;
21unless ($u) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; }
22
23my $days = $form->{days};
24$days =~ s/[^0-9]//g if defined $days;
25$days = 30 unless $days && $days =~ /^\d+$/;
26$days = 365 if $days > 365;
27$days = 1 if $days < 1;
28
29my $site_slug = $form->{site} || '';
30$site_slug =~ s/[^a-z0-9_\-]//g;
31
32my $vis = MODS::MetaAdmin::Visitors->new;
33my $roll = $vis->kpi_rollup($days);
34my @globe = $vis->geo_rollup($days);
35my $per_site_by_country = $vis->country_per_site_rollup($days);
36my $per_site_by_state = $vis->state_per_site_rollup($days);
37my $state_data = $vis->state_rollup($days);
38my $country_dims = $vis->country_dimensions_rollup($days);
39my $state_dims = $vis->state_dimensions_rollup($days);
40my $drill = length($site_slug) ? $vis->full_drill_tree($site_slug, $days) : undef;
41
42sub _esc { my $s=shift;$s//='';$s=~s/&/&amp;/g;$s=~s/</&lt;/g;$s=~s/>/&gt;/g;$s=~s/"/&quot;/g;return $s; }
43sub _jstr { my $s=shift;$s//='';$s=~s/\\/\\\\/g;$s=~s/"/\\"/g;$s=~s/[\x00-\x1f]//g;return $s; }
44sub _n { my $n=shift; $n||=0; return sprintf('%s', $n) if $n < 1000;
45 return sprintf('%.1fk', $n/1000) if $n < 100_000;
46 return sprintf('%dk', int($n/1000)) if $n < 1_000_000;
47 return sprintf('%.1fM', $n/1_000_000); }
48
49# Brand letters for site icons (matches the convention on revenue.cgi)
50my %LETTERS = (
51 webstls=>'WS', ptmatrix=>'PT', affsoft=>'AS', shopcart=>'SC',
52 repricer=>'RP', abforge=>'AB', contactforge=>'CF',
53);
54
55my $globe_json = '[' . join(',', map {
56 sprintf('{"code":"%s","name":"%s","lat":%s,"lng":%s,"count":%d}',
57 $_->{code}, _jstr($_->{name}), $_->{lat}+0, $_->{lng}+0, $_->{count}+0)
58} @globe) . ']';
59
60# Per-site-by-country JSON for the popover. Structure:
61# { "US": [{ "slug":"webstls","name":"WebSTLs","color":"#1e3a8a","visitors":120 }, ...] }
62my $per_site_json = '{';
63{
64 my @parts;
65 foreach my $code (sort keys %$per_site_by_country) {
66 my @arr;
67 foreach my $s (@{ $per_site_by_country->{$code} }) {
68 push @arr, sprintf('{"slug":"%s","name":"%s","color":"%s","visitors":%d,"sessions":%d}',
69 _jstr($s->{slug}), _jstr($s->{name}), _jstr($s->{color}),
70 $s->{visitors}+0, $s->{sessions}+0);
71 }
72 push @parts, sprintf('"%s":[%s]', _jstr($code), join(',', @arr));
73 }
74 $per_site_json .= join(',', @parts);
75}
76$per_site_json .= '}';
77
78# State-level rollup JSON for globe.js zoom-in state dots.
79# Structure: { "US": { "New York": 25, "Colorado": 8 }, ... }
80my $state_json = '{';
81{
82 my @cparts;
83 foreach my $code (sort keys %$state_data) {
84 my @rparts;
85 foreach my $region (sort keys %{ $state_data->{$code} }) {
86 push @rparts, sprintf('"%s":%d', _jstr($region), $state_data->{$code}{$region}+0);
87 }
88 push @cparts, sprintf('"%s":{%s}', _jstr($code), join(',', @rparts));
89 }
90 $state_json .= join(',', @cparts);
91}
92$state_json .= '}';
93
94# Per-country -> per-state -> per-site JSON for the state-level modal.
95# Structure: { "US": { "New York": [{slug,name,color,visitors,sessions}, ...] } }
96my $per_site_state_json = '{';
97{
98 my @cparts;
99 foreach my $code (sort keys %$per_site_by_state) {
100 my @rparts;
101 foreach my $region (sort keys %{ $per_site_by_state->{$code} }) {
102 my @sarr;
103 foreach my $s (@{ $per_site_by_state->{$code}{$region} }) {
104 push @sarr, sprintf('{"slug":"%s","name":"%s","color":"%s","visitors":%d,"sessions":%d}',
105 _jstr($s->{slug}), _jstr($s->{name}), _jstr($s->{color}),
106 $s->{visitors}+0, $s->{sessions}+0);
107 }
108 my $rj = $region; $rj =~ s/\\/\\\\/g; $rj =~ s/"/\\"/g; $rj =~ s/[\x00-\x1f]//g;
109 push @rparts, sprintf('"%s":[%s]', $rj, join(',', @sarr));
110 }
111 push @cparts, sprintf('"%s":{%s}', _jstr($code), join(',', @rparts));
112 }
113 $per_site_state_json .= join(',', @cparts);
114}
115$per_site_state_json .= '}';
116
117# Per-country -> dimension -> { value => count } JSON for the modal's
118# browser/OS/device/resolution/viewport/language/timezone bars.
119my $country_dims_json = '{';
120{
121 my @cparts;
122 foreach my $code (sort keys %$country_dims) {
123 my @dparts;
124 foreach my $dim (qw(browsers os devices resolutions viewports languages timezones)) {
125 my $rh = $country_dims->{$code}{$dim} || {};
126 my @vparts;
127 foreach my $v (sort { $rh->{$b} <=> $rh->{$a} } keys %$rh) {
128 my $vj = $v; $vj =~ s/\\/\\\\/g; $vj =~ s/"/\\"/g; $vj =~ s/[\x00-\x1f]//g;
129 push @vparts, sprintf('"%s":%d', $vj, $rh->{$v}+0);
130 }
131 push @dparts, sprintf('"%s":{%s}', $dim, join(',', @vparts));
132 }
133 push @cparts, sprintf('"%s":{%s}', _jstr($code), join(',', @dparts));
134 }
135 $country_dims_json .= join(',', @cparts);
136}
137$country_dims_json .= '}';
138
139# Per-state dimension breakdowns JSON.
140# { US: { 'New York': { browsers: {...}, ... } } }
141my $state_dims_json = '{';
142{
143 my @cparts;
144 foreach my $code (sort keys %$state_dims) {
145 my @rparts;
146 foreach my $region (sort keys %{ $state_dims->{$code} }) {
147 my @dparts;
148 foreach my $dim (qw(browsers os devices resolutions viewports languages timezones)) {
149 my $rh = $state_dims->{$code}{$region}{$dim} || {};
150 my @vparts;
151 foreach my $v (sort { $rh->{$b} <=> $rh->{$a} } keys %$rh) {
152 my $vj = $v; $vj =~ s/\\/\\\\/g; $vj =~ s/"/\\"/g; $vj =~ s/[\x00-\x1f]//g;
153 push @vparts, sprintf('"%s":%d', $vj, $rh->{$v}+0);
154 }
155 push @dparts, sprintf('"%s":{%s}', $dim, join(',', @vparts));
156 }
157 my $rj = $region; $rj =~ s/\\/\\\\/g; $rj =~ s/"/\\"/g; $rj =~ s/[\x00-\x1f]//g;
158 push @rparts, sprintf('"%s":{%s}', $rj, join(',', @dparts));
159 }
160 push @cparts, sprintf('"%s":{%s}', _jstr($code), join(',', @rparts));
161 }
162 $state_dims_json .= join(',', @cparts);
163}
164$state_dims_json .= '}';
165
166my $body = '';
167$body .= qq~
168<div class="page-head"><div class="left">
169 <span class="page-eyebrow"><span class="dot"></span> Roll-up</span>
170 <h1 class="page-title">Traffic Reports</h1>
171 <p class="page-subtitle">Where every external visitor across the portfolio is coming from. Globe + per-site breakdown over the last $days days. Read-only pull from each sister site's tracking_sessions table.</p>
172</div></div>
173~;
174
175# Range chip row -- canonical .tr-chip picker (portfolio-wide)
176$body .= qq~
177<div style="display:flex;gap:8px;margin:0 0 16px;align-items:center;padding:10px 14px;background:var(--col-surface);border:1px solid var(--col-border);border-radius:12px">
178 <span style="font-size:11px;letter-spacing:1.4px;text-transform:uppercase;color:var(--col-text-muted);font-weight:700">Range</span>~;
179foreach my $d (1, 7, 30, 90, 365) {
180 my $label = $d == 1 ? '24h' : $d == 365 ? '1y' : "${d}d";
181 my $active = ($d == $days) ? ' is-active' : '';
182 $body .= qq~<a class="tr-chip$active" href="/visitors.cgi?days=$d">$label</a>~;
183}
184my $rl = $days == 1 ? 'Last 24 hours'
185 : $days == 365 ? 'Last year'
186 : "Last $days days";
187$body .= qq~<span style="margin-left:auto;font-size:11.5px;color:var(--col-text-muted)">Showing: <strong style="color:var(--col-text)">$rl</strong></span></div>~;
188
189# KPI tiles
190my $rev_h = _n($roll->{total_pageviews});
191$body .= qq~
192<div class="dash-grid">
193 <div class="module glow-magenta">
194 <div class="module-head"><div class="left"><div class="module-icon"><svg viewBox="0 0 24 24" width="14" height="14" 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></div><div class="module-title">Page views</div></div></div>
195 <div class="module-body kpi kpi-magenta"><div class="num">$rev_h</div><div class="lbl">across @{[scalar @{$roll->{per_site}}]} sites</div><div class="sub">last $days days</div></div>
196 </div>
197 <div class="module glow-cyan">
198 <div class="module-head"><div class="left"><div class="module-icon"><svg viewBox="0 0 24 24" width="14" height="14" 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></div><div class="module-title">Visitors</div></div></div>
199 <div class="module-body kpi kpi-cyan"><div class="num">@{[ _n($roll->{total_visitors}) ]}</div><div class="lbl">unique per site, summed</div><div class="sub">cross-site dedup not possible</div></div>
200 </div>
201 <div class="module glow-lime">
202 <div class="module-head"><div class="left"><div class="module-icon"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg></div><div class="module-title">Sessions</div></div></div>
203 <div class="module-body kpi kpi-lime"><div class="num">@{[ _n($roll->{total_sessions}) ]}</div><div class="lbl">total session rows</div><div class="sub">across the portfolio</div></div>
204 </div>
205 <div class="module glow-violet">
206 <div class="module-head"><div class="left"><div class="module-icon"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg></div><div class="module-title">Countries</div></div></div>
207 <div class="module-body kpi kpi-violet"><div class="num">@{[ scalar @globe ]}</div><div class="lbl">on the globe</div><div class="sub">last $days days</div></div>
208 </div>
209</div>
210~;
211
212# Globe section
213$body .= qq~
214<h2 style="font-size:16px;letter-spacing:1px;text-transform:uppercase;color:var(--col-text-muted);margin:24px 0 12px">Traffic by region (last $days days) <span style="font-weight:400;font-size:11px;color:var(--col-text-dim);text-transform:none;letter-spacing:0;margin-left:8px">- click any country for per-site breakdown</span></h2>
215<script>
216window.GLOBE_DATA = { countries: $globe_json };
217window.GLOBE_STATE_DATA = $state_json;
218/* Per-country -> per-site list, consumed by the meta-admin popover below. */
219window.GLOBE_PER_SITE_BY_COUNTRY = $per_site_json;
220window.GLOBE_PER_SITE_BY_STATE = $per_site_state_json;
221window.GLOBE_COUNTRY_DIMENSIONS = $country_dims_json;
222window.GLOBE_STATE_DIMENSIONS = $state_dims_json;
223</script>
224<div data-globe data-width="1200" data-height="600"
225 data-globe-popover="meta-per-site"
226 style="background:#0c1018;border:1px solid var(--col-border);border-radius:14px;overflow:hidden;position:relative;aspect-ratio:1200/600;margin-bottom:18px">
227 <noscript><div style="padding:24px;color:var(--col-text);font-size:13px">JavaScript is required to render the interactive globe.</div></noscript>
228</div>
229<!-- Rich modal shown when a country is clicked on the globe.
230 Contains aggregated KPIs, per-site contributions, and the
231 state/region breakdown for the clicked country. -->
232<div id="metaModalBackdrop" 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>
233<div id="metaModal" style="position:fixed;z-index:9999;display:none;top:5vh;left:50%;transform:translateX(-50%);width:min(920px,92vw);max-height:90vh;overflow-y:auto;background:var(--col-surface,#0f1a26);border:1px solid var(--col-border,rgba(160,26,44,0.45));border-radius:16px;box-shadow:0 30px 80px rgba(0,0,0,0.7);color:var(--col-text,#e2e8f0)"></div>
234~;
235
236# Inline-accordion drill panel. Renders ALL country -> state -> city
237# data at once (one SQL per site); each row is a click-to-expand
238# trigger. Pure show/hide via JS - no AJAX, no page reload.
239my $drill_html = '';
240if ($drill) {
241 my $name = _esc($drill->{display_name});
242 my $col = _esc($drill->{color});
243 my $ltr = _esc($LETTERS{$drill->{slug}} || uc(substr($drill->{slug},0,2)));
244 my $tv = _n($drill->{total_visitors});
245 my $ts = _n($drill->{total_sessions});
246 my $close_href = "/visitors.cgi?days=$days";
247
248 $drill_html .= qq~
249<div id="site-drill" class="module" style="margin-top:18px;border:1px solid $col;box-shadow:0 0 0 1px ${col}33, 0 12px 28px ${col}22">
250 <div class="module-head"><div class="left">
251 <span class="brand-icon" style="--site-glow:$col;margin-right:8px" data-letter="$ltr"></span>
252 <div class="module-title">$name &middot; site drill</div>
253 </div>
254 <a href="$close_href" class="btn btn-secondary btn-sm" style="margin-left:auto">&times; Close drill</a>
255 </div>
256 <div class="module-body" style="padding:18px">
257 <div style="display:flex;gap:24px;margin-bottom:14px">
258 <div><div class="text-xs text-dim" style="text-transform:uppercase;letter-spacing:1.5px;font-weight:700">Visitors (last $days d)</div><div style="font-size:22px;font-weight:700;color:#fff">$tv</div></div>
259 <div><div class="text-xs text-dim" style="text-transform:uppercase;letter-spacing:1.5px;font-weight:700">Sessions (last $days d)</div><div style="font-size:22px;font-weight:700;color:#fff">$ts</div></div>
260 </div>
261~;
262
263 if (scalar @{ $drill->{countries} } > 0) {
264 $drill_html .= q~
265<style>
266.accord-scope { position: relative; }
267.accord-collapse-all { float: right; background: var(--col-bg-2, #0e1822); color: var(--col-text-muted); border: 1px solid var(--col-border); padding: 5px 11px; border-radius: 7px; font-size: 11px; font-weight: 600; cursor: pointer; transition: all .12s ease; font-family: inherit; }
268.accord-collapse-all:hover { color: var(--col-text); border-color: var(--col-text-3, #64748b); }
269.accord-tbl { width:100%; border-collapse: collapse; margin-bottom: 8px; }
270.accord-tbl th { background: rgba(255,255,255,.03); padding: 10px 14px; text-align: left; font-size: 10.5px; text-transform: uppercase; letter-spacing: 1.4px; color: var(--col-text-muted); font-weight: 700; border-bottom: 1px solid var(--col-border); }
271.accord-tbl td { padding: 11px 14px; border-bottom: 1px solid var(--col-border); font-size: 13px; vertical-align: middle; }
272.accord-tbl tr.expandable { cursor: pointer; transition: background .12s ease; }
273.accord-tbl tr.expandable:hover { background: rgba(255,255,255,.03); }
274.accord-tbl tr.expandable.is-open { background: rgba(255,255,255,.04); }
275.accord-tbl .chev { display:inline-block; width:14px; color: var(--col-text-muted); transition: transform .15s ease; margin-right:6px; }
276.accord-tbl tr.expandable.is-open .chev { transform: rotate(90deg); color: var(--col-text); }
277.accord-tbl tr.kids > td { padding: 0 !important; background: rgba(0,0,0,.18); border-bottom: 1px solid var(--col-border); }
278.accord-tbl tr.kids[hidden] { display: none; }
279.accord-tbl .nested { width:100%; border-collapse: collapse; }
280.accord-tbl .nested th { background: transparent; font-size: 10px; padding: 8px 14px 6px 36px; }
281.accord-tbl .nested td { padding: 9px 14px 9px 36px; font-size: 12.5px; border-bottom: 1px solid rgba(255,255,255,.05); }
282.accord-tbl .nested .nested th { padding-left: 58px; }
283.accord-tbl .nested .nested td { padding-left: 58px; font-size: 12px; }
284.accord-tbl .num { text-align:right; font-variant-numeric: tabular-nums; }
285.accord-tbl .leaf td { padding-left: 80px; color: var(--col-text-2); }
286</style>
287<div class="accord-scope">
288<h3 style="font-size:12px;letter-spacing:1.5px;text-transform:uppercase;color:var(--col-text-muted);margin:14px 0 8px;display:flex;align-items:center;gap:8px">By country / state / city <span style="font-weight:400;font-size:11px;color:var(--col-text-dim);text-transform:none;letter-spacing:0">- click any row with the chevron to expand</span><button type="button" class="accord-collapse-all" style="margin-left:auto;float:none">Collapse all</button></h3>
289<table class="accord-tbl">
290 <thead><tr><th>Country / state / city</th><th class="num">Visitors</th><th class="num">Sessions</th></tr></thead>
291 <tbody>
292~;
293 my $ci = 0;
294 foreach my $c (@{ $drill->{countries} }) {
295 $ci++;
296 my $cid = "cnt-$ci";
297 my $cname = _esc($c->{name});
298 my $has_states = scalar @{ $c->{states} } > 0 ? 1 : 0;
299 my $chev = $has_states ? '<span class="chev">&rsaquo;</span>' : '<span class="chev" style="opacity:0">&rsaquo;</span>';
300 $drill_html .= qq~<tr class="expandable" data-target="$cid"><td><strong>$chev$cname</strong></td><td class="num">@{[ _n($c->{visitors}) ]}</td><td class="num">@{[ _n($c->{sessions}) ]}</td></tr>~;
301 if ($has_states) {
302 $drill_html .= qq~<tr class="kids" id="$cid" hidden><td colspan="3"><table class="nested"><tbody>~;
303 my $si = 0;
304 foreach my $st (@{ $c->{states} }) {
305 $si++;
306 my $sid = "${cid}-st-$si";
307 my $rname = _esc($st->{region});
308 my $has_cities = scalar @{ $st->{cities} } > 0 ? 1 : 0;
309 my $schev = $has_cities ? '<span class="chev">&rsaquo;</span>' : '<span class="chev" style="opacity:0">&rsaquo;</span>';
310 $drill_html .= qq~<tr class="expandable" data-target="$sid"><td>$schev$rname</td><td class="num">@{[ _n($st->{visitors}) ]}</td><td class="num">@{[ _n($st->{sessions}) ]}</td></tr>~;
311 if ($has_cities) {
312 $drill_html .= qq~<tr class="kids" id="$sid" hidden><td colspan="3"><table class="nested"><tbody>~;
313 foreach my $ct (@{ $st->{cities} }) {
314 my $cn = _esc($ct->{city});
315 $drill_html .= qq~<tr class="leaf"><td>$cn</td><td class="num">@{[ _n($ct->{visitors}) ]}</td><td class="num">@{[ _n($ct->{sessions}) ]}</td></tr>~;
316 }
317 $drill_html .= '</tbody></table></td></tr>';
318 }
319 }
320 $drill_html .= '</tbody></table></td></tr>';
321 }
322 }
323 $drill_html .= '</tbody></table></div>';
324 $drill_html .= q~
325<script>
326(function(){
327 document.addEventListener('click', function(e){
328 // Collapse-all button
329 var btn = e.target.closest('.accord-collapse-all');
330 if (btn) {
331 var scope = btn.closest('.accord-scope') || document;
332 scope.querySelectorAll('tr.kids').forEach(function(k){ k.hidden = true; });
333 scope.querySelectorAll('tr.expandable.is-open').forEach(function(t){ t.classList.remove('is-open'); });
334 return;
335 }
336 // Regular row toggle
337 var tr = e.target.closest('tr.expandable');
338 if (!tr) return;
339 var id = tr.dataset.target;
340 if (!id) return;
341 var kids = document.getElementById(id);
342 if (!kids) return;
343 var open = !kids.hidden;
344 kids.hidden = open;
345 tr.classList.toggle('is-open', !open);
346 });
347})();
348</script>
349~;
350 } else {
351 $drill_html .= '<div class="banner info">No country data with ISO codes for this site in the selected window.</div>';
352 }
353 $drill_html .= '</div></div>';
354}
355
356# Per-site breakdown table (rows clickable -> ?site=<slug>)
357$body .= q~
358<div class="module" style="margin-top:18px">
359 <div class="module-head"><div class="left"><div class="module-title">Per-site breakdown <span style="font-weight:400;font-size:11px;color:var(--col-text-dim);margin-left:8px">- click a row to drill</span></div></div></div>
360 <div class="module-body" style="padding:0">
361 <table class="tbl">
362 <thead><tr><th></th><th>Site</th><th style="text-align:right">Page views</th><th style="text-align:right">Visitors</th><th style="text-align:right">Sessions</th><th>Notes</th></tr></thead>
363 <tbody>
364~;
365foreach my $s (@{ $roll->{per_site} }) {
366 my $name = _esc($s->{display_name});
367 my $col = _esc($s->{color});
368 my $ltr = _esc($LETTERS{$s->{slug}} || uc(substr($s->{slug},0,2)));
369 my $pv = defined $s->{pageviews} ? _n($s->{pageviews}) : '<span class="dim">--</span>';
370 my $vs = defined $s->{visitors} ? _n($s->{visitors}) : '<span class="dim">--</span>';
371 my $sn = defined $s->{sessions} ? _n($s->{sessions}) : '<span class="dim">--</span>';
372 my $err = _esc($s->{err} || '');
373 my $href = "/visitors.cgi?site=$s->{slug}&days=$days";
374 my $hl = ($drill && $drill->{slug} eq $s->{slug}) ? "background:${col}22;" : '';
375 $body .= qq~<tr style="cursor:pointer;$hl" onclick="location.href='$href'">
376 <td><span class="brand-icon" style="--site-glow:$col" data-letter="$ltr"></span></td>
377 <td><strong>$name</strong></td>
378 <td class="mono" style="text-align:right">$pv</td>
379 <td class="mono" style="text-align:right">$vs</td>
380 <td class="mono" style="text-align:right">$sn</td>
381 <td class="dim" style="font-size:11px">$err</td>
382 </tr>~;
383}
384$body .= q~
385 </tbody>
386 </table>
387 </div>
388</div>
389~;
390
391# Drill panel renders AFTER the per-site table now (no top-of-page jump).
392$body .= $drill_html if $drill_html;
393
394# Auto-scroll to the drill when the page loads with ?site=<slug>.
395if ($drill) {
396 $body .= q~
397<script>
398(function(){
399 var el = document.getElementById('site-drill');
400 if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
401})();
402</script>
403~;
404}
405
406$body .= q~
407<!-- Globe click -> rich modal with KPIs + per-site bars + state breakdown -->
408<script>
409(function(){
410 var modal = document.getElementById('metaModal');
411 var backdrop = document.getElementById('metaModalBackdrop');
412 if (!modal || !backdrop) return;
413 var perSiteByCountry = window.GLOBE_PER_SITE_BY_COUNTRY || {};
414 var perSiteByState = window.GLOBE_PER_SITE_BY_STATE || {};
415 var stateData = window.GLOBE_STATE_DATA || {};
416 var countryDims = window.GLOBE_COUNTRY_DIMENSIONS || {};
417 var stateDims = window.GLOBE_STATE_DIMENSIONS || {};
418 var nameMap = {};
419 (window.GLOBE_DATA && window.GLOBE_DATA.countries || []).forEach(function(c){
420 if (c.code) nameMap[c.code] = c.name || c.code;
421 });
422 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'; }
423 function esc(s){ return String(s||'').replace(/[&<>"]/g, function(m){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m];}); }
424 function renderHeader(eyebrow, title){
425 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">'
426 + '<div>'
427 + '<div style="font-size:10.5px;letter-spacing:1.6px;color:var(--col-text-muted);font-weight:700;text-transform:uppercase">'+eyebrow+'</div>'
428 + '<div style="font-size:24px;font-weight:700;color:#fff;margin-top:4px">'+title+'</div>'
429 + '</div>'
430 + '<button type="button" id="metaModalClose" style="background:var(--col-bg-2,#0e1822);border:1px solid var(--col-border,rgba(160,26,44,0.45));color:var(--col-text);width:36px;height:36px;border-radius:8px;cursor:pointer;font-size:18px;line-height:1">&times;</button>'
431 + '</div>';
432 }
433 function renderKpis(arr){
434 var html = '<div style="padding:18px 26px 8px;display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px">';
435 arr.forEach(function(k){
436 html += '<div style="background:var(--col-bg-2,#0e1822);border:1px solid var(--col-border);border-radius:10px;padding:12px 14px">'
437 + '<div style="font-size:9.5px;letter-spacing:1.5px;color:var(--col-text-muted);font-weight:700;text-transform:uppercase">'+k.lbl+'</div>'
438 + '<div style="font-size:20px;font-weight:700;color:#fff;margin-top:4px;font-variant-numeric:tabular-nums">'+k.val+'</div>'
439 + '</div>';
440 });
441 return html + '</div>';
442 }
443 function renderPerSiteBars(rows, heading){
444 if (!rows || !rows.length) return '<div style="padding:14px 26px;color:var(--col-text-muted);font-size:13px">No per-site data in the selected window.</div>';
445 var max = rows[0].visitors || 1;
446 var html = '<div style="padding:14px 26px 4px"><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>';
447 rows.forEach(function(r){
448 var pct = max ? (r.visitors / max * 100) : 0;
449 html += '<div style="margin-bottom:10px">'
450 + '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">'
451 + '<span style="color:#fff;font-weight:600;font-size:13px">'+esc(r.name)+'</span>'
452 + '<span style="color:var(--col-text-muted);font-variant-numeric:tabular-nums;font-size:12.5px">'+fmt(r.visitors)+' visitors &middot; '+fmt(r.sessions)+' sessions</span>'
453 + '</div>'
454 + '<div style="height:6px;border-radius:3px;background:rgba(255,255,255,0.06);overflow:hidden">'
455 + '<div style="height:100%;width:'+pct.toFixed(1)+'%;background:'+esc(r.color)+'"></div>'
456 + '</div></div>';
457 });
458 return html + '</div>';
459 }
460 // Render a generic "{name: count}" map as a labelled list of bars.
461 // Used for the dimension breakdowns (browser, OS, device, etc).
462 function renderDimension(label, rowsObj){
463 var entries = Object.keys(rowsObj || {}).map(function(k){return {name:k,count:rowsObj[k]};})
464 .sort(function(a,b){return b.count - a.count;});
465 if (!entries.length) return '';
466 var max = entries[0].count || 1;
467 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>';
468 entries.slice(0, 8).forEach(function(e){
469 var pct = (e.count / max * 100);
470 html += '<div style="margin-bottom:6px">'
471 + '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:2px">'
472 + '<span style="color:#fff;font-size:12px">'+esc(e.name)+'</span>'
473 + '<span style="color:var(--col-text-muted);font-variant-numeric:tabular-nums;font-size:11.5px">'+fmt(e.count)+'</span>'
474 + '</div>'
475 + '<div style="height:3px;border-radius:2px;background:rgba(255,255,255,0.05);overflow:hidden">'
476 + '<div style="height:100%;width:'+pct.toFixed(1)+'%;background:linear-gradient(90deg,#a01a2c,#d97a86)"></div>'
477 + '</div></div>';
478 });
479 return html + '</div>';
480 }
481 function showCountry(code){
482 var siteRows = perSiteByCountry[code] || [];
483 var stateRows = stateData[code] || {};
484 var dims = countryDims[code] || {};
485 var totalVisitors = siteRows.reduce(function(a,r){return a + (r.visitors||0);}, 0);
486 var totalSessions = siteRows.reduce(function(a,r){return a + (r.sessions||0);}, 0);
487 var html = renderHeader('Country detail', esc(nameMap[code] || code));
488 html += renderKpis([
489 { lbl:'Total visitors', val: fmt(totalVisitors) },
490 { lbl:'Total sessions', val: fmt(totalSessions) },
491 { lbl:'Contributing sites', val: siteRows.length },
492 { lbl:'States with traffic',val: Object.keys(stateRows).length }
493 ]);
494 html += renderPerSiteBars(siteRows, 'Per-site contributions');
495 var states = Object.keys(stateRows).map(function(s){return {name:s,count:stateRows[s]};}).sort(function(a,b){return b.count - a.count;});
496 if (states.length) {
497 var smax = states[0].count || 1;
498 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">Top states / regions</div>';
499 states.slice(0, 15).forEach(function(s){
500 var pct = (s.count / smax * 100);
501 html += '<div style="margin-bottom:8px">'
502 + '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:3px">'
503 + '<span style="color:#fff;font-size:12.5px">'+esc(s.name)+'</span>'
504 + '<span style="color:var(--col-text-muted);font-variant-numeric:tabular-nums;font-size:12px">'+fmt(s.count)+'</span>'
505 + '</div>'
506 + '<div style="height:4px;border-radius:2px;background:rgba(255,255,255,0.05);overflow:hidden">'
507 + '<div style="height:100%;width:'+pct.toFixed(1)+'%;background:linear-gradient(90deg,#a01a2c,#d97a86)"></div>'
508 + '</div></div>';
509 });
510 html += '</div>';
511 }
512 // Dimension breakdowns in a responsive grid: browsers / OS / devices,
513 // then resolutions / viewports / languages / timezones.
514 var anyDim = Object.keys(dims).length > 0;
515 if (anyDim) {
516 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>'
517 + '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px">'
518 + renderDimension('Browser', dims.browsers)
519 + renderDimension('Operating system', dims.os)
520 + renderDimension('Device type', dims.devices)
521 + renderDimension('Screen resolution', dims.resolutions)
522 + renderDimension('Viewport', dims.viewports)
523 + renderDimension('Language', dims.languages)
524 + renderDimension('Timezone', dims.timezones)
525 + '</div></div>';
526 }
527 modal.innerHTML = html;
528 }
529 function showState(code, region){
530 var siteRows = (perSiteByState[code] && perSiteByState[code][region]) || [];
531 var dims = (stateDims[code] && stateDims[code][region]) || {};
532 var totalVisitors = siteRows.reduce(function(a,r){return a + (r.visitors||0);}, 0);
533 var totalSessions = siteRows.reduce(function(a,r){return a + (r.sessions||0);}, 0);
534 var countryName = nameMap[code] || code;
535 var html = renderHeader('State / region detail', esc(region) + ' <span style="font-size:14px;color:var(--col-text-muted);font-weight:500"> &middot; ' + esc(countryName) + '</span>');
536 html += renderKpis([
537 { lbl:'Total visitors', val: fmt(totalVisitors) },
538 { lbl:'Total sessions', val: fmt(totalSessions) },
539 { lbl:'Contributing sites', val: siteRows.length },
540 { lbl:'Country', val: code }
541 ]);
542 html += renderPerSiteBars(siteRows, 'Per-site contributions in this state');
543 if (Object.keys(dims).length) {
544 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>'
545 + '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px">'
546 + renderDimension('Browser', dims.browsers)
547 + renderDimension('Operating system', dims.os)
548 + renderDimension('Device type', dims.devices)
549 + renderDimension('Screen resolution', dims.resolutions)
550 + renderDimension('Viewport', dims.viewports)
551 + renderDimension('Language', dims.languages)
552 + renderDimension('Timezone', dims.timezones)
553 + '</div></div>';
554 }
555 modal.innerHTML = html;
556 }
557 function show(code, region){
558 if (region) showState(code, region); else showCountry(code);
559 modal.style.display = 'block';
560 backdrop.style.display = 'block';
561 document.getElementById('metaModalClose').addEventListener('click', closeModal);
562 }
563 function closeModal(){
564 modal.style.display = 'none';
565 backdrop.style.display = 'none';
566 }
567 // Intercept globe country click in capture phase so globe.js's own
568 // drill modal never opens. Show our richer modal instead.
569 document.addEventListener('click', function(e){
570 var g = e.target && e.target.closest ? e.target.closest('.globe-country') : null;
571 if (!g) return;
572 var code = g.getAttribute('data-code');
573 if (!code) return;
574 // State dots carry data-region with the state name. Country dots
575 // don't, so we treat the click as state-level only when the region
576 // attribute is present.
577 var region = g.getAttribute('data-region') || '';
578 e.stopPropagation();
579 e.preventDefault();
580 show(code, region || null);
581 }, true);
582 backdrop.addEventListener('click', closeModal);
583 document.addEventListener('keydown', function(e){
584 if (e.key === 'Escape' && modal.style.display !== 'none') closeModal();
585 });
586})();
587</script>
588~;
589
590MODS::MetaAdmin::Wrapper->new->render(
591 title => 'Traffic Reports',
592 page_key => 'visitors',
593 body => $body,
594 userinfo => $u,
595 extra_js => '<script src="/assets/javascript/globe.js?v=1"></script>',
596);