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
+0
lines
Removed
-0
lines
Context
596
unchanged
Blobs
from d1ae7348f46b
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.
11#!/usr/bin/perl
22#======================================================================
33# Meta-Admin -- /visitors.cgi
44# Portfolio-wide visitor rollup. Aggregates from all 7 sister sites'
55# tracking_sessions tables via MODS::MetaAdmin::Visitors (read-only).
66# KPIs + globe + per-site breakdown.
77#======================================================================
88use strict;
99use warnings;
1010use lib '/var/www/vhosts/3dshawn.com/admin.3dshawn.com';
1111use CGI;
1212use MODS::Login;
1313use MODS::MetaAdmin::Config;
1414use MODS::MetaAdmin::Wrapper;
1515use MODS::MetaAdmin::Visitors;
1616
1717my $q = CGI->new;
1818my $form = $q->Vars;
1919my $auth = MODS::Login->new;
2020my $u = $auth->login_verify;
2121unless ($u) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; }
2222
2323my $days = $form->{days};
2424$days =~ s/[^0-9]//g if defined $days;
2525$days = 30 unless $days && $days =~ /^\d+$/;
2626$days = 365 if $days > 365;
2727$days = 1 if $days < 1;
2828
2929my $site_slug = $form->{site} || '';
3030$site_slug =~ s/[^a-z0-9_\-]//g;
3131
3232my $vis = MODS::MetaAdmin::Visitors->new;
3333my $roll = $vis->kpi_rollup($days);
3434my @globe = $vis->geo_rollup($days);
3535my $per_site_by_country = $vis->country_per_site_rollup($days);
3636my $per_site_by_state = $vis->state_per_site_rollup($days);
3737my $state_data = $vis->state_rollup($days);
3838my $country_dims = $vis->country_dimensions_rollup($days);
3939my $state_dims = $vis->state_dimensions_rollup($days);
4040my $drill = length($site_slug) ? $vis->full_drill_tree($site_slug, $days) : undef;
4141
4242sub _esc { my $s=shift;$s//='';$s=~s/&/&amp;/g;$s=~s/</&lt;/g;$s=~s/>/&gt;/g;$s=~s/"/&quot;/g;return $s; }
4343sub _jstr { my $s=shift;$s//='';$s=~s/\\/\\\\/g;$s=~s/"/\\"/g;$s=~s/[\x00-\x1f]//g;return $s; }
4444sub _n { my $n=shift; $n||=0; return sprintf('%s', $n) if $n < 1000;
4545 return sprintf('%.1fk', $n/1000) if $n < 100_000;
4646 return sprintf('%dk', int($n/1000)) if $n < 1_000_000;
4747 return sprintf('%.1fM', $n/1_000_000); }
4848
4949# Brand letters for site icons (matches the convention on revenue.cgi)
5050my %LETTERS = (
5151 webstls=>'WS', ptmatrix=>'PT', affsoft=>'AS', shopcart=>'SC',
5252 repricer=>'RP', abforge=>'AB', contactforge=>'CF',
5353);
5454
5555my $globe_json = '[' . join(',', map {
5656 sprintf('{"code":"%s","name":"%s","lat":%s,"lng":%s,"count":%d}',
5757 $_->{code}, _jstr($_->{name}), $_->{lat}+0, $_->{lng}+0, $_->{count}+0)
5858} @globe) . ']';
5959
6060# Per-site-by-country JSON for the popover. Structure:
6161# { "US": [{ "slug":"webstls","name":"WebSTLs","color":"#1e3a8a","visitors":120 }, ...] }
6262my $per_site_json = '{';
6363{
6464 my @parts;
6565 foreach my $code (sort keys %$per_site_by_country) {
6666 my @arr;
6767 foreach my $s (@{ $per_site_by_country->{$code} }) {
6868 push @arr, sprintf('{"slug":"%s","name":"%s","color":"%s","visitors":%d,"sessions":%d}',
6969 _jstr($s->{slug}), _jstr($s->{name}), _jstr($s->{color}),
7070 $s->{visitors}+0, $s->{sessions}+0);
7171 }
7272 push @parts, sprintf('"%s":[%s]', _jstr($code), join(',', @arr));
7373 }
7474 $per_site_json .= join(',', @parts);
7575}
7676$per_site_json .= '}';
7777
7878# State-level rollup JSON for globe.js zoom-in state dots.
7979# Structure: { "US": { "New York": 25, "Colorado": 8 }, ... }
8080my $state_json = '{';
8181{
8282 my @cparts;
8383 foreach my $code (sort keys %$state_data) {
8484 my @rparts;
8585 foreach my $region (sort keys %{ $state_data->{$code} }) {
8686 push @rparts, sprintf('"%s":%d', _jstr($region), $state_data->{$code}{$region}+0);
8787 }
8888 push @cparts, sprintf('"%s":{%s}', _jstr($code), join(',', @rparts));
8989 }
9090 $state_json .= join(',', @cparts);
9191}
9292$state_json .= '}';
9393
9494# Per-country -> per-state -> per-site JSON for the state-level modal.
9595# Structure: { "US": { "New York": [{slug,name,color,visitors,sessions}, ...] } }
9696my $per_site_state_json = '{';
9797{
9898 my @cparts;
9999 foreach my $code (sort keys %$per_site_by_state) {
100100 my @rparts;
101101 foreach my $region (sort keys %{ $per_site_by_state->{$code} }) {
102102 my @sarr;
103103 foreach my $s (@{ $per_site_by_state->{$code}{$region} }) {
104104 push @sarr, sprintf('{"slug":"%s","name":"%s","color":"%s","visitors":%d,"sessions":%d}',
105105 _jstr($s->{slug}), _jstr($s->{name}), _jstr($s->{color}),
106106 $s->{visitors}+0, $s->{sessions}+0);
107107 }
108108 my $rj = $region; $rj =~ s/\\/\\\\/g; $rj =~ s/"/\\"/g; $rj =~ s/[\x00-\x1f]//g;
109109 push @rparts, sprintf('"%s":[%s]', $rj, join(',', @sarr));
110110 }
111111 push @cparts, sprintf('"%s":{%s}', _jstr($code), join(',', @rparts));
112112 }
113113 $per_site_state_json .= join(',', @cparts);
114114}
115115$per_site_state_json .= '}';
116116
117117# Per-country -> dimension -> { value => count } JSON for the modal's
118118# browser/OS/device/resolution/viewport/language/timezone bars.
119119my $country_dims_json = '{';
120120{
121121 my @cparts;
122122 foreach my $code (sort keys %$country_dims) {
123123 my @dparts;
124124 foreach my $dim (qw(browsers os devices resolutions viewports languages timezones)) {
125125 my $rh = $country_dims->{$code}{$dim} || {};
126126 my @vparts;
127127 foreach my $v (sort { $rh->{$b} <=> $rh->{$a} } keys %$rh) {
128128 my $vj = $v; $vj =~ s/\\/\\\\/g; $vj =~ s/"/\\"/g; $vj =~ s/[\x00-\x1f]//g;
129129 push @vparts, sprintf('"%s":%d', $vj, $rh->{$v}+0);
130130 }
131131 push @dparts, sprintf('"%s":{%s}', $dim, join(',', @vparts));
132132 }
133133 push @cparts, sprintf('"%s":{%s}', _jstr($code), join(',', @dparts));
134134 }
135135 $country_dims_json .= join(',', @cparts);
136136}
137137$country_dims_json .= '}';
138138
139139# Per-state dimension breakdowns JSON.
140140# { US: { 'New York': { browsers: {...}, ... } } }
141141my $state_dims_json = '{';
142142{
143143 my @cparts;
144144 foreach my $code (sort keys %$state_dims) {
145145 my @rparts;
146146 foreach my $region (sort keys %{ $state_dims->{$code} }) {
147147 my @dparts;
148148 foreach my $dim (qw(browsers os devices resolutions viewports languages timezones)) {
149149 my $rh = $state_dims->{$code}{$region}{$dim} || {};
150150 my @vparts;
151151 foreach my $v (sort { $rh->{$b} <=> $rh->{$a} } keys %$rh) {
152152 my $vj = $v; $vj =~ s/\\/\\\\/g; $vj =~ s/"/\\"/g; $vj =~ s/[\x00-\x1f]//g;
153153 push @vparts, sprintf('"%s":%d', $vj, $rh->{$v}+0);
154154 }
155155 push @dparts, sprintf('"%s":{%s}', $dim, join(',', @vparts));
156156 }
157157 my $rj = $region; $rj =~ s/\\/\\\\/g; $rj =~ s/"/\\"/g; $rj =~ s/[\x00-\x1f]//g;
158158 push @rparts, sprintf('"%s":{%s}', $rj, join(',', @dparts));
159159 }
160160 push @cparts, sprintf('"%s":{%s}', _jstr($code), join(',', @rparts));
161161 }
162162 $state_dims_json .= join(',', @cparts);
163163}
164164$state_dims_json .= '}';
165165
166166my $body = '';
167167$body .= qq~
168168<div class="page-head"><div class="left">
169169 <span class="page-eyebrow"><span class="dot"></span> Roll-up</span>
170170 <h1 class="page-title">Traffic Reports</h1>
171171 <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>
172172</div></div>
173173~;
174174
175175# Range chip row -- canonical .tr-chip picker (portfolio-wide)
176176$body .= qq~
177177<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">
178178 <span style="font-size:11px;letter-spacing:1.4px;text-transform:uppercase;color:var(--col-text-muted);font-weight:700">Range</span>~;
179179foreach my $d (1, 7, 30, 90, 365) {
180180 my $label = $d == 1 ? '24h' : $d == 365 ? '1y' : "${d}d";
181181 my $active = ($d == $days) ? ' is-active' : '';
182182 $body .= qq~<a class="tr-chip$active" href="/visitors.cgi?days=$d">$label</a>~;
183183}
184184my $rl = $days == 1 ? 'Last 24 hours'
185185 : $days == 365 ? 'Last year'
186186 : "Last $days days";
187187$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>~;
188188
189189# KPI tiles
190190my $rev_h = _n($roll->{total_pageviews});
191191$body .= qq~
192192<div class="dash-grid">
193193 <div class="module glow-magenta">
194194 <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>
195195 <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>
196196 </div>
197197 <div class="module glow-cyan">
198198 <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>
199199 <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>
200200 </div>
201201 <div class="module glow-lime">
202202 <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>
203203 <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>
204204 </div>
205205 <div class="module glow-violet">
206206 <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>
207207 <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>
208208 </div>
209209</div>
210210~;
211211
212212# Globe section
213213$body .= qq~
214214<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>
215215<script>
216216window.GLOBE_DATA = { countries: $globe_json };
217217window.GLOBE_STATE_DATA = $state_json;
218218/* Per-country -> per-site list, consumed by the meta-admin popover below. */
219219window.GLOBE_PER_SITE_BY_COUNTRY = $per_site_json;
220220window.GLOBE_PER_SITE_BY_STATE = $per_site_state_json;
221221window.GLOBE_COUNTRY_DIMENSIONS = $country_dims_json;
222222window.GLOBE_STATE_DIMENSIONS = $state_dims_json;
223223</script>
224224<div data-globe data-width="1200" data-height="600"
225225 data-globe-popover="meta-per-site"
226226 style="background:#0c1018;border:1px solid var(--col-border);border-radius:14px;overflow:hidden;position:relative;aspect-ratio:1200/600;margin-bottom:18px">
227227 <noscript><div style="padding:24px;color:var(--col-text);font-size:13px">JavaScript is required to render the interactive globe.</div></noscript>
228228</div>
229229<!-- Rich modal shown when a country is clicked on the globe.
230230 Contains aggregated KPIs, per-site contributions, and the
231231 state/region breakdown for the clicked country. -->
232232<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>
233233<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>
234234~;
235235
236236# Inline-accordion drill panel. Renders ALL country -> state -> city
237237# data at once (one SQL per site); each row is a click-to-expand
238238# trigger. Pure show/hide via JS - no AJAX, no page reload.
239239my $drill_html = '';
240240if ($drill) {
241241 my $name = _esc($drill->{display_name});
242242 my $col = _esc($drill->{color});
243243 my $ltr = _esc($LETTERS{$drill->{slug}} || uc(substr($drill->{slug},0,2)));
244244 my $tv = _n($drill->{total_visitors});
245245 my $ts = _n($drill->{total_sessions});
246246 my $close_href = "/visitors.cgi?days=$days";
247247
248248 $drill_html .= qq~
249249<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">
250250 <div class="module-head"><div class="left">
251251 <span class="brand-icon" style="--site-glow:$col;margin-right:8px" data-letter="$ltr"></span>
252252 <div class="module-title">$name &middot; site drill</div>
253253 </div>
254254 <a href="$close_href" class="btn btn-secondary btn-sm" style="margin-left:auto">&times; Close drill</a>
255255 </div>
256256 <div class="module-body" style="padding:18px">
257257 <div style="display:flex;gap:24px;margin-bottom:14px">
258258 <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>
259259 <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>
260260 </div>
261261~;
262262
263263 if (scalar @{ $drill->{countries} } > 0) {
264264 $drill_html .= q~
265265<style>
266266.accord-scope { position: relative; }
267267.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; }
268268.accord-collapse-all:hover { color: var(--col-text); border-color: var(--col-text-3, #64748b); }
269269.accord-tbl { width:100%; border-collapse: collapse; margin-bottom: 8px; }
270270.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); }
271271.accord-tbl td { padding: 11px 14px; border-bottom: 1px solid var(--col-border); font-size: 13px; vertical-align: middle; }
272272.accord-tbl tr.expandable { cursor: pointer; transition: background .12s ease; }
273273.accord-tbl tr.expandable:hover { background: rgba(255,255,255,.03); }
274274.accord-tbl tr.expandable.is-open { background: rgba(255,255,255,.04); }
275275.accord-tbl .chev { display:inline-block; width:14px; color: var(--col-text-muted); transition: transform .15s ease; margin-right:6px; }
276276.accord-tbl tr.expandable.is-open .chev { transform: rotate(90deg); color: var(--col-text); }
277277.accord-tbl tr.kids > td { padding: 0 !important; background: rgba(0,0,0,.18); border-bottom: 1px solid var(--col-border); }
278278.accord-tbl tr.kids[hidden] { display: none; }
279279.accord-tbl .nested { width:100%; border-collapse: collapse; }
280280.accord-tbl .nested th { background: transparent; font-size: 10px; padding: 8px 14px 6px 36px; }
281281.accord-tbl .nested td { padding: 9px 14px 9px 36px; font-size: 12.5px; border-bottom: 1px solid rgba(255,255,255,.05); }
282282.accord-tbl .nested .nested th { padding-left: 58px; }
283283.accord-tbl .nested .nested td { padding-left: 58px; font-size: 12px; }
284284.accord-tbl .num { text-align:right; font-variant-numeric: tabular-nums; }
285285.accord-tbl .leaf td { padding-left: 80px; color: var(--col-text-2); }
286286</style>
287287<div class="accord-scope">
288288<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>
289289<table class="accord-tbl">
290290 <thead><tr><th>Country / state / city</th><th class="num">Visitors</th><th class="num">Sessions</th></tr></thead>
291291 <tbody>
292292~;
293293 my $ci = 0;
294294 foreach my $c (@{ $drill->{countries} }) {
295295 $ci++;
296296 my $cid = "cnt-$ci";
297297 my $cname = _esc($c->{name});
298298 my $has_states = scalar @{ $c->{states} } > 0 ? 1 : 0;
299299 my $chev = $has_states ? '<span class="chev">&rsaquo;</span>' : '<span class="chev" style="opacity:0">&rsaquo;</span>';
300300 $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>~;
301301 if ($has_states) {
302302 $drill_html .= qq~<tr class="kids" id="$cid" hidden><td colspan="3"><table class="nested"><tbody>~;
303303 my $si = 0;
304304 foreach my $st (@{ $c->{states} }) {
305305 $si++;
306306 my $sid = "${cid}-st-$si";
307307 my $rname = _esc($st->{region});
308308 my $has_cities = scalar @{ $st->{cities} } > 0 ? 1 : 0;
309309 my $schev = $has_cities ? '<span class="chev">&rsaquo;</span>' : '<span class="chev" style="opacity:0">&rsaquo;</span>';
310310 $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>~;
311311 if ($has_cities) {
312312 $drill_html .= qq~<tr class="kids" id="$sid" hidden><td colspan="3"><table class="nested"><tbody>~;
313313 foreach my $ct (@{ $st->{cities} }) {
314314 my $cn = _esc($ct->{city});
315315 $drill_html .= qq~<tr class="leaf"><td>$cn</td><td class="num">@{[ _n($ct->{visitors}) ]}</td><td class="num">@{[ _n($ct->{sessions}) ]}</td></tr>~;
316316 }
317317 $drill_html .= '</tbody></table></td></tr>';
318318 }
319319 }
320320 $drill_html .= '</tbody></table></td></tr>';
321321 }
322322 }
323323 $drill_html .= '</tbody></table></div>';
324324 $drill_html .= q~
325325<script>
326326(function(){
327327 document.addEventListener('click', function(e){
328328 // Collapse-all button
329329 var btn = e.target.closest('.accord-collapse-all');
330330 if (btn) {
331331 var scope = btn.closest('.accord-scope') || document;
332332 scope.querySelectorAll('tr.kids').forEach(function(k){ k.hidden = true; });
333333 scope.querySelectorAll('tr.expandable.is-open').forEach(function(t){ t.classList.remove('is-open'); });
334334 return;
335335 }
336336 // Regular row toggle
337337 var tr = e.target.closest('tr.expandable');
338338 if (!tr) return;
339339 var id = tr.dataset.target;
340340 if (!id) return;
341341 var kids = document.getElementById(id);
342342 if (!kids) return;
343343 var open = !kids.hidden;
344344 kids.hidden = open;
345345 tr.classList.toggle('is-open', !open);
346346 });
347347})();
348348</script>
349349~;
350350 } else {
351351 $drill_html .= '<div class="banner info">No country data with ISO codes for this site in the selected window.</div>';
352352 }
353353 $drill_html .= '</div></div>';
354354}
355355
356356# Per-site breakdown table (rows clickable -> ?site=<slug>)
357357$body .= q~
358358<div class="module" style="margin-top:18px">
359359 <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>
360360 <div class="module-body" style="padding:0">
361361 <table class="tbl">
362362 <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>
363363 <tbody>
364364~;
365365foreach my $s (@{ $roll->{per_site} }) {
366366 my $name = _esc($s->{display_name});
367367 my $col = _esc($s->{color});
368368 my $ltr = _esc($LETTERS{$s->{slug}} || uc(substr($s->{slug},0,2)));
369369 my $pv = defined $s->{pageviews} ? _n($s->{pageviews}) : '<span class="dim">--</span>';
370370 my $vs = defined $s->{visitors} ? _n($s->{visitors}) : '<span class="dim">--</span>';
371371 my $sn = defined $s->{sessions} ? _n($s->{sessions}) : '<span class="dim">--</span>';
372372 my $err = _esc($s->{err} || '');
373373 my $href = "/visitors.cgi?site=$s->{slug}&days=$days";
374374 my $hl = ($drill && $drill->{slug} eq $s->{slug}) ? "background:${col}22;" : '';
375375 $body .= qq~<tr style="cursor:pointer;$hl" onclick="location.href='$href'">
376376 <td><span class="brand-icon" style="--site-glow:$col" data-letter="$ltr"></span></td>
377377 <td><strong>$name</strong></td>
378378 <td class="mono" style="text-align:right">$pv</td>
379379 <td class="mono" style="text-align:right">$vs</td>
380380 <td class="mono" style="text-align:right">$sn</td>
381381 <td class="dim" style="font-size:11px">$err</td>
382382 </tr>~;
383383}
384384$body .= q~
385385 </tbody>
386386 </table>
387387 </div>
388388</div>
389389~;
390390
391391# Drill panel renders AFTER the per-site table now (no top-of-page jump).
392392$body .= $drill_html if $drill_html;
393393
394394# Auto-scroll to the drill when the page loads with ?site=<slug>.
395395if ($drill) {
396396 $body .= q~
397397<script>
398398(function(){
399399 var el = document.getElementById('site-drill');
400400 if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
401401})();
402402</script>
403403~;
404404}
405405
406406$body .= q~
407407<!-- Globe click -> rich modal with KPIs + per-site bars + state breakdown -->
408408<script>
409409(function(){
410410 var modal = document.getElementById('metaModal');
411411 var backdrop = document.getElementById('metaModalBackdrop');
412412 if (!modal || !backdrop) return;
413413 var perSiteByCountry = window.GLOBE_PER_SITE_BY_COUNTRY || {};
414414 var perSiteByState = window.GLOBE_PER_SITE_BY_STATE || {};
415415 var stateData = window.GLOBE_STATE_DATA || {};
416416 var countryDims = window.GLOBE_COUNTRY_DIMENSIONS || {};
417417 var stateDims = window.GLOBE_STATE_DIMENSIONS || {};
418418 var nameMap = {};
419419 (window.GLOBE_DATA && window.GLOBE_DATA.countries || []).forEach(function(c){
420420 if (c.code) nameMap[c.code] = c.name || c.code;
421421 });
422422 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'; }
423423 function esc(s){ return String(s||'').replace(/[&<>"]/g, function(m){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m];}); }
424424 function renderHeader(eyebrow, title){
425425 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">'
426426 + '<div>'
427427 + '<div style="font-size:10.5px;letter-spacing:1.6px;color:var(--col-text-muted);font-weight:700;text-transform:uppercase">'+eyebrow+'</div>'
428428 + '<div style="font-size:24px;font-weight:700;color:#fff;margin-top:4px">'+title+'</div>'
429429 + '</div>'
430430 + '<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>'
431431 + '</div>';
432432 }
433433 function renderKpis(arr){
434434 var html = '<div style="padding:18px 26px 8px;display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px">';
435435 arr.forEach(function(k){
436436 html += '<div style="background:var(--col-bg-2,#0e1822);border:1px solid var(--col-border);border-radius:10px;padding:12px 14px">'
437437 + '<div style="font-size:9.5px;letter-spacing:1.5px;color:var(--col-text-muted);font-weight:700;text-transform:uppercase">'+k.lbl+'</div>'
438438 + '<div style="font-size:20px;font-weight:700;color:#fff;margin-top:4px;font-variant-numeric:tabular-nums">'+k.val+'</div>'
439439 + '</div>';
440440 });
441441 return html + '</div>';
442442 }
443443 function renderPerSiteBars(rows, heading){
444444 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>';
445445 var max = rows[0].visitors || 1;
446446 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>';
447447 rows.forEach(function(r){
448448 var pct = max ? (r.visitors / max * 100) : 0;
449449 html += '<div style="margin-bottom:10px">'
450450 + '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">'
451451 + '<span style="color:#fff;font-weight:600;font-size:13px">'+esc(r.name)+'</span>'
452452 + '<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>'
453453 + '</div>'
454454 + '<div style="height:6px;border-radius:3px;background:rgba(255,255,255,0.06);overflow:hidden">'
455455 + '<div style="height:100%;width:'+pct.toFixed(1)+'%;background:'+esc(r.color)+'"></div>'
456456 + '</div></div>';
457457 });
458458 return html + '</div>';
459459 }
460460 // Render a generic "{name: count}" map as a labelled list of bars.
461461 // Used for the dimension breakdowns (browser, OS, device, etc).
462462 function renderDimension(label, rowsObj){
463463 var entries = Object.keys(rowsObj || {}).map(function(k){return {name:k,count:rowsObj[k]};})
464464 .sort(function(a,b){return b.count - a.count;});
465465 if (!entries.length) return '';
466466 var max = entries[0].count || 1;
467467 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>';
468468 entries.slice(0, 8).forEach(function(e){
469469 var pct = (e.count / max * 100);
470470 html += '<div style="margin-bottom:6px">'
471471 + '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:2px">'
472472 + '<span style="color:#fff;font-size:12px">'+esc(e.name)+'</span>'
473473 + '<span style="color:var(--col-text-muted);font-variant-numeric:tabular-nums;font-size:11.5px">'+fmt(e.count)+'</span>'
474474 + '</div>'
475475 + '<div style="height:3px;border-radius:2px;background:rgba(255,255,255,0.05);overflow:hidden">'
476476 + '<div style="height:100%;width:'+pct.toFixed(1)+'%;background:linear-gradient(90deg,#a01a2c,#d97a86)"></div>'
477477 + '</div></div>';
478478 });
479479 return html + '</div>';
480480 }
481481 function showCountry(code){
482482 var siteRows = perSiteByCountry[code] || [];
483483 var stateRows = stateData[code] || {};
484484 var dims = countryDims[code] || {};
485485 var totalVisitors = siteRows.reduce(function(a,r){return a + (r.visitors||0);}, 0);
486486 var totalSessions = siteRows.reduce(function(a,r){return a + (r.sessions||0);}, 0);
487487 var html = renderHeader('Country detail', esc(nameMap[code] || code));
488488 html += renderKpis([
489489 { lbl:'Total visitors', val: fmt(totalVisitors) },
490490 { lbl:'Total sessions', val: fmt(totalSessions) },
491491 { lbl:'Contributing sites', val: siteRows.length },
492492 { lbl:'States with traffic',val: Object.keys(stateRows).length }
493493 ]);
494494 html += renderPerSiteBars(siteRows, 'Per-site contributions');
495495 var states = Object.keys(stateRows).map(function(s){return {name:s,count:stateRows[s]};}).sort(function(a,b){return b.count - a.count;});
496496 if (states.length) {
497497 var smax = states[0].count || 1;
498498 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>';
499499 states.slice(0, 15).forEach(function(s){
500500 var pct = (s.count / smax * 100);
501501 html += '<div style="margin-bottom:8px">'
502502 + '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:3px">'
503503 + '<span style="color:#fff;font-size:12.5px">'+esc(s.name)+'</span>'
504504 + '<span style="color:var(--col-text-muted);font-variant-numeric:tabular-nums;font-size:12px">'+fmt(s.count)+'</span>'
505505 + '</div>'
506506 + '<div style="height:4px;border-radius:2px;background:rgba(255,255,255,0.05);overflow:hidden">'
507507 + '<div style="height:100%;width:'+pct.toFixed(1)+'%;background:linear-gradient(90deg,#a01a2c,#d97a86)"></div>'
508508 + '</div></div>';
509509 });
510510 html += '</div>';
511511 }
512512 // Dimension breakdowns in a responsive grid: browsers / OS / devices,
513513 // then resolutions / viewports / languages / timezones.
514514 var anyDim = Object.keys(dims).length > 0;
515515 if (anyDim) {
516516 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>'
517517 + '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px">'
518518 + renderDimension('Browser', dims.browsers)
519519 + renderDimension('Operating system', dims.os)
520520 + renderDimension('Device type', dims.devices)
521521 + renderDimension('Screen resolution', dims.resolutions)
522522 + renderDimension('Viewport', dims.viewports)
523523 + renderDimension('Language', dims.languages)
524524 + renderDimension('Timezone', dims.timezones)
525525 + '</div></div>';
526526 }
527527 modal.innerHTML = html;
528528 }
529529 function showState(code, region){
530530 var siteRows = (perSiteByState[code] && perSiteByState[code][region]) || [];
531531 var dims = (stateDims[code] && stateDims[code][region]) || {};
532532 var totalVisitors = siteRows.reduce(function(a,r){return a + (r.visitors||0);}, 0);
533533 var totalSessions = siteRows.reduce(function(a,r){return a + (r.sessions||0);}, 0);
534534 var countryName = nameMap[code] || code;
535535 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>');
536536 html += renderKpis([
537537 { lbl:'Total visitors', val: fmt(totalVisitors) },
538538 { lbl:'Total sessions', val: fmt(totalSessions) },
539539 { lbl:'Contributing sites', val: siteRows.length },
540540 { lbl:'Country', val: code }
541541 ]);
542542 html += renderPerSiteBars(siteRows, 'Per-site contributions in this state');
543543 if (Object.keys(dims).length) {
544544 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>'
545545 + '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px">'
546546 + renderDimension('Browser', dims.browsers)
547547 + renderDimension('Operating system', dims.os)
548548 + renderDimension('Device type', dims.devices)
549549 + renderDimension('Screen resolution', dims.resolutions)
550550 + renderDimension('Viewport', dims.viewports)
551551 + renderDimension('Language', dims.languages)
552552 + renderDimension('Timezone', dims.timezones)
553553 + '</div></div>';
554554 }
555555 modal.innerHTML = html;
556556 }
557557 function show(code, region){
558558 if (region) showState(code, region); else showCountry(code);
559559 modal.style.display = 'block';
560560 backdrop.style.display = 'block';
561561 document.getElementById('metaModalClose').addEventListener('click', closeModal);
562562 }
563563 function closeModal(){
564564 modal.style.display = 'none';
565565 backdrop.style.display = 'none';
566566 }
567567 // Intercept globe country click in capture phase so globe.js's own
568568 // drill modal never opens. Show our richer modal instead.
569569 document.addEventListener('click', function(e){
570570 var g = e.target && e.target.closest ? e.target.closest('.globe-country') : null;
571571 if (!g) return;
572572 var code = g.getAttribute('data-code');
573573 if (!code) return;
574574 // State dots carry data-region with the state name. Country dots
575575 // don't, so we treat the click as state-level only when the region
576576 // attribute is present.
577577 var region = g.getAttribute('data-region') || '';
578578 e.stopPropagation();
579579 e.preventDefault();
580580 show(code, region || null);
581581 }, true);
582582 backdrop.addEventListener('click', closeModal);
583583 document.addEventListener('keydown', function(e){
584584 if (e.key === 'Escape' && modal.style.display !== 'none') closeModal();
585585 });
586586})();
587587</script>
588588~;
589589
590590MODS::MetaAdmin::Wrapper->new->render(
591591 title => 'Traffic Reports',
592592 page_key => 'visitors',
593593 body => $body,
594594 userinfo => $u,
595595 extra_js => '<script src="/assets/javascript/globe.js?v=1"></script>',
596596);