Diff -- /var/www/vhosts/3dshawn.com/admin.3dshawn.com/visitors.cgi
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
to d1ae7348f46b
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 | #====================================================================== | |
| 8 | use strict; | |
| 9 | use warnings; | |
| 10 | use lib '/var/www/vhosts/3dshawn.com/admin.3dshawn.com'; | |
| 11 | use CGI; | |
| 12 | use MODS::Login; | |
| 13 | use MODS::MetaAdmin::Config; | |
| 14 | use MODS::MetaAdmin::Wrapper; | |
| 15 | use MODS::MetaAdmin::Visitors; | |
| 16 | ||
| 17 | my $q = CGI->new; | |
| 18 | my $form = $q->Vars; | |
| 19 | my $auth = MODS::Login->new; | |
| 20 | my $u = $auth->login_verify; | |
| 21 | unless ($u) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; } | |
| 22 | ||
| 23 | my $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 | ||
| 29 | my $site_slug = $form->{site} || ''; | |
| 30 | $site_slug =~ s/[^a-z0-9_\-]//g; | |
| 31 | ||
| 32 | my $vis = MODS::MetaAdmin::Visitors->new; | |
| 33 | my $roll = $vis->kpi_rollup($days); | |
| 34 | my @globe = $vis->geo_rollup($days); | |
| 35 | my $per_site_by_country = $vis->country_per_site_rollup($days); | |
| 36 | my $per_site_by_state = $vis->state_per_site_rollup($days); | |
| 37 | my $state_data = $vis->state_rollup($days); | |
| 38 | my $country_dims = $vis->country_dimensions_rollup($days); | |
| 39 | my $state_dims = $vis->state_dimensions_rollup($days); | |
| 40 | my $drill = length($site_slug) ? $vis->full_drill_tree($site_slug, $days) : undef; | |
| 41 | ||
| 42 | sub _esc { my $s=shift;$s//='';$s=~s/&/&/g;$s=~s/</</g;$s=~s/>/>/g;$s=~s/"/"/g;return $s; } | |
| 43 | sub _jstr { my $s=shift;$s//='';$s=~s/\\/\\\\/g;$s=~s/"/\\"/g;$s=~s/[\x00-\x1f]//g;return $s; } | |
| 44 | sub _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) | |
| 50 | my %LETTERS = ( | |
| 51 | webstls=>'WS', ptmatrix=>'PT', affsoft=>'AS', shopcart=>'SC', | |
| 52 | repricer=>'RP', abforge=>'AB', contactforge=>'CF', | |
| 53 | ); | |
| 54 | ||
| 55 | my $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 }, ...] } | |
| 62 | my $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 }, ... } | |
| 80 | my $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}, ...] } } | |
| 96 | my $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. | |
| 119 | my $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: {...}, ... } } } | |
| 141 | my $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 | ||
| 166 | my $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>~; | |
| 179 | foreach 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 | } | |
| 184 | my $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 | |
| 190 | my $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> | |
| 216 | window.GLOBE_DATA = { countries: $globe_json }; | |
| 217 | window.GLOBE_STATE_DATA = $state_json; | |
| 218 | /* Per-country -> per-site list, consumed by the meta-admin popover below. */ | |
| 219 | window.GLOBE_PER_SITE_BY_COUNTRY = $per_site_json; | |
| 220 | window.GLOBE_PER_SITE_BY_STATE = $per_site_state_json; | |
| 221 | window.GLOBE_COUNTRY_DIMENSIONS = $country_dims_json; | |
| 222 | window.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. | |
| 239 | my $drill_html = ''; | |
| 240 | if ($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 · site drill</div> | |
| 253 | </div> | |
| 254 | <a href="$close_href" class="btn btn-secondary btn-sm" style="margin-left:auto">× 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">›</span>' : '<span class="chev" style="opacity:0">›</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">›</span>' : '<span class="chev" style="opacity:0">›</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 | ~; | |
| 365 | foreach 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>. | |
| 395 | if ($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 {'&':'&','<':'<','>':'>','"':'"'}[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">×</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 · '+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"> · ' + 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 | ||
| 590 | MODS::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 | ); |