added on WebSTLs (webstls.com) at 2026-07-09 23:20:49
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # WebSTLs - ADMIN Traffic Funnels (platform-wide SaaS conversion funnel) | |
| 4 | # | |
| 5 | # Renders the same gorgeous SVG funnel viz as /funnels.cgi but with | |
| 6 | # stages computed across THE WHOLE PLATFORM (not one customer's site): | |
| 7 | # Signed up -> Verified -> Site created -> Tracker active -> Paid | |
| 8 | # | |
| 9 | # Used by ABForge super-admins to see where new prospects leak before | |
| 10 | # becoming paying customers. | |
| 11 | #====================================================================== | |
| 12 | use strict; | |
| 13 | use warnings; | |
| 14 | ||
| 15 | use lib '/var/www/vhosts/webstls.com/httpdocs'; | |
| 16 | use CGI; | |
| 17 | use MODS::DBConnect; | |
| 18 | use MODS::Login; | |
| 19 | use MODS::WebSTLs::Config; | |
| 20 | use MODS::WebSTLs::Wrapper; | |
| 21 | use MODS::AnonPaths; | |
| 22 | ||
| 23 | my $q = CGI->new; | |
| 24 | my $auth = MODS::Login->new; | |
| 25 | my $wrap = MODS::WebSTLs::Wrapper->new; | |
| 26 | my $db = MODS::DBConnect->new; | |
| 27 | my $cfg = MODS::WebSTLs::Config->new; | |
| 28 | my $DB = $cfg->settings('database_name'); | |
| 29 | ||
| 30 | my $userinfo = $auth->login_verify(); | |
| 31 | unless ($userinfo) { | |
| 32 | print "Status: 302 Found\nLocation: /login.cgi\n\n"; | |
| 33 | exit; | |
| 34 | } | |
| 35 | unless ($userinfo->{is_admin} || $userinfo->{is_super_admin} || $userinfo->{_admin_is_super_admin}) { | |
| 36 | print "Status: 403 Forbidden\nContent-Type: text/plain\n\nAdmin access required.\n"; | |
| 37 | exit; | |
| 38 | } | |
| 39 | ||
| 40 | my $days = int($q->param('days') || 30); | |
| 41 | $days = 1 if $days < 1; | |
| 42 | $days = 730 if $days > 730; | |
| 43 | ||
| 44 | # Custom range support: ?from=YYYY-MM-DD[Thh:mm]&to=YYYY-MM-DD[Thh:mm]. | |
| 45 | # When both parse cleanly, drive the funnel via BETWEEN; otherwise use | |
| 46 | # rolling $days. | |
| 47 | my $qfrom = $q->param('from') || ''; | |
| 48 | my $qto = $q->param('to') || ''; | |
| 49 | $qfrom =~ s/T\d{2}:\d{2}(:\d{2})?$//; | |
| 50 | $qto =~ s/T\d{2}:\d{2}(:\d{2})?$//; | |
| 51 | my $is_custom_range = ($qfrom =~ /^\d{4}-\d{2}-\d{2}$/ && $qto =~ /^\d{4}-\d{2}-\d{2}$/) ? 1 : 0; | |
| 52 | ||
| 53 | my $dbh = $db->db_connect(); | |
| 54 | ||
| 55 | # ----- Stage-query window -------------------------------------------- | |
| 56 | # Either "col >= DATE_SUB(NOW(), INTERVAL $days DAY)" for chip presets, | |
| 57 | # or "col BETWEEN 'X 00:00:00' AND 'Y 23:59:59'" for a custom range. | |
| 58 | my $window_where = sub { | |
| 59 | my ($col) = @_; | |
| 60 | return $is_custom_range | |
| 61 | ? "$col BETWEEN '$qfrom 00:00:00' AND '$qto 23:59:59'" | |
| 62 | : "$col >= DATE_SUB(NOW(), INTERVAL $days DAY)"; | |
| 63 | }; | |
| 64 | my $window = $window_where->('created_at'); | |
| 65 | my $u_window = $window_where->('u.created_at'); | |
| 66 | ||
| 67 | my @stages = ( | |
| 68 | { label => 'Signed up', sql => qq~SELECT COUNT(*) AS n FROM `${DB}`.users WHERE $window~ }, | |
| 69 | { label => 'Verified email', sql => qq~SELECT COUNT(*) AS n FROM `${DB}`.users WHERE $window AND email_verified_at IS NOT NULL~ }, | |
| 70 | { label => 'Opened storefront', sql => qq~SELECT COUNT(DISTINCT u.id) AS n FROM `${DB}`.users u JOIN `${DB}`.storefronts s ON s.user_id=u.id WHERE $u_window~ }, | |
| 71 | { label => 'Uploaded a model', sql => qq~SELECT COUNT(DISTINCT u.id) AS n FROM `${DB}`.users u JOIN `${DB}`.models m ON m.user_id=u.id WHERE $u_window~ }, | |
| 72 | { label => 'Made a sale', sql => qq~SELECT COUNT(DISTINCT u.id) AS n FROM `${DB}`.users u JOIN `${DB}`.orders o ON o.seller_user_id=u.id WHERE $u_window~ }, | |
| 73 | { label => 'Paying', sql => qq~SELECT COUNT(DISTINCT u.id) AS n FROM `${DB}`.users u JOIN `${DB}`.subscriptions s ON s.user_id=u.id WHERE $u_window AND s.status='active'~ },); | |
| 74 | ||
| 75 | # Execute each stage SQL, guarding for tables that may not yet exist. | |
| 76 | my @bars; | |
| 77 | my $entered = 0; | |
| 78 | my $i = 0; | |
| 79 | my $prev = 0; | |
| 80 | foreach my $st (@stages) { | |
| 81 | my $n = safe_count($db, $dbh, $st->{sql}); | |
| 82 | $entered = $n if $i == 0; | |
| 83 | my $pct = $entered ? sprintf('%.1f', 100.0 * $n / $entered) : 0; | |
| 84 | my $drop = ($i == 0 || !$prev) ? 0 : sprintf('%.1f', 100.0 * ($prev - $n) / $prev); | |
| 85 | push @bars, { | |
| 86 | label => $st->{label}, | |
| 87 | count => $n, | |
| 88 | pct => $pct, | |
| 89 | drop_pct => $drop, | |
| 90 | }; | |
| 91 | $prev = $n; | |
| 92 | $i++; | |
| 93 | } | |
| 94 | ||
| 95 | # Top biggest drop | |
| 96 | my $biggest = { step => '-', pct => 0 }; | |
| 97 | for my $k (1 .. $#bars) { | |
| 98 | if ($bars[$k]->{drop_pct} > $biggest->{pct}) { | |
| 99 | $biggest = { step => $bars[$k]->{label}, pct => $bars[$k]->{drop_pct} }; | |
| 100 | } | |
| 101 | } | |
| 102 | ||
| 103 | my $converted = $bars[-1]->{count}; | |
| 104 | my $conv_pct = $entered ? sprintf('%.1f', 100.0 * $converted / $entered) : 0; | |
| 105 | ||
| 106 | # Daily signups sparkline data (silenced, like safe_count) | |
| 107 | my @sparks; | |
| 108 | { | |
| 109 | local *OLDOUT2; | |
| 110 | open(OLDOUT2, '>&', \*STDOUT) or do {}; | |
| 111 | open(STDOUT, '>', '/dev/null') or do {}; | |
| 112 | @sparks = eval { $db->db_readwrite_multiple($dbh, qq~ | |
| 113 | SELECT DATE(created_at) AS d, COUNT(*) AS n | |
| 114 | FROM `${DB}`.users | |
| 115 | WHERE $window | |
| 116 | GROUP BY DATE(created_at) | |
| 117 | ORDER BY d ASC | |
| 118 | ~, $ENV{SCRIPT_NAME}, __LINE__) }; | |
| 119 | @sparks = () unless @sparks; | |
| 120 | open(STDOUT, '>&', \*OLDOUT2) or do {}; | |
| 121 | } | |
| 122 | ||
| 123 | # Top acquisition channels: email-domain breakdown of recent signups so | |
| 124 | # admins can see which channel (Google Workspace, Gmail, etc.) feeds the | |
| 125 | # top of the funnel. Falls back to "(none)" for any blank/odd address. | |
| 126 | my @top_domains; | |
| 127 | { | |
| 128 | local *OLDOUT3; | |
| 129 | open(OLDOUT3, '>&', \*STDOUT) or do {}; | |
| 130 | open(STDOUT, '>', '/dev/null') or do {}; | |
| 131 | @top_domains = eval { $db->db_readwrite_multiple($dbh, qq~ | |
| 132 | SELECT SUBSTRING_INDEX(email, '\@', -1) AS domain, COUNT(*) AS n | |
| 133 | FROM `${DB}`.users | |
| 134 | WHERE $window | |
| 135 | AND email IS NOT NULL AND email != '' | |
| 136 | GROUP BY SUBSTRING_INDEX(email, '\@', -1) | |
| 137 | ORDER BY n DESC | |
| 138 | LIMIT 10 | |
| 139 | ~, $ENV{SCRIPT_NAME}, __LINE__) }; | |
| 140 | @top_domains = () unless @top_domains; | |
| 141 | open(STDOUT, '>&', \*OLDOUT3) or do {}; | |
| 142 | } | |
| 143 | ||
| 144 | $db->db_disconnect($dbh); | |
| 145 | ||
| 146 | # ----- Render -------------------------------------------------------- | |
| 147 | # Canonical chip-style range picker (matches admin_traffic.cgi's UX). | |
| 148 | my %rc_active = (1 => '', 7 => '', 30 => '', 90 => '', 365 => '', custom => ''); | |
| 149 | if ($is_custom_range) { | |
| 150 | $rc_active{custom} = ' is-active'; | |
| 151 | } else { | |
| 152 | if ($days == 1) { $rc_active{1} = ' is-active'; } | |
| 153 | elsif ($days == 7) { $rc_active{7} = ' is-active'; } | |
| 154 | elsif ($days == 30) { $rc_active{30} = ' is-active'; } | |
| 155 | elsif ($days == 90) { $rc_active{90} = ' is-active'; } | |
| 156 | elsif ($days == 365) { $rc_active{365} = ' is-active'; } | |
| 157 | else { $rc_active{30} = ' is-active'; } | |
| 158 | } | |
| 159 | my $rc_range_label = | |
| 160 | $rc_active{custom} ? "$qfrom -- $qto" | |
| 161 | : $rc_active{1} ? 'Last 24h' | |
| 162 | : $rc_active{7} ? 'Last 7 days' | |
| 163 | : $rc_active{30} ? 'Last 30 days' | |
| 164 | : $rc_active{90} ? 'Last 90 days' | |
| 165 | : $rc_active{365} ? 'Last 1 year' | |
| 166 | : "Last $days days"; | |
| 167 | my $rc_from_input = $is_custom_range ? "${qfrom}T00:00" : ''; | |
| 168 | my $rc_to_input = $is_custom_range ? "${qto}T23:59" : ''; | |
| 169 | my $rc_custom_display = $is_custom_range ? 'flex' : 'none'; | |
| 170 | ||
| 171 | my $svg = render_svg_funnel(\@bars); | |
| 172 | my $sparkbars = ''; | |
| 173 | my $max_spark = 1; | |
| 174 | foreach my $r (@sparks) { $max_spark = $r->{n} if $r->{n} > $max_spark; } | |
| 175 | foreach my $r (@sparks) { | |
| 176 | my $h = int(($r->{n} / $max_spark) * 100); | |
| 177 | $sparkbars .= qq~<div class="afnl-spark-col" title="$r->{d}: $r->{n} signups"><div class="afnl-spark-bar" style="height:${h}%"></div></div>~; | |
| 178 | } | |
| 179 | ||
| 180 | my $step_cards = ''; | |
| 181 | my $idx = 0; | |
| 182 | foreach my $b (@bars) { | |
| 183 | my $cnt = fmt_num($b->{count}); | |
| 184 | my $drop_class = ($b->{drop_pct} && $b->{drop_pct} > 40) ? 'high' : (($b->{drop_pct} && $b->{drop_pct} > 20) ? 'mid' : 'low'); | |
| 185 | my $color = step_color($idx, scalar @bars); | |
| 186 | $idx++; | |
| 187 | $step_cards .= qq~ | |
| 188 | <div class="afnl-step" style="--step-color:$color"> | |
| 189 | <div class="afnl-step-num">$idx</div> | |
| 190 | <div class="afnl-step-info"> | |
| 191 | <div class="afnl-step-label">$b->{label}</div> | |
| 192 | <div class="afnl-step-meta">$cnt users · $b->{pct}% of signups</div> | |
| 193 | </div> | |
| 194 | <div class="afnl-step-drop $drop_class"> | |
| 195 | <div class="lbl">Drop</div> | |
| 196 | <div class="val">$b->{drop_pct}%</div> | |
| 197 | </div> | |
| 198 | </div>~; | |
| 199 | } | |
| 200 | ||
| 201 | my $css = afnl_css(); | |
| 202 | my $hero = afnl_hero('Conversion Funnel', | |
| 203 | 'Where customers leak between signup and paying. <strong>Find the leak, plug the leak.</strong>'); | |
| 204 | ||
| 205 | my $entered_fmt = fmt_num($entered); | |
| 206 | my $converted_fmt = fmt_num($converted); | |
| 207 | my $biggest_pct = $biggest->{pct}; | |
| 208 | my $biggest_step = h_esc($biggest->{step}); | |
| 209 | ||
| 210 | my $body = qq~ | |
| 211 | $css | |
| 212 | $hero | |
| 213 | ||
| 214 | <div class="afnl-toolbar"> | |
| 215 | <form id="rangeForm" method="get" action="/admin_funnels.cgi" | |
| 216 | style="display:flex;flex-wrap:wrap;align-items:center;gap:8px;flex:1 1 auto;padding:10px 14px;background:var(--col-surface);border:1px solid var(--col-border);border-radius:12px"> | |
| 217 | <span style="font-size:11.5px;letter-spacing:1px;text-transform:uppercase;color:var(--col-text-muted);font-weight:700">Range</span> | |
| 218 | <a class="tr-chip$rc_active{1}" href="?days=1">24h</a> | |
| 219 | <a class="tr-chip$rc_active{7}" href="?days=7">7d</a> | |
| 220 | <a class="tr-chip$rc_active{30}" href="?days=30">30d</a> | |
| 221 | <a class="tr-chip$rc_active{90}" href="?days=90">90d</a> | |
| 222 | <a class="tr-chip$rc_active{365}" href="?days=365">1y</a> | |
| 223 | <button type="button" class="tr-chip$rc_active{custom}" id="customBtn">Custom…</button> | |
| 224 | <div id="customWrap" style="display:$rc_custom_display;gap:6px;align-items:center;margin-left:8px"> | |
| 225 | <input type="datetime-local" name="from" value="$rc_from_input"> | |
| 226 | <span style="color:var(--col-text-muted);font-size:12px">to</span> | |
| 227 | <input type="datetime-local" name="to" value="$rc_to_input"> | |
| 228 | <button type="submit" style="padding:5px 12px;border-radius:999px;font-size:12px;font-weight:600;border:1px solid var(--col-border);background:var(--col-surface);color:var(--col-text);cursor:pointer">Apply</button> | |
| 229 | </div> | |
| 230 | <span style="margin-left:auto;font-size:11.5px;color:var(--col-text-muted)">Showing: <strong style="color:var(--col-text)">$rc_range_label</strong></span> | |
| 231 | </form> | |
| 232 | <a class="afnl-toolbar-btn" href="/admin_funnels.cgi" style="margin-left:8px"> | |
| 233 | <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M11 4h-7v16h16v-7M18.5 2.5a2.121 2.121 0 1 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> | |
| 234 | Edit steps | |
| 235 | </a> | |
| 236 | <a class="afnl-toolbar-btn ghost" href="/admin_funnels.cgi"> | |
| 237 | <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg> | |
| 238 | All funnels | |
| 239 | </a> | |
| 240 | </div> | |
| 241 | <style> | |
| 242 | .tr-chip{display:inline-flex;align-items:center;padding:5px 11px;border-radius:999px;font-size:12px;font-weight:600;color:var(--col-text-muted);text-decoration:none;background:transparent;border:1px solid var(--col-border);cursor:pointer;transition:all .15s ease;font-family:inherit} | |
| 243 | .tr-chip:hover{color:var(--col-text);border-color:var(--col-brand, var(--col-brand-2, var(--col-accent, #5aa9ff)))} | |
| 244 | .tr-chip.is-active{background:var(--col-brand, var(--col-brand-2, var(--col-accent, #5aa9ff)));color:#0c1018;border-color:var(--col-brand, var(--col-brand-2, var(--col-accent, #5aa9ff)))} | |
| 245 | </style> | |
| 246 | <script> | |
| 247 | (function(){ | |
| 248 | var btn = document.getElementById('customBtn'); | |
| 249 | var wrap = document.getElementById('customWrap'); | |
| 250 | if (!btn || !wrap) return; | |
| 251 | btn.addEventListener('click', function(){ | |
| 252 | var open = wrap.style.display !== 'none'; | |
| 253 | wrap.style.display = open ? 'none' : 'flex'; | |
| 254 | if (!open) { var f = wrap.querySelector('input[type=datetime-local]'); if (f) f.focus(); } | |
| 255 | }); | |
| 256 | })(); | |
| 257 | </script> | |
| 258 | ||
| 259 | ${\ _afnpp_funnels_widget($db, $dbh, $DB, $q) } | |
| 260 | ${\ afnpp_render_panel($db, $dbh, $DB, $days) } | |
| 261 | ||
| 262 | ${\ MODS::AnonPaths::render_for_admin($db, $dbh, $days, $q->param('path'), $entered) } | |
| 263 | ||
| 264 | <div class="afnl-stats-grid"> | |
| 265 | <div class="afnl-stat orb-orange"> | |
| 266 | <div class="lbl">Signups</div><div class="val">$entered_fmt</div> | |
| 267 | <div class="sub">New accounts in the last $days days</div> | |
| 268 | </div> | |
| 269 | <div class="afnl-stat orb-pink"> | |
| 270 | <div class="lbl">Reached final stage</div><div class="val">$converted_fmt</div> | |
| 271 | <div class="sub">Made it all the way through</div> | |
| 272 | </div> | |
| 273 | <div class="afnl-stat orb-violet"> | |
| 274 | <div class="lbl">End-to-end rate</div><div class="val grad-text">$conv_pct%</div> | |
| 275 | <div class="sub">Signup → final stage</div> | |
| 276 | </div> | |
| 277 | <div class="afnl-stat orb-cyan"> | |
| 278 | <div class="lbl">Biggest leak</div><div class="val">$biggest_pct%</div> | |
| 279 | <div class="sub">at <strong>$biggest_step</strong></div> | |
| 280 | </div> | |
| 281 | </div> | |
| 282 | ||
| 283 | <div class="afnl-viz-wrap"> | |
| 284 | <div class="afnl-viz-glow"></div> | |
| 285 | <div class="afnl-viz-head"> | |
| 286 | <div class="afnl-viz-title">Platform funnel</div> | |
| 287 | <div class="afnl-viz-sub">Every signup walks down this funnel. Each band is the count at that stage.</div> | |
| 288 | </div> | |
| 289 | $svg | |
| 290 | </div> | |
| 291 | ||
| 292 | <div class="afnl-spark-card"> | |
| 293 | <div class="afnl-spark-head"> | |
| 294 | <div> | |
| 295 | <div class="afnl-spark-title">Signups by day</div> | |
| 296 | <div class="afnl-spark-sub">The very top of the funnel — what feeds it.</div> | |
| 297 | </div> | |
| 298 | </div> | |
| 299 | <div class="afnl-spark-wrap">$sparkbars</div> | |
| 300 | </div> | |
| 301 | ||
| 302 | <div class="afnl-step-grid">$step_cards</div> | |
| 303 | ||
| 304 | <script> | |
| 305 | window.addEventListener('DOMContentLoaded', function(){ | |
| 306 | document.querySelectorAll('.fnl-band').forEach(function(el, i){ | |
| 307 | setTimeout(function(){ el.classList.add('lit'); }, 100 + i * 110); | |
| 308 | }); | |
| 309 | document.querySelectorAll('.afnl-stat').forEach(function(el, i){ | |
| 310 | setTimeout(function(){ el.classList.add('lit'); }, 60 + i * 80); | |
| 311 | }); | |
| 312 | document.querySelectorAll('.afnl-step').forEach(function(el, i){ | |
| 313 | setTimeout(function(){ el.classList.add('lit'); }, 320 + i * 70); | |
| 314 | }); | |
| 315 | }); | |
| 316 | </script> | |
| 317 | ~; | |
| 318 | ||
| 319 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n"; | |
| 320 | $wrap->render({ | |
| 321 | userinfo => $userinfo, | |
| 322 | page_key => 'admin_funnels', | |
| 323 | title => 'Conversion Funnel', | |
| 324 | body => $body, | |
| 325 | }); | |
| 326 | ||
| 327 | #====================================================================== | |
| 328 | # Helpers | |
| 329 | #====================================================================== | |
| 330 | sub safe_count { | |
| 331 | my ($db, $dbh, $sql) = @_; | |
| 332 | # DBConnect prints DB errors to STDOUT, which would corrupt the CGI | |
| 333 | # header stream and 500 the page. Capture+discard during the query. | |
| 334 | local *OLDOUT; | |
| 335 | open(OLDOUT, '>&', \*STDOUT) or do {}; | |
| 336 | open(STDOUT, '>', '/dev/null') or do {}; | |
| 337 | my $r = eval { $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__) }; | |
| 338 | open(STDOUT, '>&', \*OLDOUT) or do {}; | |
| 339 | return 0 unless $r && defined $r->{n}; | |
| 340 | return int($r->{n}); | |
| 341 | } | |
| 342 | ||
| 343 | sub fmt_num { | |
| 344 | my ($n) = @_; $n = int($n || 0); | |
| 345 | 1 while $n =~ s/(\d)(\d{3})(?!\d)/$1,$2/; | |
| 346 | return $n; | |
| 347 | } | |
| 348 | sub h_esc { | |
| 349 | my ($s) = @_; return '' unless defined $s; | |
| 350 | $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; $s =~ s/"/"/g; | |
| 351 | return $s; | |
| 352 | } | |
| 353 | ||
| 354 | sub step_color { | |
| 355 | my ($i, $n) = @_; | |
| 356 | my @hues = (22, 320, 285, 250, 200, 168, 140, 90); | |
| 357 | return "hsl($hues[$i % scalar @hues], 92%, 60%)"; | |
| 358 | } | |
| 359 | sub step_color_dark { | |
| 360 | my ($i, $n) = @_; | |
| 361 | my @hues = (22, 320, 285, 250, 200, 168, 140, 90); | |
| 362 | return "hsl($hues[$i % scalar @hues], 80%, 38%)"; | |
| 363 | } | |
| 364 | ||
| 365 | sub render_svg_funnel { | |
| 366 | my ($bars) = @_; | |
| 367 | my $n = scalar @$bars; | |
| 368 | return '<div class="afnl-empty">No data yet.</div>' unless $n; | |
| 369 | ||
| 370 | my $W = 1080; | |
| 371 | my $H_band = 88; my $H_gap = 22; my $top_pad = 10; | |
| 372 | my $H = $top_pad + $n * $H_band + ($n - 1) * $H_gap + 36; | |
| 373 | ||
| 374 | my $defs = ''; | |
| 375 | for my $i (0..$n-1) { | |
| 376 | my $c1 = step_color($i, $n); | |
| 377 | my $c2 = step_color_dark($i, $n); | |
| 378 | $defs .= qq~<linearGradient id="afnlG$i" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="$c1"/><stop offset="100%" stop-color="$c2"/></linearGradient>~; | |
| 379 | } | |
| 380 | $defs .= qq~<linearGradient id="afnlLeak" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="#ff5b5b" stop-opacity="0.95"/><stop offset="100%" stop-color="#7a1f1f" stop-opacity="0"/></linearGradient>~; | |
| 381 | ||
| 382 | my $entered = $bars->[0]->{count} || 1; | |
| 383 | my $min_pct = 8; | |
| 384 | my @widths; | |
| 385 | for my $i (0..$n-1) { | |
| 386 | my $count = $bars->[$i]->{count} || 0; | |
| 387 | my $pct = $entered ? (100.0 * $count / $entered) : 0; | |
| 388 | my $vw = ($pct < $min_pct) ? $min_pct : $pct; | |
| 389 | push @widths, int($W * $vw / 100); | |
| 390 | } | |
| 391 | ||
| 392 | my $bands = ''; | |
| 393 | my $conn = ''; | |
| 394 | for my $i (0..$n-1) { | |
| 395 | my $bw = $widths[$i]; | |
| 396 | my $x = int(($W - $bw) / 2); | |
| 397 | my $y = $top_pad + $i * ($H_band + $H_gap); | |
| 398 | my $count = fmt_num($bars->[$i]->{count}); | |
| 399 | my $pct = $bars->[$i]->{pct}; | |
| 400 | my $label = h_esc($bars->[$i]->{label}); | |
| 401 | my $tt = "$label - $count ($pct%)"; | |
| 402 | ||
| 403 | $bands .= qq~<g class="fnl-band" data-step="$i"><title>$tt</title>~; | |
| 404 | $bands .= qq~<rect x="$x" y="$y" rx="14" ry="14" width="$bw" height="$H_band" fill="url(#afnlG$i)"/>~; | |
| 405 | my $hy = $y + 6; my $hw = $bw - 24; my $hx = $x + 12; | |
| 406 | $bands .= qq~<rect x="$hx" y="$hy" rx="6" ry="6" width="$hw" height="3" fill="white" opacity="0.18"/>~; | |
| 407 | my $cx = int($x + $bw/2); | |
| 408 | my $cy = $y + 38; | |
| 409 | if ($bw > 220) { | |
| 410 | $bands .= qq~<text x="$cx" y="$cy" text-anchor="middle" font-family="-apple-system,sans-serif" font-size="22" font-weight="800" fill="white">$count</text>~; | |
| 411 | my $cy2 = $y + 62; | |
| 412 | $bands .= qq~<text x="$cx" y="$cy2" text-anchor="middle" font-family="-apple-system,sans-serif" font-size="12" font-weight="500" fill="white" opacity="0.85">$label · ${pct}%</text>~; | |
| 413 | } elsif ($bw > 110) { | |
| 414 | $bands .= qq~<text x="$cx" y="$cy" text-anchor="middle" font-family="-apple-system,sans-serif" font-size="16" font-weight="700" fill="white">$count</text>~; | |
| 415 | my $cy2 = $y + 58; | |
| 416 | $bands .= qq~<text x="$cx" y="$cy2" text-anchor="middle" font-family="-apple-system,sans-serif" font-size="11" fill="white" opacity="0.85">${pct}%</text>~; | |
| 417 | } else { | |
| 418 | $bands .= qq~<text x="$cx" y="$cy" text-anchor="middle" font-family="-apple-system,sans-serif" font-size="14" font-weight="700" fill="white">$count</text>~; | |
| 419 | } | |
| 420 | my $lbl_x = $x + $bw + 18; | |
| 421 | my $lbl_y = $y + 36; | |
| 422 | if ($lbl_x + 180 > $W) { | |
| 423 | $lbl_x = $x - 18; | |
| 424 | $bands .= qq~<text x="$lbl_x" y="$lbl_y" text-anchor="end" font-family="-apple-system,sans-serif" font-size="13" font-weight="600" fill="#cfd6e0">$label</text>~; | |
| 425 | my $lbl_y2 = $y + 56; | |
| 426 | $bands .= qq~<text x="$lbl_x" y="$lbl_y2" text-anchor="end" font-family="-apple-system,sans-serif" font-size="11" fill="#7a8392">${pct}% of signups</text>~; | |
| 427 | } else { | |
| 428 | $bands .= qq~<text x="$lbl_x" y="$lbl_y" font-family="-apple-system,sans-serif" font-size="13" font-weight="600" fill="#cfd6e0">$label</text>~; | |
| 429 | my $lbl_y2 = $y + 56; | |
| 430 | $bands .= qq~<text x="$lbl_x" y="$lbl_y2" font-family="-apple-system,sans-serif" font-size="11" fill="#7a8392">${pct}% of signups</text>~; | |
| 431 | } | |
| 432 | $bands .= qq~</g>~; | |
| 433 | ||
| 434 | if ($i < $n - 1) { | |
| 435 | my $bw2 = $widths[$i+1]; | |
| 436 | my $x2 = int(($W - $bw2)/2); | |
| 437 | my $y2a = $y + $H_band; | |
| 438 | my $y2b = $y + $H_band + $H_gap; | |
| 439 | my $c = step_color($i, $n); | |
| 440 | $conn .= qq~<polygon points="$x,$y2a ${\ ($x+$bw) },$y2a ${\ ($x2+$bw2) },$y2b $x2,$y2b" fill="$c" opacity="0.18"/>~; | |
| 441 | my $drop_pct = $bars->[$i+1]->{drop_pct} || 0; | |
| 442 | if ($drop_pct > 0.5) { | |
| 443 | my $leak_w = int(80 + $drop_pct * 2.4); | |
| 444 | my $leak_h = int(8 + $drop_pct * 0.6); | |
| 445 | my $lx = $x + $bw + 8; | |
| 446 | my $ly = $y2a - int($H_gap/2); | |
| 447 | $bands .= qq~<rect x="$lx" y="$ly" rx="6" ry="6" width="$leak_w" height="$leak_h" fill="url(#afnlLeak)"/>~; | |
| 448 | my $tx = $lx + 6; | |
| 449 | my $ty = $ly + $leak_h + 14; | |
| 450 | $bands .= qq~<text x="$tx" y="$ty" font-family="-apple-system,sans-serif" font-size="11" font-weight="700" fill="#ff8a8a">-${drop_pct}% leak</text>~; | |
| 451 | } | |
| 452 | } | |
| 453 | } | |
| 454 | return qq~<div class="afnl-svg-stage"><svg viewBox="0 0 $W $H" class="afnl-svg" preserveAspectRatio="xMidYMid meet"><defs>$defs</defs>$conn$bands</svg></div>~; | |
| 455 | } | |
| 456 | ||
| 457 | sub afnl_hero { | |
| 458 | my ($title, $sub) = @_; | |
| 459 | my $t = h_esc($title); | |
| 460 | return qq~ | |
| 461 | <div class="afnl-hero"> | |
| 462 | <div class="afnl-hero-orb orb-a"></div> | |
| 463 | <div class="afnl-hero-orb orb-b"></div> | |
| 464 | <div class="afnl-hero-orb orb-c"></div> | |
| 465 | <div class="afnl-hero-grid"></div> | |
| 466 | <div class="afnl-hero-content"> | |
| 467 | <div class="afnl-hero-pill"> | |
| 468 | <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M3 4h18l-7 10v6l-4-2v-4z"/></svg> | |
| 469 | <span>Admin · Platform Funnel</span> | |
| 470 | </div> | |
| 471 | <h1 class="afnl-hero-title">$t</h1> | |
| 472 | <p class="afnl-hero-sub">$sub</p> | |
| 473 | </div> | |
| 474 | </div>~; | |
| 475 | } | |
| 476 | ||
| 477 | sub admin_top_domains_panel { | |
| 478 | my ($rows, $total_signups) = @_; | |
| 479 | return '' unless ref($rows) eq 'ARRAY' && scalar @$rows; | |
| 480 | ||
| 481 | my $max = 0; | |
| 482 | foreach my $r (@$rows) { $max = $r->{n} if $r->{n} > $max; } | |
| 483 | $max = 1 if $max < 1; | |
| 484 | ||
| 485 | my $cells = ''; | |
| 486 | my $rank = 0; | |
| 487 | foreach my $r (@$rows) { | |
| 488 | $rank++; | |
| 489 | my $dom_disp = h_esc($r->{domain}); | |
| 490 | my $cnt = fmt_num($r->{n}); | |
| 491 | my $bar_pct = int(100.0 * $r->{n} / $max + 0.5); | |
| 492 | my $share_pct = $total_signups ? sprintf('%.1f', 100.0 * $r->{n} / $total_signups) : 0; | |
| 493 | $cells .= qq~ | |
| 494 | <div class="afnl-dom-row" style="--pct:${bar_pct}%"> | |
| 495 | <div class="afnl-dom-rank">$rank</div> | |
| 496 | <div class="afnl-dom-name" title="$dom_disp">$dom_disp</div> | |
| 497 | <div class="afnl-dom-share">${share_pct}%</div> | |
| 498 | <div class="afnl-dom-cnt">$cnt</div> | |
| 499 | </div>~; | |
| 500 | } | |
| 501 | ||
| 502 | return qq~ | |
| 503 | <div class="afnl-dom-wrap"> | |
| 504 | <div class="afnl-dom-head"> | |
| 505 | <div> | |
| 506 | <div class="afnl-dom-title">Top Acquisition Channels</div> | |
| 507 | <div class="afnl-dom-sub">Where this cohort came from, by email domain. Spot company-domain clusters (sales-lead signal) vs free-mail (top-of-funnel).</div> | |
| 508 | </div> | |
| 509 | </div> | |
| 510 | <div class="afnl-dom-list">$cells</div> | |
| 511 | </div>~; | |
| 512 | } | |
| 513 | ||
| 514 | sub afnl_css { | |
| 515 | return <<'CSS'; | |
| 516 | <style> | |
| 517 | .afnl-hero.afnl-hero { | |
| 518 | position: relative; margin: -8px -8px 24px; padding: 68px 140px 72px 40px !important; | |
| 519 | border-radius: 22px; overflow: hidden; | |
| 520 | background: | |
| 521 | radial-gradient(900px 280px at 14% 0%, rgba(99,102,241,0.40), transparent 70%), | |
| 522 | radial-gradient(700px 240px at 88% 90%, rgba(139,92,246,0.44), transparent 70%), | |
| 523 | linear-gradient(180deg, #0d1219 0%, #060912 100%); | |
| 524 | border: 1px solid rgba(255,255,255,0.06); | |
| 525 | box-shadow: 0 28px 60px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.04); | |
| 526 | } | |
| 527 | .afnl-hero-grid { | |
| 528 | position: absolute; inset: 0; | |
| 529 | background-image: | |
| 530 | linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px), | |
| 531 | linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px); | |
| 532 | background-size: 36px 36px; | |
| 533 | mask-image: radial-gradient(ellipse at 50% 30%, black 0%, transparent 70%); | |
| 534 | pointer-events: none; | |
| 535 | } | |
| 536 | .afnl-hero-orb { position: absolute; border-radius: 50%; filter: blur(60px); opacity: 0.85; pointer-events: none; animation: afnlOrb 12s ease-in-out infinite; } | |
| 537 | .afnl-hero-orb.orb-a { width: 320px; height: 320px; left: -60px; top: -80px; background: radial-gradient(circle, #6366f1 0%, transparent 70%); } | |
| 538 | .afnl-hero-orb.orb-b { width: 380px; height: 380px; right: -100px; top: -40px; background: radial-gradient(circle, #8b5cf6 0%, transparent 70%); animation-delay: -3s; } | |
| 539 | .afnl-hero-orb.orb-c { width: 260px; height: 260px; left: 40%; bottom: -120px; background: radial-gradient(circle, #22d3ee 0%, transparent 70%); animation-delay: -7s; } | |
| 540 | @keyframes afnlOrb { 0%, 100% { transform: translate(0,0) scale(1); } 50% { transform: translate(20px,-16px) scale(1.05); } } | |
| 541 | .afnl-hero-content { position: relative; z-index: 1; max-width: 760px; } | |
| 542 | .afnl-hero-pill { display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px; background: rgba(99,102,241,0.16); border: 1px solid rgba(99,102,241,0.42); border-radius: 999px; color: #a5b4fc; font-size: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 24px; } | |
| 543 | .afnl-hero-title { margin: 0 0 20px; font-size: 44px; line-height: 1.05; font-weight: 800; color: #fff; letter-spacing: -0.02em; text-shadow: 0 4px 24px rgba(0,0,0,0.5); } | |
| 544 | .afnl-hero-sub { margin: 0; color: rgba(207,214,224,0.85); font-size: 17px; line-height: 1.55; } | |
| 545 | .afnl-hero-sub strong { color: #fff; } | |
| 546 | .afnl-toolbar { display: flex; justify-content: flex-start; margin: 0 0 18px; flex-wrap: wrap; gap: 12px; } | |
| 547 | .afnl-toolbar-form { display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap; } | |
| 548 | .afnl-field { display: inline-flex; flex-direction: column; gap: 4px; } | |
| 549 | .afnl-field-lbl { font-size: 11px; color: #6f7787; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; } | |
| 550 | .afnl-field select { background: #161c25; border: 1px solid rgba(255,255,255,0.08); color: #e8edf2; padding: 0 14px; border-radius: 10px; font-size: 14px; min-width: 160px; height: 34px; margin-top: 8px; box-sizing: border-box; } | |
| 551 | .afnl-toolbar-btn { display: inline-flex; align-items: center; gap: 8px; padding: 0 16px; height: 34px; margin-top: 8px; box-sizing: border-box; border-radius: 10px; font-size: 14px; font-weight: 600; text-decoration: none; transition: all 0.18s ease; background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%); color: #fff; border: none; box-shadow: 0 6px 18px rgba(99,102,241,0.32); } | |
| 552 | .afnl-toolbar-btn:hover { transform: translateY(-1px); box-shadow: 0 10px 24px rgba(99,102,241,0.46); } | |
| 553 | .afnl-toolbar-btn.ghost { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); color: #cfd6e0; box-shadow: none; } | |
| 554 | .afnl-toolbar-btn.ghost:hover { background: rgba(255,255,255,0.07); } | |
| 555 | .afnl-stats-grid { display: grid; gap: 16px; margin: 0 0 24px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); } | |
| 556 | .afnl-stat { position: relative; overflow: hidden; padding: 22px 40px; background: linear-gradient(160deg, rgba(255,255,255,0.04), rgba(255,255,255,0.01)); border: 1px solid rgba(255,255,255,0.06); border-radius: 16px; opacity: 0; transform: translateY(10px); transition: all 0.5s cubic-bezier(.4,0,.2,1); } | |
| 557 | .afnl-stat.lit { opacity: 1; transform: none; } | |
| 558 | .afnl-stat::after { content: ""; position: absolute; width: 160px; height: 160px; border-radius: 50%; filter: blur(40px); right: -40px; top: -40px; opacity: 0.5; pointer-events: none; } | |
| 559 | .afnl-stat.orb-orange::after { background: #f97316; } | |
| 560 | .afnl-stat.orb-pink::after { background: #ec4899; } | |
| 561 | .afnl-stat.orb-violet::after { background: #a855f7; } | |
| 562 | .afnl-stat.orb-cyan::after { background: #22d3ee; } | |
| 563 | .afnl-stat .lbl { font-size: 11px; color: #8893a4; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; } | |
| 564 | .afnl-stat .val { font-size: 32px; font-weight: 800; color: #fff; margin: 8px 0 6px; line-height: 1; position: relative; } | |
| 565 | .afnl-stat .val.grad-text { background: linear-gradient(90deg, #f97316, #ec4899, #a855f7); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; } | |
| 566 | .afnl-stat .sub { font-size: 12px; color: #7a8392; position: relative; } | |
| 567 | .afnl-viz-wrap { position: relative; padding: 24px 40px 20px; background: linear-gradient(180deg, #0f1520 0%, #0a0f17 100%); border: 1px solid rgba(255,255,255,0.06); border-radius: 18px; overflow: hidden; margin: 0 0 22px; } | |
| 568 | .afnl-viz-glow { position: absolute; inset: 0; background: radial-gradient(700px 200px at 20% 0%, rgba(249,115,22,0.16), transparent 70%), radial-gradient(600px 200px at 80% 100%, rgba(168,85,247,0.16), transparent 70%); pointer-events: none; } | |
| 569 | .afnl-viz-head { position: relative; margin-bottom: 14px; } | |
| 570 | .afnl-viz-title { font-size: 18px; font-weight: 700; color: #fff; } | |
| 571 | .afnl-viz-sub { font-size: 12px; color: #8893a4; margin-top: 4px; } | |
| 572 | .afnl-svg-stage { position: relative; padding: 6px 0 0; } | |
| 573 | .afnl-svg { width: 100%; height: auto; max-height: 720px; display: block; } | |
| 574 | .fnl-band { opacity: 0; transform: scaleX(0.6); transform-origin: 50% 50%; transition: opacity 0.55s ease, transform 0.7s cubic-bezier(.4,0,.2,1); cursor: pointer; } | |
| 575 | .fnl-band.lit { opacity: 1; transform: scaleX(1); } | |
| 576 | .fnl-band:hover { filter: brightness(1.12) drop-shadow(0 0 14px rgba(249,115,22,0.5)); } | |
| 577 | /* Top Acquisition Channels (admin) */ | |
| 578 | .afnl-dom-wrap { position: relative; padding: 22px 40px 18px; background: linear-gradient(160deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01)); border: 1px solid rgba(255,255,255,0.06); border-radius: 16px; margin: 0 0 18px; overflow: hidden; } | |
| 579 | .afnl-dom-wrap::before { content: ""; position: absolute; inset: 0; background: radial-gradient(500px 200px at 100% 0%, rgba(34,211,238,0.10), transparent 60%), radial-gradient(500px 200px at 0% 100%, rgba(168,85,247,0.10), transparent 60%); pointer-events: none; } | |
| 580 | .afnl-dom-head { position: relative; margin-bottom: 16px; max-width: 720px; } | |
| 581 | .afnl-dom-title { font-size: 17px; font-weight: 700; color: #fff; } | |
| 582 | .afnl-dom-sub { font-size: 12px; color: #8893a4; margin-top: 4px; line-height: 1.55; } | |
| 583 | .afnl-dom-list { position: relative; display: flex; flex-direction: column; gap: 4px; } | |
| 584 | .afnl-dom-row { position: relative; display: grid; grid-template-columns: 24px 1fr 60px 60px; align-items: center; gap: 10px; padding: 8px 12px; background: rgba(255,255,255,0.02); border-radius: 8px; font-size: 13px; overflow: hidden; } | |
| 585 | .afnl-dom-row::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: var(--pct, 0%); background: linear-gradient(90deg, rgba(34,211,238,0.18) 0%, rgba(168,85,247,0.06) 100%); border-radius: 8px 0 0 8px; z-index: 0; transition: width 0.5s cubic-bezier(.4,0,.2,1); } | |
| 586 | .afnl-dom-row > * { position: relative; z-index: 1; } | |
| 587 | .afnl-dom-rank { color: #6f7787; font-weight: 700; font-size: 11px; text-align: center; } | |
| 588 | .afnl-dom-name { color: #cfd6e0; font-family: 'SF Mono', Menlo, monospace; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 589 | .afnl-dom-share { color: #8893a4; font-size: 11px; text-align: right; font-weight: 600; } | |
| 590 | .afnl-dom-cnt { color: #fff; font-weight: 700; font-size: 13px; text-align: right; } | |
| 591 | ||
| 592 | .afnl-spark-card { position: relative; padding: 22px 40px 18px; background: linear-gradient(160deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01)); border: 1px solid rgba(255,255,255,0.06); border-radius: 16px; margin-bottom: 22px; } | |
| 593 | .afnl-spark-title { font-size: 15px; font-weight: 700; color: #fff; } | |
| 594 | .afnl-spark-sub { font-size: 12px; color: #7a8392; margin-top: 3px; } | |
| 595 | .afnl-spark-wrap { display: flex; align-items: flex-end; gap: 4px; height: 80px; margin-top: 14px; } | |
| 596 | .afnl-spark-col { flex: 1; min-width: 6px; display: flex; align-items: flex-end; } | |
| 597 | .afnl-spark-bar { width: 100%; background: linear-gradient(180deg, #f97316 0%, #ec4899 100%); border-radius: 3px 3px 0 0; min-height: 2px; box-shadow: 0 -2px 8px rgba(249,115,22,0.4); transition: all 0.18s; } | |
| 598 | .afnl-spark-col:hover .afnl-spark-bar { background: linear-gradient(180deg, #fb9c4d 0%, #f072ad 100%); } | |
| 599 | .afnl-step-grid { display: grid; gap: 10px; } | |
| 600 | .afnl-step { display: flex; gap: 16px; align-items: center; padding: 14px 18px; background: linear-gradient(160deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01)); border: 1px solid rgba(255,255,255,0.06); border-left: 3px solid var(--step-color, #f97316); border-radius: 12px; opacity: 0; transform: translateX(-10px); transition: all 0.5s cubic-bezier(.4,0,.2,1); } | |
| 601 | .afnl-step.lit { opacity: 1; transform: none; } | |
| 602 | .afnl-step-num { width: 34px; height: 34px; border-radius: 9px; background: var(--step-color, #f97316); color: #0a0f17; font-weight: 800; font-size: 16px; display: flex; align-items: center; justify-content: center; box-shadow: 0 6px 16px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.3); flex-shrink: 0; } | |
| 603 | .afnl-step-info { flex: 1; } | |
| 604 | .afnl-step-label { font-size: 15px; font-weight: 700; color: #fff; } | |
| 605 | .afnl-step-meta { font-size: 12px; color: #8893a4; margin-top: 2px; } | |
| 606 | .afnl-step-drop { text-align: right; padding: 6px 12px; border-radius: 8px; min-width: 76px; } | |
| 607 | .afnl-step-drop .lbl { font-size: 9px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.8; } | |
| 608 | .afnl-step-drop .val { font-size: 17px; font-weight: 800; line-height: 1; margin-top: 2px; } | |
| 609 | .afnl-step-drop.high { background: rgba(239,68,68,0.14); color: #ff8a8a; } | |
| 610 | .afnl-step-drop.mid { background: rgba(234,179,8,0.12); color: #fbbf24; } | |
| 611 | .afnl-step-drop.low { background: rgba(34,197,94,0.10); color: #4ade80; } | |
| 612 | .afnl-empty { text-align: center; padding: 60px 40px; color: #8893a4; } | |
| 613 | ||
| 614 | /* --- Top Acquisition Paths (4-column dimensional view) --- */ | |
| 615 | .afnpp-wrap { padding: 22px 40px 18px; background: linear-gradient(160deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01)); border: 1px solid rgba(255,255,255,0.06); border-radius: 16px; margin: 0 0 22px; } | |
| 616 | .afnpp-head { margin-bottom: 14px; } | |
| 617 | .afnpp-title { font-size: 18px; font-weight: 700; color: #fff; } | |
| 618 | .afnpp-sub { font-size: 12px; color: #8893a4; margin-top: 4px; } | |
| 619 | .afnpp-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; } | |
| 620 | .afnpp-col { background: rgba(255,255,255,0.02); border: 1px solid hsla(var(--col-hue,220), 60%, 45%, 0.55); border-radius: 12px; padding: 14px; box-shadow: 0 0 24px hsla(var(--col-hue,220), 80%, 45%, 0.14); } | |
| 621 | .afnpp-col-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 10px; } | |
| 622 | .afnpp-col-label { font-weight: 700; color: #fff; font-size: 13px; } | |
| 623 | .afnpp-col-sub { font-size: 10px; color: #7a8499; text-transform: uppercase; letter-spacing: 0.08em; font-weight: 700; } | |
| 624 | .afnpp-row { position: relative; display: grid; grid-template-columns: 20px 1fr auto; align-items: center; gap: 10px; padding: 9px 12px; border-radius: 8px; background: rgba(0,0,0,0.28); overflow: hidden; margin-bottom: 6px; cursor: default; } | |
| 625 | .afnpp-bar { position: absolute; inset: 0; z-index: 0; } | |
| 626 | .afnpp-bar span { display: block; height: 100%; width: var(--pct,0%); background: linear-gradient(90deg, hsla(var(--col-hue,220), 80%, 45%, 0.42), hsla(var(--col-hue,220), 80%, 45%, 0.08)); } | |
| 627 | .afnpp-rank { position: relative; z-index: 1; color: #7a8499; font-size: 11px; font-weight: 700; } | |
| 628 | .afnpp-val { position: relative; z-index: 1; color: #e8edf2; font-family: 'JetBrains Mono','Consolas',monospace; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 629 | .afnpp-cnt { position: relative; z-index: 1; color: #fff; font-weight: 700; font-size: 14px; } | |
| 630 | @media (max-width: 900px) { .afnpp-grid { grid-template-columns: 1fr 1fr; } } | |
| 631 | </style> | |
| 632 | CSS | |
| 633 | } | |
| 634 | ||
| 635 | ||
| 636 | # 4-column "Top Acquisition Paths" panel (referrer / utm source / utm campaign / landing page). | |
| 637 | # Ported from ABForge customer /funnels.cgi so every site's admin funnel has parity. | |
| 638 | # Uses anon_sessions columns present on all 7 sister-site schemas. | |
| 639 | sub afnpp_render_panel { | |
| 640 | my ($db, $dbh, $DB, $days) = @_; | |
| 641 | $days = int($days || 30); | |
| 642 | $days = 730 if $days > 730; | |
| 643 | $days = 1 if $days < 1; | |
| 644 | my $window = "DATE_SUB(NOW(), INTERVAL $days DAY)"; | |
| 645 | my @dims = ( | |
| 646 | { key=>'referer_host', label=>'Referrer', fb=>'(direct)' }, | |
| 647 | { key=>'utm_source', label=>'UTM source', fb=>'(none)' }, | |
| 648 | { key=>'utm_campaign', label=>'UTM campaign', fb=>'(none)' }, | |
| 649 | { key=>'landing_path', label=>'Landing page', fb=>'/' }, | |
| 650 | ); | |
| 651 | my $cols_html = ''; | |
| 652 | my $grand_total = 0; | |
| 653 | my $ci = 0; | |
| 654 | foreach my $d (@dims) { | |
| 655 | $ci++; | |
| 656 | my $col = $d->{key}; | |
| 657 | my $fb = $d->{fb}; | |
| 658 | my $fb_esc = $fb; $fb_esc =~ s/'/''/g; | |
| 659 | my @rows = eval { $db->db_readwrite_multiple($dbh, qq~ | |
| 660 | SELECT IFNULL(NULLIF($col,''), '$fb_esc') AS val, COUNT(*) AS n | |
| 661 | FROM `${DB}`.anon_sessions | |
| 662 | WHERE first_seen_at >= $window | |
| 663 | GROUP BY IFNULL(NULLIF($col,''), '$fb_esc') | |
| 664 | ORDER BY n DESC | |
| 665 | LIMIT 3 | |
| 666 | ~, $ENV{SCRIPT_NAME}, __LINE__) }; | |
| 667 | @rows = () unless @rows; | |
| 668 | my $max = 0; | |
| 669 | foreach my $r (@rows) { $max = $r->{n} if $r->{n} > $max; $grand_total += $r->{n}; } | |
| 670 | $max = 1 if $max < 1; | |
| 671 | my $cells = ''; | |
| 672 | my $rk = 0; | |
| 673 | foreach my $r (@rows) { | |
| 674 | $rk++; | |
| 675 | my $v = defined $r->{val} ? $r->{val} : $fb; | |
| 676 | $v =~ s/&/&/g; $v =~ s/</</g; $v =~ s/>/>/g; $v =~ s/"/"/g; | |
| 677 | my $n = $r->{n} || 0; | |
| 678 | my $pct = int(100 * ($r->{n} || 0) / $max + 0.5); | |
| 679 | $cells .= qq~<div class="afnpp-row" style="--pct:${pct}%"><div class="afnpp-rank">$rk</div><div class="afnpp-bar"><span></span></div><div class="afnpp-val" title="$v">$v</div><div class="afnpp-cnt">$n</div></div>~; | |
| 680 | } | |
| 681 | my $hue = 239; # brand hue (WebSTLs indigo) | |
| 682 | my $nn = scalar(@rows); | |
| 683 | $cells = '<div class="afnpp-row" style="--pct:0%"><div class="afnpp-rank">—</div><div class="afnpp-bar"><span></span></div><div class="afnpp-val">No data yet</div><div class="afnpp-cnt">0</div></div>' unless $nn; | |
| 684 | $cols_html .= qq~<div class="afnpp-col" style="--col-hue:$hue"><div class="afnpp-col-head"><div class="afnpp-col-label">$d->{label}</div><div class="afnpp-col-sub">Top $nn</div></div>$cells</div>~; | |
| 685 | } | |
| 686 | return qq~<div class="afnpp-wrap"><div class="afnpp-head"><div class="afnpp-title">Top Acquisition Paths</div><div class="afnpp-sub">Where anonymous visitors came from before signing up. Referrer host, UTM tags, and landing page, top 3 each.</div></div><div class="afnpp-grid">$cols_html</div></div>~; | |
| 687 | } | |
| 688 | ||
| 689 | ||
| 690 | # Discovered/managed funnels widget. Shows a list of funnels from the | |
| 691 | # funnels table (auto + user) with per-row Hide + a Show-Hidden toggle | |
| 692 | # above. Reads ?hide=N / ?unhide=N on the SAME script for state changes | |
| 693 | # so no separate action handler is required. | |
| 694 | sub _afnpp_funnels_widget { | |
| 695 | my ($db, $dbh, $DB, $q) = @_; | |
| 696 | ||
| 697 | # Handle hide/unhide via the SAME URL, then let the caller redirect | |
| 698 | # or just fall through and re-render. | |
| 699 | my $hide_id = int($q->param('hide') || 0); | |
| 700 | my $unhide_id = int($q->param('unhide') || 0); | |
| 701 | if ($hide_id) { | |
| 702 | $db->db_readwrite($dbh, qq~UPDATE `${DB}`.funnels SET is_hidden=1 WHERE id='$hide_id'~, | |
| 703 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 704 | } | |
| 705 | if ($unhide_id) { | |
| 706 | $db->db_readwrite($dbh, qq~UPDATE `${DB}`.funnels SET is_hidden=0 WHERE id='$unhide_id'~, | |
| 707 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 708 | } | |
| 709 | ||
| 710 | my $show_hidden = $q->param('show_hidden') ? 1 : 0; | |
| 711 | ||
| 712 | my @visible = $db->db_readwrite_multiple($dbh, qq~ | |
| 713 | SELECT id, name, source, is_hidden, steps | |
| 714 | FROM `${DB}`.funnels | |
| 715 | WHERE is_active=1 AND (is_hidden=0 OR is_hidden IS NULL) | |
| 716 | ORDER BY (source='auto') ASC, created_at DESC | |
| 717 | LIMIT 40 | |
| 718 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 719 | ||
| 720 | my $hidden_n = 0; | |
| 721 | my @hidden; | |
| 722 | if ($show_hidden) { | |
| 723 | @hidden = $db->db_readwrite_multiple($dbh, qq~ | |
| 724 | SELECT id, name, source, is_hidden, steps | |
| 725 | FROM `${DB}`.funnels | |
| 726 | WHERE is_active=1 AND is_hidden=1 | |
| 727 | ORDER BY (source='auto') ASC, created_at DESC | |
| 728 | LIMIT 40 | |
| 729 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 730 | $hidden_n = scalar @hidden; | |
| 731 | } else { | |
| 732 | my $r = $db->db_readwrite($dbh, qq~ | |
| 733 | SELECT COUNT(*) AS n FROM `${DB}`.funnels WHERE is_active=1 AND is_hidden=1 | |
| 734 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 735 | $hidden_n = ($r && $r->{n}) ? int($r->{n}) : 0; | |
| 736 | } | |
| 737 | ||
| 738 | return '' unless scalar(@visible) || $hidden_n; | |
| 739 | ||
| 740 | my $row_html = ''; | |
| 741 | my $row_of = sub { | |
| 742 | my ($f, $is_hidden) = @_; | |
| 743 | my $name = $f->{name} // 'Untitled'; | |
| 744 | $name =~ s/&/&/g; $name =~ s/</</g; $name =~ s/>/>/g; | |
| 745 | my $badge = ''; | |
| 746 | if (($f->{source} || '') eq 'auto') { | |
| 747 | $badge = qq~<span class="afnpp-fpick-badge">AUTO</span>~; | |
| 748 | } | |
| 749 | # Show first + last step labels as breadcrumb | |
| 750 | my $trail = ''; | |
| 751 | if ($f->{steps} && $f->{steps} =~ /"label":"([^"]+)"/) { | |
| 752 | my $first = $1; | |
| 753 | my $last = $first; | |
| 754 | while ($f->{steps} =~ /"label":"([^"]+)"/g) { $last = $1; } | |
| 755 | $trail = ($first eq $last) ? $first : "$first → $last"; | |
| 756 | } | |
| 757 | my $action = $is_hidden | |
| 758 | ? qq~<a class="afnpp-fpick-unhide" href="?unhide=$f->{id}&show_hidden=1">Unhide</a>~ | |
| 759 | : qq~<a class="afnpp-fpick-hide" href="?hide=$f->{id}" title="Hide this funnel"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.06 10.06 0 0 1 12 20c-7 0-11-8-11-8a19.79 19.79 0 0 1 4.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a19.7 19.7 0 0 1-3.36 4.36"/><line x1="1" y1="1" x2="23" y2="23"/></svg></a>~; | |
| 760 | return qq~<div class="afnpp-fpick-row"><div class="afnpp-fpick-name">$name $badge</div><div class="afnpp-fpick-trail">$trail</div>$action</div>~; | |
| 761 | }; | |
| 762 | foreach my $f (@visible) { $row_html .= $row_of->($f, 0); } | |
| 763 | ||
| 764 | my $hidden_html = ''; | |
| 765 | if ($show_hidden && scalar @hidden) { | |
| 766 | my $hr = ''; | |
| 767 | foreach my $f (@hidden) { $hr .= $row_of->($f, 1); } | |
| 768 | $hidden_html = qq~<div class="afnpp-fpick-hidden"><div class="afnpp-fpick-hidden-head">Hidden funnels <a href="?">hide list</a></div>$hr</div>~; | |
| 769 | } | |
| 770 | ||
| 771 | my $chip = ''; | |
| 772 | if (!$show_hidden && $hidden_n > 0) { | |
| 773 | $chip = qq~<a class="afnpp-fpick-chip" href="?show_hidden=1">Show $hidden_n hidden</a>~; | |
| 774 | } | |
| 775 | ||
| 776 | return qq~ | |
| 777 | <div class="afnpp-fpick-wrap"> | |
| 778 | <div class="afnpp-fpick-head"> | |
| 779 | <div> | |
| 780 | <div class="afnpp-fpick-title">Discovered & saved funnels</div> | |
| 781 | <div class="afnpp-fpick-sub">Auto-discovered from your traffic (background hourly). Hide the ones you don’t care about; bring them back any time.</div> | |
| 782 | </div> | |
| 783 | $chip | |
| 784 | </div> | |
| 785 | <div class="afnpp-fpick-list">$row_html</div> | |
| 786 | $hidden_html | |
| 787 | </div> | |
| 788 | <style> | |
| 789 | .afnpp-fpick-wrap { padding: 20px 40px 18px; background: linear-gradient(160deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01)); border: 1px solid rgba(255,255,255,0.06); border-radius: 16px; margin: 0 0 22px; } | |
| 790 | .afnpp-fpick-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; gap: 12px; } | |
| 791 | .afnpp-fpick-title { font-size: 17px; font-weight: 700; color: #fff; } | |
| 792 | .afnpp-fpick-sub { font-size: 12px; color: #8893a4; margin-top: 4px; } | |
| 793 | .afnpp-fpick-list { display: flex; flex-direction: column; gap: 6px; } | |
| 794 | .afnpp-fpick-row { display: grid; grid-template-columns: 1fr 1fr 40px; align-items: center; gap: 14px; padding: 10px 14px; border-radius: 8px; background: rgba(0,0,0,0.25); border: 1px solid rgba(255,255,255,0.04); } | |
| 795 | .afnpp-fpick-name { color: #e8edf2; font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 796 | .afnpp-fpick-trail { color: #7a8499; font-family: 'JetBrains Mono','Consolas',monospace; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 797 | .afnpp-fpick-badge { background: rgba(249,115,22,0.18); color: #ffae6e; border: 1px solid rgba(249,115,22,0.42); padding: 1px 6px; font-size: 9px; font-weight: 700; border-radius: 999px; letter-spacing: 0.06em; margin-left: 4px; vertical-align: middle; } | |
| 798 | .afnpp-fpick-hide { color: #7a8499; display: inline-flex; align-items: center; justify-content: center; padding: 4px; border-radius: 6px; } | |
| 799 | .afnpp-fpick-hide:hover { background: rgba(255,255,255,0.08); color: #fff; } | |
| 800 | .afnpp-fpick-unhide { background: rgba(34,197,94,0.15); color: #86efac; border: 1px solid rgba(34,197,94,0.35); padding: 4px 12px; border-radius: 6px; font-size: 12px; font-weight: 700; text-decoration: none; } | |
| 801 | .afnpp-fpick-unhide:hover { background: rgba(34,197,94,0.28); color: #fff; } | |
| 802 | .afnpp-fpick-chip { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; background: rgba(148,163,184,0.10); color: #cbd5e1; border: 1px solid rgba(148,163,184,0.25); border-radius: 999px; font-size: 11px; font-weight: 700; text-decoration: none; letter-spacing: 0.05em; text-transform: uppercase; white-space: nowrap; } | |
| 803 | .afnpp-fpick-chip:hover { background: rgba(148,163,184,0.18); color: #fff; } | |
| 804 | .afnpp-fpick-hidden { margin-top: 14px; padding-top: 12px; border-top: 1px dashed rgba(148,163,184,0.20); } | |
| 805 | .afnpp-fpick-hidden-head { font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; } | |
| 806 | .afnpp-fpick-hidden-head a { color: #94a3b8; text-decoration: underline; font-weight: 500; text-transform: none; letter-spacing: normal; margin-left: 8px; } | |
| 807 | </style>~; | |
| 808 | } |