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