Diff -- /var/www/vhosts/3dshawn.com/shop.3dshawn.com/admin_funnels.cgi
Diff
/var/www/vhosts/3dshawn.com/shop.3dshawn.com/admin_funnels.cgi
added on local at 2026-07-09 23:20:47
Added
+1242
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 2145a9487c06
to 2145a9487c06
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 | # TaskForge - 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/3dshawn.com/shop.3dshawn.com'; | |
| 16 | use CGI; | |
| 17 | use MODS::DBConnect; | |
| 18 | use MODS::Login; | |
| 19 | use MODS::ShopCart::Config; | |
| 20 | use MODS::ShopCart::Wrapper; | |
| 21 | use MODS::AnonPaths; | |
| 22 | ||
| 23 | my $q = CGI->new; | |
| 24 | my $auth = MODS::Login->new; | |
| 25 | my $wrap = MODS::ShopCart::Wrapper->new; | |
| 26 | my $db = MODS::DBConnect->new; | |
| 27 | my $cfg = MODS::ShopCart::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 %qparams = %{ $q->Vars }; | |
| 41 | my ($range_opts, $preset_val, $range_from, $range_to) = MODS::AnonPaths::parse_range_params(\%qparams); | |
| 42 | my $days = (ref($range_opts) eq 'HASH' && $range_opts->{days}) ? $range_opts->{days} : 30; | |
| 43 | $days = 7 if $days < 1; | |
| 44 | $days = 365 if $days > 365; | |
| 45 | ||
| 46 | my $dbh = $db->db_connect(); | |
| 47 | ||
| 48 | # ----- External visitor funnel (modal page-path journey) ------------- | |
| 49 | # Pivoted 2026-06-13 to align with the Top Acquisition Paths panel. | |
| 50 | # Upgraded 2026-06-13b: stages are now real page paths from the most- | |
| 51 | # common journey, with rich hover popovers per stage. | |
| 52 | my $is_range_mode = ($range_opts->{from} && $range_opts->{to}) ? 1 : 0; | |
| 53 | my $range_from_sql = $is_range_mode ? "'$range_opts->{from} 00:00:00'" : ''; | |
| 54 | my $range_to_sql = $is_range_mode ? "'$range_opts->{to} 23:59:59'" : ''; | |
| 55 | # date_clause(column) -> WHERE fragment that covers the current window | |
| 56 | my $date_clause = sub { | |
| 57 | my ($col) = @_; | |
| 58 | return $is_range_mode | |
| 59 | ? "$col BETWEEN $range_from_sql AND $range_to_sql" | |
| 60 | : "$col >= DATE_SUB(NOW(), INTERVAL $days DAY)"; | |
| 61 | }; | |
| 62 | ||
| 63 | # Honor ?path=... (clicked row in Top Acquisition Paths) by passing the | |
| 64 | # selected path through so the big Platform funnel rebuilds against THIS | |
| 65 | # specific path's stages instead of the modal-journey ones. | |
| 66 | my $selected_path_early = $q->param('path') || ''; | |
| 67 | $selected_path_early =~ s/[\x00-\x1f]//g; | |
| 68 | $selected_path_early = substr($selected_path_early, 0, 1000); | |
| 69 | $range_opts->{selected_path} = $selected_path_early if length $selected_path_early; | |
| 70 | ||
| 71 | my $entered = 0; | |
| 72 | my @bars; | |
| 73 | ($entered, @bars) = MODS::AnonPaths::external_funnel_stages($db, $dbh, $range_opts); | |
| 74 | ||
| 75 | # Top biggest drop | |
| 76 | my $biggest = { step => '-', pct => 0 }; | |
| 77 | for my $k (1 .. $#bars) { | |
| 78 | if ($bars[$k]->{drop_pct} > $biggest->{pct}) { | |
| 79 | $biggest = { step => $bars[$k]->{label}, pct => $bars[$k]->{drop_pct} }; | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | my $converted = $bars[-1]->{count}; | |
| 84 | my $conv_pct = $entered ? sprintf('%.1f', 100.0 * $converted / $entered) : 0; | |
| 85 | ||
| 86 | # Daily signups sparkline data (silenced, like safe_count) | |
| 87 | my @sparks; | |
| 88 | { | |
| 89 | local *OLDOUT2; | |
| 90 | open(OLDOUT2, '>&', \*STDOUT) or do {}; | |
| 91 | open(STDOUT, '>', '/dev/null') or do {}; | |
| 92 | my $clause = $date_clause->('created_at'); | |
| 93 | @sparks = eval { $db->db_readwrite_multiple($dbh, qq~ | |
| 94 | SELECT DATE(created_at) AS d, COUNT(*) AS n | |
| 95 | FROM `${DB}`.users | |
| 96 | WHERE $clause | |
| 97 | GROUP BY DATE(created_at) | |
| 98 | ORDER BY d ASC | |
| 99 | ~, $ENV{SCRIPT_NAME}, __LINE__) }; | |
| 100 | @sparks = () unless @sparks; | |
| 101 | open(STDOUT, '>&', \*OLDOUT2) or do {}; | |
| 102 | } | |
| 103 | ||
| 104 | # Top acquisition PATHS: ordered page sequences taken by anonymous visitors | |
| 105 | # (pre-signup only — logged-in pages are never tracked). Each session is | |
| 106 | # rolled up into a single "/foo > /bar > /signup.cgi" string; we group by | |
| 107 | # that string and count occurrences plus end-state (signed_up vs abandoned). | |
| 108 | my $arrow = chr(0xE2) . chr(0x86) . chr(0x92); # → as UTF-8 bytes | |
| 109 | my @top_paths; | |
| 110 | { | |
| 111 | local *OLDOUT3; | |
| 112 | open(OLDOUT3, '>&', \*STDOUT) or do {}; | |
| 113 | open(STDOUT, '>', '/dev/null') or do {}; | |
| 114 | my $clause = $date_clause->('s.first_seen_at'); | |
| 115 | @top_paths = eval { $db->db_readwrite_multiple($dbh, qq~ | |
| 116 | SELECT path_sequence, | |
| 117 | COUNT(*) AS n, | |
| 118 | SUM(CASE WHEN end_reason='signed_up' THEN 1 ELSE 0 END) AS signups, | |
| 119 | SUM(CASE | |
| 120 | WHEN end_reason IN ('abandoned','bounced') THEN 1 | |
| 121 | WHEN end_reason='active' AND last_seen_at < DATE_SUB(NOW(), INTERVAL 30 MINUTE) THEN 1 | |
| 122 | ELSE 0 | |
| 123 | END) AS abandons | |
| 124 | FROM ( | |
| 125 | SELECT s.id, s.end_reason, s.last_seen_at, | |
| 126 | GROUP_CONCAT(p.page_path ORDER BY p.sequence SEPARATOR ' $arrow ') AS path_sequence | |
| 127 | FROM `${DB}`.anon_sessions s | |
| 128 | JOIN `${DB}`.anon_pageviews p ON p.session_id=s.id | |
| 129 | WHERE $clause | |
| 130 | GROUP BY s.id | |
| 131 | ) AS sess | |
| 132 | WHERE path_sequence IS NOT NULL AND path_sequence != '' | |
| 133 | GROUP BY path_sequence | |
| 134 | ORDER BY n DESC | |
| 135 | LIMIT 10 | |
| 136 | ~, $ENV{SCRIPT_NAME}, __LINE__) }; | |
| 137 | @top_paths = () unless @top_paths; | |
| 138 | open(STDOUT, '>&', \*OLDOUT3) or do {}; | |
| 139 | } | |
| 140 | ||
| 141 | # Drill-in: if ?path=... is set, build a per-step funnel of that path. | |
| 142 | my $selected_path_raw = $q->param('path') || ''; | |
| 143 | $selected_path_raw =~ s/[\x00-\x1f]//g; | |
| 144 | $selected_path_raw = substr($selected_path_raw, 0, 1000); | |
| 145 | my @path_funnel_steps; | |
| 146 | my ($selected_total, $selected_signups, $selected_abandons) = (0, 0, 0); | |
| 147 | if (length $selected_path_raw) { | |
| 148 | my @steps = split /\s+\Q$arrow\E\s+/, $selected_path_raw; | |
| 149 | if (@steps) { | |
| 150 | # For each step prefix, count sessions whose path STARTS with that prefix. | |
| 151 | # We do this in Perl over a pulled-back set to keep SQL simple. | |
| 152 | my @all_sessions; | |
| 153 | { | |
| 154 | local *OLDOUT4; | |
| 155 | open(OLDOUT4, '>&', \*STDOUT) or do {}; | |
| 156 | open(STDOUT, '>', '/dev/null') or do {}; | |
| 157 | my $clause = $date_clause->('s.first_seen_at'); | |
| 158 | @all_sessions = eval { $db->db_readwrite_multiple($dbh, qq~ | |
| 159 | SELECT s.id, s.end_reason, s.last_seen_at, | |
| 160 | GROUP_CONCAT(p.page_path ORDER BY p.sequence SEPARATOR ' $arrow ') AS path_sequence | |
| 161 | FROM `${DB}`.anon_sessions s | |
| 162 | JOIN `${DB}`.anon_pageviews p ON p.session_id=s.id | |
| 163 | WHERE $clause | |
| 164 | GROUP BY s.id | |
| 165 | ~, $ENV{SCRIPT_NAME}, __LINE__) }; | |
| 166 | @all_sessions = () unless @all_sessions; | |
| 167 | open(STDOUT, '>&', \*OLDOUT4) or do {}; | |
| 168 | } | |
| 169 | # Compute totals + per-step counts | |
| 170 | for (my $i = 0; $i < @steps; $i++) { | |
| 171 | my $prefix = join(" $arrow ", @steps[0..$i]); | |
| 172 | my ($reached, $stop_here_abandon, $signup_here) = (0, 0, 0); | |
| 173 | foreach my $sess (@all_sessions) { | |
| 174 | my $seq = $sess->{path_sequence} // ''; | |
| 175 | # Reached this step if path starts with this prefix | |
| 176 | next unless index($seq, $prefix) == 0; | |
| 177 | $reached++; | |
| 178 | # If they stopped here (this prefix == full path) AND ended abandoned, mark abandon | |
| 179 | my $is_full_match = ($seq eq $prefix); | |
| 180 | my $is_abandon = | |
| 181 | $sess->{end_reason} eq 'abandoned' || $sess->{end_reason} eq 'bounced' || | |
| 182 | ($sess->{end_reason} eq 'active' && _epoch_is_stale($sess->{last_seen_at})); | |
| 183 | $stop_here_abandon++ if $is_full_match && $is_abandon; | |
| 184 | $signup_here++ if $is_full_match && $sess->{end_reason} eq 'signed_up'; | |
| 185 | } | |
| 186 | push @path_funnel_steps, { | |
| 187 | idx => $i, | |
| 188 | step => $steps[$i], | |
| 189 | reached => $reached, | |
| 190 | abandon => $stop_here_abandon, | |
| 191 | signup => $signup_here, | |
| 192 | }; | |
| 193 | } | |
| 194 | if (@path_funnel_steps) { | |
| 195 | $selected_total = $path_funnel_steps[0]->{reached}; | |
| 196 | $selected_signups = 0; | |
| 197 | $selected_abandons = 0; | |
| 198 | $selected_signups += $_->{signup} for @path_funnel_steps; | |
| 199 | $selected_abandons += $_->{abandon} for @path_funnel_steps; | |
| 200 | } | |
| 201 | } | |
| 202 | } | |
| 203 | ||
| 204 | $db->db_disconnect($dbh); | |
| 205 | ||
| 206 | sub _epoch_is_stale { | |
| 207 | my ($ts) = @_; | |
| 208 | return 1 unless $ts; # treat NULL last_seen as stale | |
| 209 | require Time::Local; | |
| 210 | if ($ts =~ /^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})/) { | |
| 211 | my $ep = eval { Time::Local::timelocal($6,$5,$4,$3,$2-1,$1-1900) } || 0; | |
| 212 | return ($ep > 0 && (time - $ep) > 1800) ? 1 : 0; | |
| 213 | } | |
| 214 | return 0; | |
| 215 | } | |
| 216 | ||
| 217 | # ----- Render -------------------------------------------------------- | |
| 218 | my $days_opts = join('', map { | |
| 219 | my $sel = ($_ == $days) ? 'selected' : ''; | |
| 220 | qq~<option value="$_" $sel>Last $_ days</option>~; | |
| 221 | } (7, 14, 30, 60, 90, 180)); | |
| 222 | ||
| 223 | my @stage_popovers = map { MODS::AnonPaths::render_stage_popover($_) } @bars; | |
| 224 | my $svg = render_svg_funnel(\@bars, \@stage_popovers); | |
| 225 | my $sparkbars = ''; | |
| 226 | my $max_spark = 1; | |
| 227 | foreach my $r (@sparks) { $max_spark = $r->{n} if $r->{n} > $max_spark; } | |
| 228 | foreach my $r (@sparks) { | |
| 229 | my $h = int(($r->{n} / $max_spark) * 100); | |
| 230 | $sparkbars .= qq~<div class="afnl-spark-col" title="$r->{d}: $r->{n} signups"><div class="afnl-spark-bar" style="height:${h}%"></div></div>~; | |
| 231 | } | |
| 232 | ||
| 233 | my $step_cards = ''; | |
| 234 | my $idx = 0; | |
| 235 | foreach my $b (@bars) { | |
| 236 | my $cnt = fmt_num($b->{count}); | |
| 237 | my $drop_class = ($b->{drop_pct} && $b->{drop_pct} > 40) ? 'high' : (($b->{drop_pct} && $b->{drop_pct} > 20) ? 'mid' : 'low'); | |
| 238 | my $color = step_color($idx, scalar @bars); | |
| 239 | $idx++; | |
| 240 | my $popover = MODS::AnonPaths::render_stage_popover($b); | |
| 241 | my $label_esc = h_esc($b->{label} // ''); | |
| 242 | my $hv = $b->{hover} || {}; | |
| 243 | my $top_ref_host = ($hv->{referrers} && @{$hv->{referrers}}) ? $hv->{referrers}->[0]->{host} : '(direct)'; | |
| 244 | my $top_ref_n = ($hv->{referrers} && @{$hv->{referrers}}) ? $hv->{referrers}->[0]->{n} : 0; | |
| 245 | my $top_utm_src = ($hv->{utm_sources} && @{$hv->{utm_sources}}) ? $hv->{utm_sources}->[0]->{src} : '(none)'; | |
| 246 | my $top_utm_n = ($hv->{utm_sources} && @{$hv->{utm_sources}}) ? $hv->{utm_sources}->[0]->{n} : 0; | |
| 247 | my $tref_esc = h_esc($top_ref_host); | |
| 248 | my $tutm_esc = h_esc($top_utm_src); | |
| 249 | $step_cards .= qq~ | |
| 250 | <div class="afnl-step ap-pop-host" style="--step-color:$color"> | |
| 251 | <div class="afnl-step-num">$idx</div> | |
| 252 | <div class="afnl-step-label">$label_esc</div> | |
| 253 | <div class="afnl-step-meta">$cnt · $b->{pct}%</div> | |
| 254 | <div class="afnl-step-src"> | |
| 255 | <span class="afnl-step-src-lbl">via</span> | |
| 256 | <span class="afnl-step-src-val" title="Top referring host">$tref_esc</span> | |
| 257 | <span class="afnl-step-src-sep">·</span> | |
| 258 | <span class="afnl-step-src-lbl">utm</span> | |
| 259 | <span class="afnl-step-src-val" title="Top UTM source">$tutm_esc</span> | |
| 260 | </div> | |
| 261 | <div class="afnl-step-drop $drop_class" title="Drop = the % of visitors who reached this stage but did not continue to the next"> | |
| 262 | <span class="val">$b->{drop_pct}%</span> | |
| 263 | </div> | |
| 264 | $popover | |
| 265 | </div>~; | |
| 266 | } | |
| 267 | ||
| 268 | my $css = afnl_css() . MODS::AnonPaths::popover_css(); | |
| 269 | my $hero = afnl_hero('Conversion Funnel', | |
| 270 | 'Where visitors leak between landing and signing up. <strong>Find the leak, plug the leak.</strong>'); | |
| 271 | my $range_controls = MODS::AnonPaths::render_range_controls($preset_val, $range_from, $range_to); | |
| 272 | ||
| 273 | my $entered_fmt = fmt_num($entered); | |
| 274 | my $converted_fmt = fmt_num($converted); | |
| 275 | my $biggest_pct = $biggest->{pct}; | |
| 276 | my $biggest_step = h_esc($biggest->{step}); | |
| 277 | ||
| 278 | # Build the Platform funnel section as a separate variable so the nested | |
| 279 | # qq{} string doesn't collide with the outer body's qq~ delimiter. | |
| 280 | my $is_drill_mode = length($selected_path_early) ? 1 : 0; | |
| 281 | my $drill_disp = h_esc($selected_path_early); | |
| 282 | my $funnel_title_lbl = $is_drill_mode | |
| 283 | ? qq{Drill-in: <span class="afnl-drill-path">$drill_disp</span>} | |
| 284 | : 'Platform funnel'; | |
| 285 | my $funnel_sub_lbl = $is_drill_mode | |
| 286 | ? qq{Showing the stage-by-stage funnel for this specific acquisition path. <a href="?days=$days" class="afnl-clear">← back to modal funnel</a>} | |
| 287 | : 'Every visitor walks down this funnel. Each band is the count of anonymous sessions at that stage. Click any row in Top Acquisition Paths above to drill into a specific path.'; | |
| 288 | my $funnel_section = qq{ | |
| 289 | <div class="afnl-viz-wrap"> | |
| 290 | <div class="afnl-viz-glow"></div> | |
| 291 | <div class="afnl-viz-head"> | |
| 292 | <div class="afnl-viz-title"> | |
| 293 | $funnel_title_lbl | |
| 294 | <span class="afnl-info" tabindex="0">? | |
| 295 | <span class="afnl-info-pop"> | |
| 296 | <strong>Stages</strong> are the pages visitors take in order. In modal mode the funnel uses the most-common journey; if you click a row in Top Acquisition Paths above, the funnel rebuilds against THAT path.<br><br> | |
| 297 | <strong>Leak</strong> means visitors who reached a stage but didn't continue to the next one. They either bounced off the page, browsed elsewhere on the site, or left without signing up.<br><br> | |
| 298 | <strong>Drop %</strong> is the share of visitors who leaked between two stages. Big drops are where you're losing people — that's where to focus copy / design fixes. | |
| 299 | </span> | |
| 300 | </span> | |
| 301 | </div> | |
| 302 | <div class="afnl-viz-sub">$funnel_sub_lbl</div> | |
| 303 | </div> | |
| 304 | $svg | |
| 305 | </div> | |
| 306 | }; | |
| 307 | ||
| 308 | my $body = qq~ | |
| 309 | $css | |
| 310 | $hero | |
| 311 | ||
| 312 | <div class="afnl-toolbar"> | |
| 313 | <form method="GET" action="/admin_funnels.cgi" class="afnl-toolbar-form"> | |
| 314 | $range_controls | |
| 315 | <a class="afnl-toolbar-btn" href="/admin_funnels.cgi"> | |
| 316 | <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> | |
| 317 | Edit steps | |
| 318 | </a> | |
| 319 | <a class="afnl-toolbar-btn ghost" href="/admin_funnels.cgi"> | |
| 320 | <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> | |
| 321 | All funnels | |
| 322 | </a> | |
| 323 | </form> | |
| 324 | </div> | |
| 325 | ||
| 326 | ${\ _afnpp_funnels_widget($db, $dbh, $DB, $q) } | |
| 327 | ${\ afnpp_render_panel($db, $dbh, $DB, $days) } | |
| 328 | ${\ admin_top_paths_panel(\@top_paths, $entered, $selected_path_raw, $arrow, $days) } | |
| 329 | ${\ admin_path_funnel_drilldown(\@path_funnel_steps, $selected_path_raw, $selected_total, $selected_signups, $selected_abandons, $arrow) } | |
| 330 | ||
| 331 | <div class="afnl-stats-grid"> | |
| 332 | <div class="afnl-stat orb-orange"> | |
| 333 | <div class="lbl">Visitors landed</div><div class="val">$entered_fmt</div> | |
| 334 | <div class="sub">Unique anonymous sessions in the last $days days</div> | |
| 335 | </div> | |
| 336 | <div class="afnl-stat orb-pink"> | |
| 337 | <div class="lbl">Signed up</div><div class="val">$converted_fmt</div> | |
| 338 | <div class="sub">Visitors who completed signup</div> | |
| 339 | </div> | |
| 340 | <div class="afnl-stat orb-violet"> | |
| 341 | <div class="lbl">Land → signup rate</div><div class="val grad-text">$conv_pct%</div> | |
| 342 | <div class="sub">Of every 100 visitors</div> | |
| 343 | </div> | |
| 344 | <div class="afnl-stat orb-cyan"> | |
| 345 | <div class="lbl">Biggest leak</div><div class="val">$biggest_pct%</div> | |
| 346 | <div class="sub">at <strong>$biggest_step</strong></div> | |
| 347 | </div> | |
| 348 | </div> | |
| 349 | ||
| 350 | $funnel_section | |
| 351 | ||
| 352 | <div class="afnl-step-grid"> | |
| 353 | <div class="afnl-step-header"> | |
| 354 | <div class="col-h" data-help="The order this stage appears in the funnel, top to bottom. Stage 1 is the first page visitors landed on. Each subsequent stage is the next page they hit on the way through. The final stage is always 'Signed up' — visitors who completed signup. Colors here also color the corresponding band in the funnel above.">#</div> | |
| 355 | <div class="col-h" data-help="The page path at this stage of the funnel. In Modal mode this is the single most-popular page visitors took at this depth. In Drill-in mode (after clicking a row in Top Acquisition Paths above) it's the exact page in the path you selected. Paths come from the anonymous pre-signup tracker (anon_pageviews) — once visitors sign in, their movements stop being tracked here for privacy.">Page</div> | |
| 356 | <div class="col-h" data-help="Two numbers separated by a dot: (1) Count = how many distinct anonymous sessions reached this stage in the chosen date window. (2) % = that count divided by the total number of visitors who landed on the site at all in the same window — your baseline. Stage 1 is always close to but not necessarily 100%, because some visitors entered through a different page than the modal-entry page.">Visitors</div> | |
| 357 | <div class="col-h" data-help="Where these visitors came from. 'via' is the top referrer host — the domain in the HTTP Referer header when they arrived (search engines, social, partner sites). (direct) means no referrer was sent — typed URL, bookmark, or app link. 'utm' is the top utm_source parameter on the URL — set by you on outbound campaign links (e.g. ?utm_source=newsletter). (none) means the URL didn't carry a UTM tag. Hover any band in the funnel above for the full top 3 of each.">Top source</div> | |
| 358 | <div class="col-h afnl-step-h-drop" data-help="The percentage of visitors at THIS stage who did NOT make it to the next stage. Calculated as (this_stage_count - next_stage_count) / this_stage_count * 100. They either bounced, browsed elsewhere, or left without signing up. The red leak chip on the funnel above shows the absolute number of visitors lost between two stages — pair them: high Drop + lots lost = the leak worth plugging first. Stage colors: green ≤ 20%, amber 20–40%, red > 40%.">Drop</div> | |
| 359 | </div> | |
| 360 | $step_cards | |
| 361 | </div> | |
| 362 | ||
| 363 | <script> | |
| 364 | window.addEventListener('DOMContentLoaded', function(){ | |
| 365 | document.querySelectorAll('.fnl-band').forEach(function(el, i){ | |
| 366 | setTimeout(function(){ el.classList.add('lit'); }, 100 + i * 110); | |
| 367 | }); | |
| 368 | document.querySelectorAll('.afnl-stat').forEach(function(el, i){ | |
| 369 | setTimeout(function(){ el.classList.add('lit'); }, 60 + i * 80); | |
| 370 | }); | |
| 371 | document.querySelectorAll('.afnl-step').forEach(function(el, i){ | |
| 372 | setTimeout(function(){ el.classList.add('lit'); }, 320 + i * 70); | |
| 373 | }); | |
| 374 | ||
| 375 | /* ---- Cursor-follow popovers ---- | |
| 376 | For every popover host (.ap-pop-host / .afnl-hit), reposition the | |
| 377 | inner .ap-pop to follow the mouse, anchored just to the right of | |
| 378 | the cursor. Clamped to viewport. Falls back to the CSS-positioned | |
| 379 | popover if JS fails. */ | |
| 380 | var FOLLOW_OFFSET = 18; | |
| 381 | document.addEventListener('mousemove', function(e){ | |
| 382 | /* Popovers are pointer-events:none so the cursor passes through them. | |
| 383 | Whatever's under the popover catches the event, so the popover | |
| 384 | still tracks the cursor even when overlapping. */ | |
| 385 | var host = e.target.closest && e.target.closest('.ap-pop-host, .afnl-hit'); | |
| 386 | if (!host) return; | |
| 387 | var pop = host.querySelector(':scope > .ap-pop'); | |
| 388 | if (!pop) return; | |
| 389 | /* Wait one frame so the popover has dimensions to read */ | |
| 390 | if (pop.offsetWidth === 0) { | |
| 391 | requestAnimationFrame(function(){ positionPop(pop, e.clientX, e.clientY); }); | |
| 392 | } else { | |
| 393 | positionPop(pop, e.clientX, e.clientY); | |
| 394 | } | |
| 395 | }, { passive: true }); | |
| 396 | ||
| 397 | function positionPop(pop, cx, cy){ | |
| 398 | var pw = pop.offsetWidth, ph = pop.offsetHeight; | |
| 399 | var vw = window.innerWidth, vh = window.innerHeight; | |
| 400 | var x = cx + FOLLOW_OFFSET; | |
| 401 | var y = cy - ph / 2; | |
| 402 | /* If would overflow right, flip to left of cursor */ | |
| 403 | if (x + pw > vw - 8) x = cx - pw - FOLLOW_OFFSET; | |
| 404 | if (x < 8) x = 8; | |
| 405 | if (y < 8) y = 8; | |
| 406 | if (y + ph > vh - 8) y = vh - ph - 8; | |
| 407 | pop.style.position = 'fixed'; | |
| 408 | pop.style.left = x + 'px'; | |
| 409 | pop.style.top = y + 'px'; | |
| 410 | pop.style.transform = 'none'; | |
| 411 | } | |
| 412 | }); | |
| 413 | </script> | |
| 414 | ~; | |
| 415 | ||
| 416 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n"; | |
| 417 | $wrap->render({ | |
| 418 | userinfo => $userinfo, | |
| 419 | page_key => 'admin_funnels', | |
| 420 | title => 'Conversion Funnel', | |
| 421 | body => $body, | |
| 422 | }); | |
| 423 | ||
| 424 | #====================================================================== | |
| 425 | # Helpers | |
| 426 | #====================================================================== | |
| 427 | sub safe_count { | |
| 428 | my ($db, $dbh, $sql) = @_; | |
| 429 | # DBConnect prints DB errors to STDOUT, which would corrupt the CGI | |
| 430 | # header stream and 500 the page. Capture+discard during the query. | |
| 431 | local *OLDOUT; | |
| 432 | open(OLDOUT, '>&', \*STDOUT) or do {}; | |
| 433 | open(STDOUT, '>', '/dev/null') or do {}; | |
| 434 | my $r = eval { $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__) }; | |
| 435 | open(STDOUT, '>&', \*OLDOUT) or do {}; | |
| 436 | return 0 unless $r && defined $r->{n}; | |
| 437 | return int($r->{n}); | |
| 438 | } | |
| 439 | ||
| 440 | sub fmt_num { | |
| 441 | my ($n) = @_; $n = int($n || 0); | |
| 442 | 1 while $n =~ s/(\d)(\d{3})(?!\d)/$1,$2/; | |
| 443 | return $n; | |
| 444 | } | |
| 445 | sub h_esc { | |
| 446 | my ($s) = @_; return '' unless defined $s; | |
| 447 | $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; $s =~ s/"/"/g; | |
| 448 | return $s; | |
| 449 | } | |
| 450 | ||
| 451 | sub _site_funnel_palette { | |
| 452 | # HSL hue array per brand. Reads from HTTP_HOST so the same code | |
| 453 | # cloned across sister sites picks the right palette automatically. | |
| 454 | my $host = lc($ENV{HTTP_HOST} || $ENV{SERVER_NAME} || ''); | |
| 455 | return [15, 28, 350, 340, 320, 305] if $host =~ /taskforge/; # warm reds/pinks | |
| 456 | return [22, 35, 55, 100, 150, 175] if $host =~ /abforge/; # orange -> amber -> green | |
| 457 | return [60, 85, 130, 175, 200, 215] if $host =~ /(^|\.)shop\./; # olive/green -> teal -> sky | |
| 458 | return [160, 170, 180, 190, 200, 210] if $host =~ /repricer/; # emerald greens | |
| 459 | return [265, 275, 285, 295, 310, 325] if $host =~ /affiliate/; # violet/purple | |
| 460 | return [185, 195, 205, 215, 225, 240] if $host =~ /(crm|contactforge)/; # cyan -> blue -> teal | |
| 461 | return [22, 320, 285, 250, 200, 168, 140, 90]; # default: original rainbow | |
| 462 | } | |
| 463 | ||
| 464 | sub step_color { | |
| 465 | my ($i, $n) = @_; | |
| 466 | my $hues = _site_funnel_palette(); | |
| 467 | return "hsl($hues->[$i % scalar @$hues], 92%, 60%)"; | |
| 468 | } | |
| 469 | sub step_color_dark { | |
| 470 | my ($i, $n) = @_; | |
| 471 | my $hues = _site_funnel_palette(); | |
| 472 | return "hsl($hues->[$i % scalar @$hues], 80%, 38%)"; | |
| 473 | } | |
| 474 | sub step_color_deep { | |
| 475 | my ($i, $n) = @_; | |
| 476 | my $hues = _site_funnel_palette(); | |
| 477 | return "hsl($hues->[$i % scalar @$hues], 70%, 18%)"; | |
| 478 | } | |
| 479 | ||
| 480 | # Pick a small inline icon based on the page-path the stage represents. | |
| 481 | # Returns just the inner <path> data — caller wraps in <svg>. Tiny 24x24 | |
| 482 | # viewBox using lucide-style stroke paths. | |
| 483 | sub icon_for_label { | |
| 484 | my ($lbl) = @_; | |
| 485 | my $l = lc($lbl // ''); | |
| 486 | return '<path d="M3 9.5l9-6.5 9 6.5V21H3z"/><path d="M9 21V12h6v9"/>' | |
| 487 | if $l eq '/' || $l eq '/index.cgi' || $l eq '/home' || $l =~ m{^/index}; | |
| 488 | return '<path d="M20 12V8H6a2 2 0 1 1 0-4h12v4"/><path d="M20 12v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8"/><circle cx="16" cy="14" r="1.5"/>' | |
| 489 | if $l =~ /pricing|plan|billing|tier|cost/; | |
| 490 | return '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/>' | |
| 491 | if $l =~ /signup|register|join|trial|get-?started/; | |
| 492 | return '<path d="M21 2l-2 2"/><path d="M11.39 11.61a5.5 5.5 0 1 1-7.78 7.78 5.5 5.5 0 0 1 7.78-7.78z"/><path d="M15.5 7.5l3 3L22 7l-3-3"/>' | |
| 493 | if $l =~ /login|signin|auth/; | |
| 494 | return '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>' | |
| 495 | if $l =~ /blog|article|post|news/; | |
| 496 | return '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26"/>' | |
| 497 | if $l =~ /feature|product|highlight|spec/; | |
| 498 | return '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>' | |
| 499 | if $l =~ /signed up|success|complete|thanks|welcome|paid|checkout/; | |
| 500 | return '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>' | |
| 501 | if $l =~ /search|find/; | |
| 502 | return '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>' | |
| 503 | if $l =~ /pending|active|recent/; | |
| 504 | # default: page-with-fold icon | |
| 505 | return '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>'; | |
| 506 | } | |
| 507 | ||
| 508 | sub render_svg_funnel { | |
| 509 | my ($bars, $stage_popovers_ref) = @_; | |
| 510 | my $n = scalar @$bars; | |
| 511 | return '<div class="afnl-empty">No data yet.</div>' unless $n; | |
| 512 | ||
| 513 | # Layout: funnel cone lives in the LEFT ~48% of the canvas. Right half | |
| 514 | # is for side labels + leak chips. A curved colored arrow links each | |
| 515 | # band to its label so the eye can follow which is which. | |
| 516 | my $W = 1200; | |
| 517 | my $FUNNEL_W = 560; # max width the cone is allowed | |
| 518 | my $FUNNEL_CX = int($FUNNEL_W / 2); # cone centered in the funnel area | |
| 519 | my $LABEL_X = 660; # where side label text starts | |
| 520 | my $BULLET_X = $LABEL_X - 20; # where the arrow bullet sits | |
| 521 | my $H_band = 96; | |
| 522 | my $H_gap = 22; | |
| 523 | my $top_pad = 16; | |
| 524 | my $H = $top_pad + $n * $H_band + ($n - 1) * $H_gap + 32; | |
| 525 | ||
| 526 | my $defs = ''; | |
| 527 | for my $i (0..$n-1) { | |
| 528 | my $c1 = step_color($i, $n); | |
| 529 | my $c2 = step_color_dark($i, $n); | |
| 530 | # Main fill: bright brand color across the whole band, fading | |
| 531 | # slightly toward the dark brand variant only at the arrow tip. | |
| 532 | # No near-black edge stops -- keeps the brand color forward. | |
| 533 | $defs .= qq~<linearGradient id="afnlG$i" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="$c1"/><stop offset="70%" stop-color="$c1"/><stop offset="100%" stop-color="$c2"/></linearGradient>~; | |
| 534 | # Vertical overlay: just a top sheen highlight. No bottom shadow. | |
| 535 | $defs .= qq~<linearGradient id="afnlGtop$i" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="rgba(255,255,255,0.32)"/><stop offset="38%" stop-color="rgba(255,255,255,0)"/></linearGradient>~; | |
| 536 | # 3D shadow color (darker variant for the back/under polygon) | |
| 537 | $defs .= qq~<linearGradient id="afnlGsh$i" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="rgba(0,0,0,0.55)"/><stop offset="100%" stop-color="rgba(0,0,0,0.18)"/></linearGradient>~; | |
| 538 | # Arrowhead marker per stage, colored to match the band | |
| 539 | $defs .= qq~<marker id="afnlArrow$i" markerWidth="11" markerHeight="11" refX="9" refY="5.5" orient="auto" markerUnits="userSpaceOnUse"><path d="M0,0 L10,5.5 L0,11 L2,5.5 z" fill="$c1"/></marker>~; | |
| 540 | } | |
| 541 | $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>~; | |
| 542 | $defs .= qq~<marker id="afnlLeakArrow" markerWidth="11" markerHeight="11" refX="9" refY="5.5" orient="auto" markerUnits="userSpaceOnUse"><path d="M0,0 L10,5.5 L0,11 L2,5.5 z" fill="#ff5b5b"/></marker>~; | |
| 543 | # Radial aurora behind the cone | |
| 544 | $defs .= qq~<radialGradient id="afnlAurora" cx="0.3" cy="0.4" r="0.7"><stop offset="0%" stop-color="rgba(168,85,247,0.18)"/><stop offset="60%" stop-color="rgba(168,85,247,0.04)"/><stop offset="100%" stop-color="rgba(0,0,0,0)"/></radialGradient>~; | |
| 545 | # Glow filter | |
| 546 | $defs .= qq~<filter id="afnlGlow" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="6" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter>~; | |
| 547 | # Shine sweep gradient (animated via CSS) | |
| 548 | $defs .= qq~<linearGradient id="afnlShine" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="rgba(255,255,255,0)"/><stop offset="50%" stop-color="rgba(255,255,255,0.22)"/><stop offset="100%" stop-color="rgba(255,255,255,0)"/></linearGradient>~; | |
| 549 | ||
| 550 | my $entered = $bars->[0]->{count} || 1; | |
| 551 | my $min_pct = 10; | |
| 552 | my @widths; | |
| 553 | for my $i (0..$n-1) { | |
| 554 | my $count = $bars->[$i]->{count} || 0; | |
| 555 | my $pct = $entered ? (100.0 * $count / $entered) : 0; | |
| 556 | my $vw = ($pct < $min_pct) ? $min_pct : $pct; | |
| 557 | push @widths, int($FUNNEL_W * $vw / 100); | |
| 558 | } | |
| 559 | ||
| 560 | my $bands = ''; | |
| 561 | my $conn = ''; | |
| 562 | my $hits = ''; # HTML hit-area overlays for rich popovers | |
| 563 | for my $i (0..$n-1) { | |
| 564 | my $bw = $widths[$i]; | |
| 565 | my $x = int($FUNNEL_CX - $bw/2); | |
| 566 | my $y = $top_pad + $i * ($H_band + $H_gap); | |
| 567 | my $count = fmt_num($bars->[$i]->{count}); | |
| 568 | my $pct = $bars->[$i]->{pct}; | |
| 569 | my $label = h_esc($bars->[$i]->{label}); | |
| 570 | my $hv = $bars->[$i]->{hover} || {}; | |
| 571 | my $top_ref = ($hv->{referrers} && @{$hv->{referrers}}) ? $hv->{referrers}->[0]->{host} . " (" . $hv->{referrers}->[0]->{n} . ")" : '-'; | |
| 572 | my $top_utm = ($hv->{utm_sources} && @{$hv->{utm_sources}}) ? $hv->{utm_sources}->[0]->{src} . " (" . $hv->{utm_sources}->[0]->{n} . ")" : '-'; | |
| 573 | my $top_nxt = ($hv->{next_pages} && @{$hv->{next_pages}}) ? $hv->{next_pages}->[0]->{path} . " (" . $hv->{next_pages}->[0]->{n} . ")" : '-'; | |
| 574 | my $sn_n = $hv->{signups} || 0; | |
| 575 | my $abn_n = $hv->{abandons} || 0; | |
| 576 | my $tt = "$label\n$count visitors - $pct%\nDrop: $bars->[$i]->{drop_pct}%\n\nTop referrer: $top_ref\nTop UTM source: $top_utm\nTop next page: $top_nxt\n\nSigned up: $sn_n\nAbandoned: $abn_n"; | |
| 577 | $tt =~ s/&/&/g; $tt =~ s/</</g; $tt =~ s/>/>/g; | |
| 578 | ||
| 579 | my $stage_color = step_color($i, $n); | |
| 580 | ||
| 581 | # ----- 3D arrow-ribbon shape: rounded-left body with an arrow | |
| 582 | # point on the right that aims at the side label. | |
| 583 | my $tip = 26; # depth of the arrow point | |
| 584 | my $body_r = 16; # corner radius on the left | |
| 585 | my $body_right = $x + $bw - $tip; # where the body ends and the tip starts | |
| 586 | my $tip_x = $x + $bw; # arrow point x | |
| 587 | my $tip_y = $y + int($H_band / 2); | |
| 588 | ||
| 589 | my $arrow_d = "M $x,${\ ($y + $body_r) } " | |
| 590 | . "A $body_r,$body_r 0 0 1 ${\ ($x + $body_r) },$y " | |
| 591 | . "L $body_right,$y " | |
| 592 | . "L $tip_x,$tip_y " | |
| 593 | . "L $body_right,${\ ($y + $H_band) } " | |
| 594 | . "L ${\ ($x + $body_r) },${\ ($y + $H_band) } " | |
| 595 | . "A $body_r,$body_r 0 0 1 $x,${\ ($y + $H_band - $body_r) } " | |
| 596 | . "Z"; | |
| 597 | ||
| 598 | # 3D shadow polygon: same shape, offset down/right, darker, behind | |
| 599 | my $shadow_dx = 4; my $shadow_dy = 6; | |
| 600 | my $shadow_d = $arrow_d; | |
| 601 | $shadow_d =~ s/(\d+(?:\.\d+)?),(\d+(?:\.\d+)?)/($1+$shadow_dx).",".($2+$shadow_dy)/ge; | |
| 602 | ||
| 603 | $bands .= qq~<g class="fnl-band" data-step="$i"><title>$tt</title>~; | |
| 604 | # Shadow first (below) | |
| 605 | $bands .= qq~<path d="$shadow_d" fill="rgba(0,0,0,0.45)" filter="blur(2px)" opacity="0.85"/>~; | |
| 606 | # Main fill | |
| 607 | $bands .= qq~<path class="fnl-band-rect" d="$arrow_d" fill="url(#afnlG$i)"/>~; | |
| 608 | # Vertical overlay: top sheen + bottom shadow | |
| 609 | $bands .= qq~<path d="$arrow_d" fill="url(#afnlGtop$i)" pointer-events="none"/>~; | |
| 610 | # Inner top highlight stripe | |
| 611 | my $hy = $y + 6; | |
| 612 | my $hw = $bw - 32 - $tip; | |
| 613 | my $hx = $x + 16; | |
| 614 | $bands .= qq~<rect x="$hx" y="$hy" rx="3" ry="3" width="$hw" height="2.5" fill="white" opacity="0.32" pointer-events="none"/>~; | |
| 615 | # Animated shine sweep across the band (clipped to the arrow shape) | |
| 616 | my $clip_id = "afnlClip$i"; | |
| 617 | $defs .= qq[<clipPath id="$clip_id"><path d="$arrow_d"/></clipPath>]; | |
| 618 | my $shine_w = 90; | |
| 619 | my $shine_from = $x - $shine_w; | |
| 620 | my $shine_to = $x + $bw; | |
| 621 | my $shine_delay = sprintf('%.2f', $i * 0.45); | |
| 622 | $bands .= qq~<rect class="fnl-shine" clip-path="url(#$clip_id)" x="$shine_from" y="$y" width="$shine_w" height="$H_band" fill="url(#afnlShine)" opacity="0.85" pointer-events="none"><animate attributeName="x" from="$shine_from" to="$shine_to" dur="3.6s" begin="${shine_delay}s" repeatCount="indefinite"/></rect>~; | |
| 623 | ||
| 624 | # ----- Icon inside the band, left side (in the rounded body, not the tip) | |
| 625 | if ($bw >= 130) { | |
| 626 | my $icon_d = icon_for_label($bars->[$i]->{label}); | |
| 627 | my $ix = $x + 22; | |
| 628 | my $iy = $y + int($H_band/2) - 13; | |
| 629 | $bands .= qq~<g class="fnl-band-icon" transform="translate($ix $iy)"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.95)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">$icon_d</svg></g>~; | |
| 630 | } | |
| 631 | ||
| 632 | # ----- Inside the band: count (big) + percent (small under it), | |
| 633 | # centered in the body portion (excluding the arrow tip). | |
| 634 | my $body_cx = int($x + ($bw - $tip) / 2); | |
| 635 | my $cy_count = $y + 44; | |
| 636 | my $count_size = ($bw > 240) ? 26 : (($bw > 130) ? 20 : 15); | |
| 637 | $bands .= qq~<text x="$body_cx" y="$cy_count" text-anchor="middle" font-family="-apple-system,sans-serif" font-size="$count_size" font-weight="800" fill="white" style="text-shadow:0 2px 6px rgba(0,0,0,0.4)">$count</text>~; | |
| 638 | my $cy_pct = $y + 66; | |
| 639 | if ($bw > 110) { | |
| 640 | $bands .= qq~<text x="$body_cx" y="$cy_pct" text-anchor="middle" font-family="-apple-system,sans-serif" font-size="11" font-weight="600" fill="white" opacity="0.85" letter-spacing="0.05em">${pct}%</text>~; | |
| 641 | } | |
| 642 | ||
| 643 | # ----- Curved animated arrow from arrow tip -> bullet -> label | |
| 644 | my $arrow_start_x = $tip_x + 4; | |
| 645 | my $line_y = $tip_y; | |
| 646 | my $midx = int(($arrow_start_x + $BULLET_X) / 2); | |
| 647 | my $arrow_path = "M $arrow_start_x,$line_y Q $midx,$line_y ${\ ($BULLET_X - 6) },$line_y"; | |
| 648 | $bands .= qq~<path class="fnl-arrow" d="$arrow_path" stroke="$stage_color" stroke-width="2.4" fill="none" stroke-linecap="round" marker-end="url(#afnlArrow$i)" opacity="0.95" style="animation-delay:${\ ($i * 0.35) }s"/>~; | |
| 649 | # Pulsing bullet at the label end | |
| 650 | $bands .= qq~<circle class="fnl-bullet" cx="$BULLET_X" cy="$line_y" r="6.5" fill="$stage_color" opacity="0.35" style="animation-delay:${\ ($i * 0.35) }s"/>~; | |
| 651 | $bands .= qq~<circle cx="$BULLET_X" cy="$line_y" r="3.5" fill="$stage_color"/>~; | |
| 652 | ||
| 653 | # ----- Side label (page path + meta) | |
| 654 | my $lbl_y = $y + 30; | |
| 655 | my $lbl_y2 = $y + 50; | |
| 656 | $bands .= qq~<text x="$LABEL_X" y="$lbl_y" font-family="-apple-system,sans-serif" font-size="15" font-weight="700" fill="#fff" style="text-shadow:0 1px 3px rgba(0,0,0,0.6)">$label</text>~; | |
| 657 | $bands .= qq~<text x="$LABEL_X" y="$lbl_y2" font-family="-apple-system,sans-serif" font-size="11.5" fill="#8893a4">$count visitors · ${pct}% of landed</text>~; | |
| 658 | $bands .= qq~</g>~; | |
| 659 | ||
| 660 | # ----- Leak indicator: pinned BELOW THIS BAND'S label, with a | |
| 661 | # downward red arrow suggesting drainage from the source band. | |
| 662 | if ($i < $n - 1) { | |
| 663 | my $drop_pct = $bars->[$i+1]->{drop_pct} || 0; | |
| 664 | if ($drop_pct > 0.5) { | |
| 665 | my $lost_n = $bars->[$i]->{count} - $bars->[$i+1]->{count}; | |
| 666 | $lost_n = 0 if $lost_n < 0; | |
| 667 | my $cur_lbl = h_esc($bars->[$i]->{label}); | |
| 668 | my $next_lbl = h_esc($bars->[$i+1]->{label}); | |
| 669 | my $leak_tt = "Leak: $lost_n visitors reached $cur_lbl but did not continue to $next_lbl. That's $drop_pct% of this stage's cohort."; | |
| 670 | $leak_tt =~ s/&/&/g; $leak_tt =~ s/</</g; $leak_tt =~ s/>/>/g; | |
| 671 | my $chip_y = $y + 64; | |
| 672 | my $chip_w = 210; | |
| 673 | my $leak_lbl = "☠ -${drop_pct}% leak · ${lost_n} lost"; | |
| 674 | # Drain emerges from just inside the arrow-tip and curves down to the chip | |
| 675 | my $drain_start_x = $tip_x - 2; | |
| 676 | my $drain_start_y = $y + $H_band - 8; | |
| 677 | my $drain_end_y = $chip_y + 9; | |
| 678 | $bands .= qq~<g class="afnl-leak"><title>$leak_tt</title>~; | |
| 679 | $bands .= qq~<path class="fnl-leak-drain" d="M $drain_start_x,$drain_start_y Q ${\ ($drain_start_x + 30) },${\ (($drain_start_y + $drain_end_y) / 2) } ${\ ($LABEL_X - 6) },$drain_end_y" stroke="#ff5b5b" stroke-width="2" stroke-dasharray="3 4" fill="none" stroke-linecap="round" opacity="0.7" marker-end="url(#afnlLeakArrow)"/>~; | |
| 680 | $bands .= qq~<rect class="fnl-leak-chip" x="$LABEL_X" y="$chip_y" rx="9" ry="9" width="$chip_w" height="20" fill="url(#afnlLeak)"/>~; | |
| 681 | $bands .= qq~<text x="${\ ($LABEL_X + 12) }" y="${\ ($chip_y + 14) }" font-family="-apple-system,sans-serif" font-size="11" font-weight="800" fill="#ffd5d5">$leak_lbl</text>~; | |
| 682 | $bands .= qq~</g>~; | |
| 683 | } | |
| 684 | } | |
| 685 | ||
| 686 | # ----- Soft trapezoid between band i body and band i+1 body | |
| 687 | # (uses body widths, NOT including arrow tip, so the cone shape | |
| 688 | # flows visually down the funnel.) | |
| 689 | if ($i < $n - 1) { | |
| 690 | my $bw2 = $widths[$i+1]; | |
| 691 | my $body_w1 = $bw - $tip; | |
| 692 | my $body_w2 = $bw2 - $tip; | |
| 693 | my $bx1 = $x; | |
| 694 | my $bx1r = $x + $body_w1; | |
| 695 | my $bx2 = int($FUNNEL_CX - $body_w2/2); | |
| 696 | my $bx2r = $bx2 + $body_w2; | |
| 697 | my $y2a = $y + $H_band; | |
| 698 | my $y2b = $y + $H_band + $H_gap; | |
| 699 | my $c = step_color($i, $n); | |
| 700 | $conn .= qq~<polygon points="$bx1,$y2a $bx1r,$y2a $bx2r,$y2b $bx2,$y2b" fill="$c" opacity="0.18"/>~; | |
| 701 | # Animated downward chevron showing flow (centered on body, not tip) | |
| 702 | my $body_cx_now = int($x + ($bw - $tip) / 2); | |
| 703 | my $chev_y = int(($y2a + $y2b) / 2); | |
| 704 | $conn .= qq~<path class="fnl-chev" d="M ${\ ($body_cx_now - 8) },${\ ($chev_y - 4) } L $body_cx_now,${\ ($chev_y + 4) } L ${\ ($body_cx_now + 8) },${\ ($chev_y - 4) }" stroke="white" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" opacity="0.55" style="animation-delay:${\ ($i * 0.25) }s"/>~; | |
| 705 | } | |
| 706 | ||
| 707 | # ----- HTML hit-area overlay (whole row) for rich popover hover | |
| 708 | my $row_top_pct = sprintf('%.3f', 100 * $y / $H); | |
| 709 | my $row_h_pct = sprintf('%.3f', 100 * $H_band / $H); | |
| 710 | my $popover_html = ($stage_popovers_ref && $stage_popovers_ref->[$i]) ? $stage_popovers_ref->[$i] : ''; | |
| 711 | $hits .= qq~<div class="afnl-hit" data-step="$i" style="top:${row_top_pct}%; height:${row_h_pct}%;">$popover_html</div>~; | |
| 712 | } | |
| 713 | # Aurora background rect sits behind everything in the SVG | |
| 714 | my $aurora = qq~<rect x="0" y="0" width="$W" height="$H" fill="url(#afnlAurora)" pointer-events="none"/>~; | |
| 715 | return qq~<div class="afnl-svg-stage"><svg viewBox="0 0 $W $H" class="afnl-svg" preserveAspectRatio="xMidYMid meet"><defs>$defs</defs>$aurora$conn$bands</svg><div class="afnl-hit-layer">$hits</div></div>~; | |
| 716 | } | |
| 717 | ||
| 718 | sub afnl_hero { | |
| 719 | my ($title, $sub) = @_; | |
| 720 | my $t = h_esc($title); | |
| 721 | return qq~ | |
| 722 | <div class="afnl-hero"> | |
| 723 | <div class="afnl-hero-orb orb-a"></div> | |
| 724 | <div class="afnl-hero-orb orb-b"></div> | |
| 725 | <div class="afnl-hero-orb orb-c"></div> | |
| 726 | <div class="afnl-hero-grid"></div> | |
| 727 | <div class="afnl-hero-content"> | |
| 728 | <div class="afnl-hero-pill"> | |
| 729 | <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> | |
| 730 | <span>Admin · Platform Funnel</span> | |
| 731 | </div> | |
| 732 | <h1 class="afnl-hero-title">$t</h1> | |
| 733 | <p class="afnl-hero-sub">$sub</p> | |
| 734 | </div> | |
| 735 | </div>~; | |
| 736 | } | |
| 737 | ||
| 738 | sub admin_top_paths_panel { | |
| 739 | my ($rows, $total_signups, $selected, $arrow, $days) = @_; | |
| 740 | return '' unless ref($rows) eq 'ARRAY'; | |
| 741 | if (!scalar @$rows) { | |
| 742 | return qq~ | |
| 743 | <div class="afnl-dom-wrap"> | |
| 744 | <div class="afnl-dom-head"> | |
| 745 | <div> | |
| 746 | <div class="afnl-dom-title">Top Acquisition Paths</div> | |
| 747 | <div class="afnl-dom-sub">Page sequences anonymous visitors take before they sign up or abandon. Click a path to drill into its per-step funnel.</div> | |
| 748 | </div> | |
| 749 | </div> | |
| 750 | <div class="afnl-empty">No anonymous-visitor data yet for the last $days days. Once visitors land on a marketing page, their paths will appear here.</div> | |
| 751 | </div>~; | |
| 752 | } | |
| 753 | ||
| 754 | my $max = 0; | |
| 755 | foreach my $r (@$rows) { $max = $r->{n} if $r->{n} > $max; } | |
| 756 | $max = 1 if $max < 1; | |
| 757 | ||
| 758 | my $cells = ''; | |
| 759 | my $rank = 0; | |
| 760 | foreach my $r (@$rows) { | |
| 761 | $rank++; | |
| 762 | my $path_disp = h_esc($r->{path_sequence}); | |
| 763 | my $cnt = fmt_num($r->{n}); | |
| 764 | my $sn = fmt_num($r->{signups} || 0); | |
| 765 | my $an = fmt_num($r->{abandons} || 0); | |
| 766 | my $bar_pct = int(100.0 * $r->{n} / $max + 0.5); | |
| 767 | my $is_sel = (defined($selected) && length($selected) && $selected eq $r->{path_sequence}) ? 'is-selected' : ''; | |
| 768 | my $share_pct = $total_signups ? sprintf('%.1f', 100.0 * $r->{n} / $total_signups) : '0.0'; | |
| 769 | # URL-encode the path for the link | |
| 770 | my $enc = $r->{path_sequence}; $enc =~ s/([^A-Za-z0-9])/sprintf("%%%02X", ord($1))/eg; | |
| 771 | $cells .= qq~ | |
| 772 | <a href="?days=$days&path=$enc" class="afnl-path-row $is_sel" style="--pct:${bar_pct}%"> | |
| 773 | <div class="afnl-path-rank">$rank</div> | |
| 774 | <div class="afnl-path-name" title="$path_disp">$path_disp</div> | |
| 775 | <div class="afnl-path-stat ok"><span class="dot"></span>$sn</div> | |
| 776 | <div class="afnl-path-stat bad"><span class="dot"></span>$an</div> | |
| 777 | <div class="afnl-path-share">${share_pct}%</div> | |
| 778 | <div class="afnl-path-cnt">$cnt</div> | |
| 779 | </a>~; | |
| 780 | } | |
| 781 | ||
| 782 | return qq~ | |
| 783 | <div class="afnl-dom-wrap"> | |
| 784 | <div class="afnl-dom-head"> | |
| 785 | <div> | |
| 786 | <div class="afnl-dom-title">Top Acquisition Paths</div> | |
| 787 | <div class="afnl-dom-sub">Page sequences anonymous visitors take before they sign up or abandon. Click a row to see that path as a step-by-step funnel.</div> | |
| 788 | </div> | |
| 789 | <div class="afnl-path-legend"> | |
| 790 | <span><span class="dot ok"></span> signed up</span> | |
| 791 | <span><span class="dot bad"></span> abandoned</span> | |
| 792 | </div> | |
| 793 | </div> | |
| 794 | <div class="afnl-path-list">$cells</div> | |
| 795 | </div>~; | |
| 796 | } | |
| 797 | ||
| 798 | sub admin_path_funnel_drilldown { | |
| 799 | my ($steps, $path_raw, $total, $signups, $abandons, $arrow) = @_; | |
| 800 | return '' unless ref($steps) eq 'ARRAY' && scalar @$steps; | |
| 801 | return '' unless length($path_raw // ''); | |
| 802 | ||
| 803 | my $path_disp = h_esc($path_raw); | |
| 804 | my $total_fmt = fmt_num($total); | |
| 805 | my $signup_fmt = fmt_num($signups); | |
| 806 | my $aban_fmt = fmt_num($abandons); | |
| 807 | my $continued = $total - $signups - $abandons; | |
| 808 | $continued = 0 if $continued < 0; | |
| 809 | my $cont_fmt = fmt_num($continued); | |
| 810 | ||
| 811 | my $entered = $total || 1; | |
| 812 | my $bars = ''; | |
| 813 | my $idx = 0; | |
| 814 | foreach my $s (@$steps) { | |
| 815 | $idx++; | |
| 816 | my $step_path = h_esc($s->{step}); | |
| 817 | my $r = $s->{reached}; | |
| 818 | my $sn = $s->{signup}; | |
| 819 | my $an = $s->{abandon}; | |
| 820 | my $reached_pct = $entered ? sprintf('%.1f', 100 * $r / $entered) : '0.0'; | |
| 821 | my $bar_w = $entered ? int(100 * $r / $entered) : 0; | |
| 822 | $bar_w = 4 if $bar_w < 4 && $r > 0; | |
| 823 | my $reached_n = fmt_num($r); | |
| 824 | my $sn_n = fmt_num($sn); | |
| 825 | my $an_n = fmt_num($an); | |
| 826 | my $sn_block = $sn ? qq~<span class="step-marker ok" title="$sn signed up here">✓ $sn_n</span>~ : ''; | |
| 827 | my $an_block = $an ? qq~<span class="step-marker bad" title="$an abandoned here">✕ $an_n</span>~ : ''; | |
| 828 | $bars .= qq~ | |
| 829 | <div class="afnl-path-step"> | |
| 830 | <div class="afnl-path-step-head"> | |
| 831 | <div class="afnl-path-step-num">$idx</div> | |
| 832 | <div class="afnl-path-step-name" title="$step_path">$step_path</div> | |
| 833 | <div class="afnl-path-step-meta">$reached_n · ${reached_pct}%</div> | |
| 834 | </div> | |
| 835 | <div class="afnl-path-step-bar"><div class="afnl-path-step-fill" style="width:${bar_w}%"></div></div> | |
| 836 | <div class="afnl-path-step-markers">$sn_block $an_block</div> | |
| 837 | </div>~; | |
| 838 | } | |
| 839 | ||
| 840 | return qq~ | |
| 841 | <div class="afnl-pathdrill"> | |
| 842 | <div class="afnl-pathdrill-head"> | |
| 843 | <div class="lbl">Path drill-in</div> | |
| 844 | <div class="path-string">$path_disp</div> | |
| 845 | <div class="afnl-pathdrill-stats"> | |
| 846 | <span>Sessions <strong>$total_fmt</strong></span> | |
| 847 | <span class="ok">Signed up <strong>$signup_fmt</strong></span> | |
| 848 | <span class="bad">Abandoned <strong>$aban_fmt</strong></span> | |
| 849 | <span class="dim">Still active <strong>$cont_fmt</strong></span> | |
| 850 | </div> | |
| 851 | </div> | |
| 852 | <div class="afnl-pathdrill-steps">$bars</div> | |
| 853 | </div>~; | |
| 854 | } | |
| 855 | ||
| 856 | sub afnl_css { | |
| 857 | return <<'CSS'; | |
| 858 | <style> | |
| 859 | .afnl-hero.afnl-hero { | |
| 860 | position: relative; margin: -8px -8px 24px; padding: 68px 140px 72px 40px !important; | |
| 861 | border-radius: 22px; overflow: hidden; | |
| 862 | background: | |
| 863 | radial-gradient(900px 280px at 14% 0%, rgba(30,80,140,0.35), transparent 70%), | |
| 864 | radial-gradient(700px 240px at 88% 90%, rgba(20,60,110,0.35), transparent 70%), | |
| 865 | linear-gradient(180deg, #0a1d3a 0%, #060912 100%); | |
| 866 | border: 1px solid rgba(255,255,255,0.06); | |
| 867 | box-shadow: 0 28px 60px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.04); | |
| 868 | } | |
| 869 | .afnl-hero-grid { | |
| 870 | position: absolute; inset: 0; | |
| 871 | background-image: | |
| 872 | linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px), | |
| 873 | linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px); | |
| 874 | background-size: 36px 36px; | |
| 875 | mask-image: radial-gradient(ellipse at 50% 30%, black 0%, transparent 70%); | |
| 876 | pointer-events: none; | |
| 877 | } | |
| 878 | .afnl-hero-orb { position: absolute; border-radius: 50%; filter: blur(60px); opacity: 0.55; pointer-events: none; animation: afnlOrb 12s ease-in-out infinite; } | |
| 879 | .afnl-hero-orb.orb-a { width: 320px; height: 320px; left: -60px; top: -80px; background: radial-gradient(circle, #0d1d3a 0%, transparent 70%); } | |
| 880 | .afnl-hero-orb.orb-b { width: 380px; height: 380px; right: -100px; top: -40px; background: radial-gradient(circle, #081428 0%, transparent 70%); animation-delay: -3s; } | |
| 881 | .afnl-hero-orb.orb-c { width: 260px; height: 260px; left: 40%; bottom: -120px; background: radial-gradient(circle, #123556 0%, transparent 70%); animation-delay: -7s; } | |
| 882 | @keyframes afnlOrb { 0%, 100% { transform: translate(0,0) scale(1); } 50% { transform: translate(20px,-16px) scale(1.05); } } | |
| 883 | .afnl-hero-content { position: relative; z-index: 1; max-width: 760px; } | |
| 884 | .afnl-hero-pill { display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px; background: rgba(59,130,246,0.16); border: 1px solid rgba(59,130,246,0.42); border-radius: 999px; color: #93c5fd; font-size: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 24px; } | |
| 885 | .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); } | |
| 886 | .afnl-hero-sub { margin: 0; color: rgba(207,214,224,0.85); font-size: 17px; line-height: 1.55; } | |
| 887 | .afnl-hero-sub strong { color: #fff; } | |
| 888 | .afnl-toolbar { display: flex; justify-content: flex-start; margin: 0 0 18px; flex-wrap: wrap; gap: 12px; } | |
| 889 | .afnl-toolbar-form { display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap; } | |
| 890 | .afnl-field { display: inline-flex; flex-direction: column; gap: 4px; } | |
| 891 | .afnl-field-lbl { font-size: 11px; color: #6f7787; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; } | |
| 892 | .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; } | |
| 893 | .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, #3b82f6 0%, #2563eb 100%); color: #fff; border: none; box-shadow: 0 6px 18px rgba(59,130,246,0.32); } | |
| 894 | .afnl-toolbar-btn:hover { transform: translateY(-1px); box-shadow: 0 10px 24px rgba(59,130,246,0.46); } | |
| 895 | .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; } | |
| 896 | .afnl-toolbar-btn.ghost:hover { background: rgba(255,255,255,0.07); } | |
| 897 | .afnl-stats-grid { display: grid; gap: 16px; margin: 0 0 24px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); } | |
| 898 | .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); } | |
| 899 | .afnl-stat.lit { opacity: 1; transform: none; } | |
| 900 | .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; } | |
| 901 | .afnl-stat.orb-orange::after { background: #f97316; } | |
| 902 | .afnl-stat.orb-pink::after { background: #ec4899; } | |
| 903 | .afnl-stat.orb-violet::after { background: #a855f7; } | |
| 904 | .afnl-stat.orb-cyan::after { background: #22d3ee; } | |
| 905 | .afnl-stat .lbl { font-size: 11px; color: #8893a4; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; } | |
| 906 | .afnl-stat .val { font-size: 32px; font-weight: 800; color: #fff; margin: 8px 0 6px; line-height: 1; position: relative; } | |
| 907 | .afnl-stat .val.grad-text { background: linear-gradient(90deg, #f97316, #ec4899, #a855f7); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; } | |
| 908 | .afnl-stat .sub { font-size: 12px; color: #7a8392; position: relative; } | |
| 909 | .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; } | |
| 910 | .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; } | |
| 911 | .afnl-viz-head { position: relative; margin-bottom: 14px; } | |
| 912 | .afnl-viz-title { font-size: 18px; font-weight: 700; color: #fff; } | |
| 913 | .afnl-viz-sub { font-size: 12px; color: #8893a4; margin-top: 4px; } | |
| 914 | .afnl-drill-path { font-family: 'SF Mono', Menlo, monospace; font-size: 14px; color: #c4a3f0; word-break: break-all; } | |
| 915 | .afnl-clear { color: #5aa9ff; text-decoration: none; font-weight: 600; margin-left: 6px; } | |
| 916 | .afnl-clear:hover { color: #7cc1ff; text-decoration: underline; } | |
| 917 | .afnl-svg-stage { position: relative; padding: 6px 0 0; isolation: isolate; } | |
| 918 | .afnl-svg-stage::before { content: ""; position: absolute; inset: -30px; background: radial-gradient(620px 240px at 24% 30%, rgba(168,85,247,0.16), transparent 70%), radial-gradient(540px 220px at 78% 70%, rgba(34,211,238,0.10), transparent 70%); pointer-events: none; z-index: 0; animation: afnl-aurora 14s ease-in-out infinite; } | |
| 919 | .afnl-svg { width: 100%; height: auto; max-height: 760px; display: block; position: relative; z-index: 1; } | |
| 920 | .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; } | |
| 921 | .fnl-band.lit { opacity: 1; transform: scaleX(1); } | |
| 922 | .fnl-band-rect { transition: filter 0.25s ease; } | |
| 923 | .fnl-band:hover .fnl-band-rect { filter: brightness(1.15) drop-shadow(0 0 16px rgba(168,85,247,0.6)); } | |
| 924 | .fnl-band-icon { opacity: 0.92; } | |
| 925 | .fnl-shine { mix-blend-mode: screen; } | |
| 926 | /* Animated curved arrow from band to label */ | |
| 927 | .fnl-arrow { stroke-dasharray: 6 8; animation: afnl-dash-flow 1.4s linear infinite; } | |
| 928 | .fnl-bullet { animation: afnl-bullet-pulse 1.8s ease-in-out infinite; transform-origin: center; transform-box: fill-box; } | |
| 929 | /* Animated downward chevron between bands */ | |
| 930 | .fnl-chev { animation: afnl-chev-float 1.6s ease-in-out infinite; transform-origin: center; transform-box: fill-box; } | |
| 931 | /* Leak: drain line + chip pulse */ | |
| 932 | .fnl-leak-drain { stroke-dasharray: 3 4; animation: afnl-leak-drain 1.1s linear infinite; } | |
| 933 | .fnl-leak-chip { animation: afnl-leak-pulse 2.4s ease-in-out infinite; transform-origin: center; transform-box: fill-box; } | |
| 934 | @keyframes afnl-dash-flow { to { stroke-dashoffset: -28; } } | |
| 935 | @keyframes afnl-bullet-pulse { 0%, 100% { transform: scale(1); opacity: 0.35; } 50% { transform: scale(1.5); opacity: 0.05; } } | |
| 936 | @keyframes afnl-chev-float { 0%, 100% { transform: translateY(-2px); opacity: 0.45; } 50% { transform: translateY(3px); opacity: 0.9; } } | |
| 937 | @keyframes afnl-leak-drain { to { stroke-dashoffset: -14; } } | |
| 938 | @keyframes afnl-leak-pulse { 0%, 100% { opacity: 0.85; } 50% { opacity: 1; filter: drop-shadow(0 0 8px rgba(239,68,68,0.55)); } } | |
| 939 | @keyframes afnl-aurora { 0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.9; } 50% { transform: translate(20px, -10px) scale(1.05); opacity: 1; } } | |
| 940 | /* HTML hit-area overlay (full row width over each band) */ | |
| 941 | .afnl-hit-layer { position: absolute; inset: 0; pointer-events: none; z-index: 2; } | |
| 942 | .afnl-hit { position: absolute; left: 0; right: 0; pointer-events: auto; cursor: help; } | |
| 943 | .afnl-hit .ap-pop { left: 50%; top: 100%; transform: translate(-50%, -6px); } | |
| 944 | .afnl-hit:hover .ap-pop, .afnl-hit:focus-within .ap-pop { opacity: 1; transform: translate(-50%, 8px); } | |
| 945 | .afnl-hit:hover ~ .afnl-svg .fnl-band[data-step] { } /* placeholder */ | |
| 946 | /* Top Acquisition Channels (admin) */ | |
| 947 | .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; } | |
| 948 | .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; } | |
| 949 | .afnl-dom-head { position: relative; margin-bottom: 16px; max-width: 720px; } | |
| 950 | .afnl-dom-title { font-size: 17px; font-weight: 700; color: #fff; } | |
| 951 | .afnl-dom-sub { font-size: 12px; color: #8893a4; margin-top: 4px; line-height: 1.55; } | |
| 952 | .afnl-dom-list { position: relative; display: flex; flex-direction: column; gap: 4px; } | |
| 953 | .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; } | |
| 954 | .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); } | |
| 955 | .afnl-dom-row > * { position: relative; z-index: 1; } | |
| 956 | .afnl-dom-rank { color: #6f7787; font-weight: 700; font-size: 11px; text-align: center; } | |
| 957 | .afnl-dom-name { color: #cfd6e0; font-family: 'SF Mono', Menlo, monospace; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 958 | .afnl-dom-share { color: #8893a4; font-size: 11px; text-align: right; font-weight: 600; } | |
| 959 | .afnl-dom-cnt { color: #fff; font-weight: 700; font-size: 13px; text-align: right; } | |
| 960 | ||
| 961 | .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; } | |
| 962 | .afnl-spark-title { font-size: 15px; font-weight: 700; color: #fff; } | |
| 963 | .afnl-spark-sub { font-size: 12px; color: #7a8392; margin-top: 3px; } | |
| 964 | .afnl-spark-wrap { display: flex; align-items: flex-end; gap: 4px; height: 80px; margin-top: 14px; } | |
| 965 | .afnl-spark-col { flex: 1; min-width: 6px; display: flex; align-items: flex-end; } | |
| 966 | .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; } | |
| 967 | .afnl-spark-col:hover .afnl-spark-bar { background: linear-gradient(180deg, #fb9c4d 0%, #f072ad 100%); } | |
| 968 | .afnl-step-grid { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; } | |
| 969 | .afnl-step-header { display: grid; grid-template-columns: 32px minmax(180px, 1fr) 110px minmax(220px, 1.2fr) 90px; column-gap: 32px; align-items: center; padding: 8px 18px 6px; color: #6f7787; font-size: 10px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; border-bottom: 1px solid rgba(255,255,255,0.06); margin-bottom: 4px; position: relative; } | |
| 970 | .afnl-step-header .afnl-step-h-drop { text-align: right; } | |
| 971 | .afnl-step-header .col-h { position: relative; cursor: help; } | |
| 972 | .afnl-step-header .col-h::after { content: attr(data-help); position: absolute; top: calc(100% + 10px); left: 0; min-width: 280px; max-width: 360px; padding: 14px 16px; background: rgba(14,18,26,0.98); border: 1px solid rgba(196,163,240,0.36); border-radius: 12px; box-shadow: 0 20px 40px rgba(0,0,0,0.6); color: #cfd6e0; font-size: 12px; font-weight: 400; line-height: 1.55; letter-spacing: normal; text-transform: none; text-align: left; opacity: 0; pointer-events: none; transform: translateY(-4px); transition: opacity 0.16s ease, transform 0.16s ease; white-space: normal; z-index: 80; } | |
| 973 | .afnl-step-header .col-h:hover::after, .afnl-step-header .col-h:focus::after { opacity: 1; transform: translateY(0); } | |
| 974 | .afnl-step-header .col-h.afnl-step-h-drop::after { right: 0; left: auto; } | |
| 975 | .afnl-step { display: grid; grid-template-columns: 32px minmax(180px, 1fr) 110px minmax(220px, 1.2fr) 90px; column-gap: 32px; align-items: center; padding: 11px 18px; 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-left: 3px solid var(--step-color, #f97316); border-radius: 10px; opacity: 0; transform: translateX(-10px); transition: all 0.5s cubic-bezier(.4,0,.2,1); position: relative; overflow: hidden; min-height: 44px; } | |
| 976 | .afnl-step.lit { opacity: 1; transform: none; } | |
| 977 | .afnl-step::after { content: ""; position: absolute; width: 260px; height: 140px; border-radius: 50%; filter: blur(48px); right: -60px; top: -50px; background: var(--step-color, #f97316); opacity: 0.4; pointer-events: none; transition: opacity 0.25s ease; } | |
| 978 | .afnl-step::before { content: ""; position: absolute; width: 160px; height: 80px; border-radius: 50%; filter: blur(36px); left: -40px; bottom: -30px; background: var(--step-color, #f97316); opacity: 0.18; pointer-events: none; transition: opacity 0.25s ease; } | |
| 979 | .afnl-step > * { position: relative; z-index: 1; } | |
| 980 | .afnl-step:hover { background: linear-gradient(160deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02)); border-color: rgba(255,255,255,0.14); } | |
| 981 | .afnl-step:hover::after { opacity: 0.6; } | |
| 982 | .afnl-step:hover::before { opacity: 0.3; } | |
| 983 | .afnl-step-num { width: 28px; height: 28px; border-radius: 7px; background: var(--step-color, #f97316); color: #0a0f17; font-weight: 800; font-size: 13px; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 10px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.3); } | |
| 984 | .afnl-step-label { font-size: 14px; font-weight: 700; color: #fff; font-family: 'SF Mono', Menlo, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } | |
| 985 | .afnl-step-meta { font-size: 12.5px; color: #cfd6e0; font-weight: 600; white-space: nowrap; } | |
| 986 | .afnl-step-src { font-size: 11px; color: #6f7787; display: flex; gap: 6px; align-items: baseline; min-width: 0; overflow: hidden; white-space: nowrap; } | |
| 987 | .afnl-step-src-lbl { color: #5a6270; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; font-size: 9.5px; flex-shrink: 0; } | |
| 988 | .afnl-step-src-val { color: #cfd6e0; font-family: 'SF Mono', Menlo, monospace; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex-shrink: 1; } | |
| 989 | .afnl-step-src-sep { color: #3a3f4a; flex-shrink: 0; } | |
| 990 | .afnl-step-drop { text-align: right; padding: 5px 10px; border-radius: 7px; cursor: help; font-weight: 700; justify-self: end; } | |
| 991 | .afnl-step-drop .val { font-size: 14px; } | |
| 992 | .afnl-info { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 50%; background: rgba(196,163,240,0.18); color: #c4a3f0; font-weight: 700; font-size: 11px; margin-left: 8px; cursor: help; position: relative; vertical-align: 2px; } | |
| 993 | .afnl-info:hover, .afnl-info:focus { background: rgba(196,163,240,0.32); outline: none; } | |
| 994 | .afnl-info-pop { position: absolute; top: calc(100% + 8px); left: -8px; z-index: 50; width: 340px; padding: 14px 16px; background: rgba(14,18,26,0.98); border: 1px solid rgba(196,163,240,0.36); border-radius: 12px; box-shadow: 0 20px 50px rgba(0,0,0,0.6); color: #cfd6e0; font-size: 12px; font-weight: 400; line-height: 1.55; letter-spacing: normal; text-transform: none; text-align: left; opacity: 0; pointer-events: none; transform: translateY(-4px); transition: opacity 0.16s ease, transform 0.16s ease; } | |
| 995 | .afnl-info-pop strong { color: #fff; font-weight: 700; } | |
| 996 | .afnl-info:hover .afnl-info-pop, .afnl-info:focus .afnl-info-pop { opacity: 1; pointer-events: auto; transform: translateY(0); } | |
| 997 | .afnl-step-drop .lbl { font-size: 9px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.8; } | |
| 998 | .afnl-step-drop .val { font-size: 17px; font-weight: 800; line-height: 1; margin-top: 2px; } | |
| 999 | .afnl-step-drop.high { background: rgba(239,68,68,0.14); color: #ff8a8a; } | |
| 1000 | .afnl-step-drop.mid { background: rgba(234,179,8,0.12); color: #fbbf24; } | |
| 1001 | .afnl-step-drop.low { background: rgba(34,197,94,0.10); color: #4ade80; } | |
| 1002 | .afnl-empty { text-align: center; padding: 60px 40px; color: #8893a4; } | |
| 1003 | ||
| 1004 | /* Top Acquisition PATHS */ | |
| 1005 | .afnl-path-legend { display: flex; gap: 14px; font-size: 11px; color: #8893a4; } | |
| 1006 | .afnl-path-legend .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; vertical-align: 1px; margin-right: 4px; } | |
| 1007 | .afnl-path-legend .dot.ok { background: #22c55e; } | |
| 1008 | .afnl-path-legend .dot.bad { background: #ef4444; } | |
| 1009 | .afnl-path-list { position: relative; display: flex; flex-direction: column; gap: 4px; } | |
| 1010 | .afnl-path-row { position: relative; display: grid; grid-template-columns: 24px 1fr 70px 70px 60px 60px; align-items: center; gap: 10px; padding: 10px 12px; background: rgba(255,255,255,0.02); border-radius: 8px; font-size: 13px; overflow: hidden; text-decoration: none; color: inherit; cursor: pointer; transition: background 0.15s ease; } | |
| 1011 | .afnl-path-row:hover { background: rgba(255,255,255,0.05); } | |
| 1012 | .afnl-path-row.is-selected { background: rgba(168,85,247,0.12); border: 1px solid rgba(168,85,247,0.3); } | |
| 1013 | .afnl-path-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); } | |
| 1014 | .afnl-path-row > * { position: relative; z-index: 1; } | |
| 1015 | .afnl-path-rank { color: #6f7787; font-weight: 700; font-size: 11px; text-align: center; } | |
| 1016 | .afnl-path-name { color: #cfd6e0; font-family: 'SF Mono', Menlo, monospace; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 1017 | .afnl-path-stat { font-size: 11px; font-weight: 600; text-align: right; display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px; } | |
| 1018 | .afnl-path-stat.ok { color: #4ade80; } | |
| 1019 | .afnl-path-stat.bad { color: #f87171; } | |
| 1020 | .afnl-path-stat .dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; } | |
| 1021 | .afnl-path-stat.ok .dot { background: #22c55e; } | |
| 1022 | .afnl-path-stat.bad .dot { background: #ef4444; } | |
| 1023 | .afnl-path-share { color: #8893a4; font-size: 11px; text-align: right; font-weight: 600; } | |
| 1024 | .afnl-path-cnt { color: #fff; font-weight: 700; font-size: 13px; text-align: right; } | |
| 1025 | ||
| 1026 | /* Path drill-in funnel */ | |
| 1027 | .afnl-pathdrill { position: relative; padding: 24px 40px; background: linear-gradient(160deg, rgba(168,85,247,0.06), rgba(255,255,255,0.01)); border: 1px solid rgba(168,85,247,0.18); border-radius: 16px; margin: 0 0 22px; } | |
| 1028 | .afnl-pathdrill-head .lbl { font-size: 11px; color: #c4a3f0; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 6px; } | |
| 1029 | .afnl-pathdrill-head .path-string { color: #fff; font-family: 'SF Mono', Menlo, monospace; font-size: 13px; word-break: break-all; margin-bottom: 14px; } | |
| 1030 | .afnl-pathdrill-stats { display: flex; gap: 18px; flex-wrap: wrap; font-size: 12px; color: #8893a4; padding-bottom: 16px; border-bottom: 1px solid rgba(255,255,255,0.06); margin-bottom: 24px; } | |
| 1031 | .afnl-pathdrill-stats strong { color: #fff; margin-left: 6px; } | |
| 1032 | .afnl-pathdrill-stats .ok strong { color: #4ade80; } | |
| 1033 | .afnl-pathdrill-stats .bad strong { color: #f87171; } | |
| 1034 | .afnl-pathdrill-stats .dim strong { color: #cbd5e1; } | |
| 1035 | .afnl-pathdrill-steps { display: flex; flex-direction: column; gap: 14px; } | |
| 1036 | .afnl-path-step { display: grid; gap: 6px; } | |
| 1037 | .afnl-path-step-head { display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 10px; } | |
| 1038 | .afnl-path-step-num { width: 26px; height: 26px; border-radius: 7px; background: rgba(168,85,247,0.25); color: #c4a3f0; font-weight: 800; font-size: 12px; display: flex; align-items: center; justify-content: center; } | |
| 1039 | .afnl-path-step-name { color: #cfd6e0; font-family: 'SF Mono', Menlo, monospace; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 1040 | .afnl-path-step-meta { color: #8893a4; font-size: 12px; font-weight: 600; } | |
| 1041 | .afnl-path-step-bar { height: 10px; background: rgba(255,255,255,0.04); border-radius: 5px; overflow: hidden; } | |
| 1042 | .afnl-path-step-fill { height: 100%; background: linear-gradient(90deg, #a855f7 0%, #22d3ee 100%); border-radius: 5px; transition: width 0.6s cubic-bezier(.4,0,.2,1); } | |
| 1043 | .afnl-path-step-markers { display: flex; gap: 8px; margin-left: 38px; } | |
| 1044 | .afnl-path-step-markers .step-marker { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 12px; font-size: 11px; font-weight: 700; } | |
| 1045 | .afnl-path-step-markers .step-marker.ok { background: rgba(34,197,94,0.18); color: #4ade80; } | |
| 1046 | .afnl-path-step-markers .step-marker.bad { background: rgba(239,68,68,0.18); color: #f87171; } | |
| 1047 | ||
| 1048 | /* --- Top Acquisition Paths (4-column dimensional view) --- */ | |
| 1049 | .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; } | |
| 1050 | .afnpp-head { margin-bottom: 14px; } | |
| 1051 | .afnpp-title { font-size: 18px; font-weight: 700; color: #fff; } | |
| 1052 | .afnpp-sub { font-size: 12px; color: #8893a4; margin-top: 4px; } | |
| 1053 | .afnpp-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; } | |
| 1054 | .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); } | |
| 1055 | .afnpp-col-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 10px; } | |
| 1056 | .afnpp-col-label { font-weight: 700; color: #fff; font-size: 13px; } | |
| 1057 | .afnpp-col-sub { font-size: 10px; color: #7a8499; text-transform: uppercase; letter-spacing: 0.08em; font-weight: 700; } | |
| 1058 | .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; } | |
| 1059 | .afnpp-bar { position: absolute; inset: 0; z-index: 0; } | |
| 1060 | .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)); } | |
| 1061 | .afnpp-rank { position: relative; z-index: 1; color: #7a8499; font-size: 11px; font-weight: 700; } | |
| 1062 | .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; } | |
| 1063 | .afnpp-cnt { position: relative; z-index: 1; color: #fff; font-weight: 700; font-size: 14px; } | |
| 1064 | @media (max-width: 900px) { .afnpp-grid { grid-template-columns: 1fr 1fr; } } | |
| 1065 | </style> | |
| 1066 | CSS | |
| 1067 | } | |
| 1068 | ||
| 1069 | ||
| 1070 | # 4-column "Top Acquisition Paths" panel (referrer / utm source / utm campaign / landing page). | |
| 1071 | # Ported from ABForge customer /funnels.cgi so every site's admin funnel has parity. | |
| 1072 | # Uses anon_sessions columns present on all 7 sister-site schemas. | |
| 1073 | sub afnpp_render_panel { | |
| 1074 | my ($db, $dbh, $DB, $days) = @_; | |
| 1075 | $days = int($days || 30); | |
| 1076 | $days = 730 if $days > 730; | |
| 1077 | $days = 1 if $days < 1; | |
| 1078 | my $window = "DATE_SUB(NOW(), INTERVAL $days DAY)"; | |
| 1079 | my @dims = ( | |
| 1080 | { key=>'referer_host', label=>'Referrer', fb=>'(direct)' }, | |
| 1081 | { key=>'utm_source', label=>'UTM source', fb=>'(none)' }, | |
| 1082 | { key=>'utm_campaign', label=>'UTM campaign', fb=>'(none)' }, | |
| 1083 | { key=>'landing_path', label=>'Landing page', fb=>'/' }, | |
| 1084 | ); | |
| 1085 | my $cols_html = ''; | |
| 1086 | my $grand_total = 0; | |
| 1087 | my $ci = 0; | |
| 1088 | foreach my $d (@dims) { | |
| 1089 | $ci++; | |
| 1090 | my $col = $d->{key}; | |
| 1091 | my $fb = $d->{fb}; | |
| 1092 | my $fb_esc = $fb; $fb_esc =~ s/'/''/g; | |
| 1093 | my @rows = eval { $db->db_readwrite_multiple($dbh, qq~ | |
| 1094 | SELECT IFNULL(NULLIF($col,''), '$fb_esc') AS val, COUNT(*) AS n | |
| 1095 | FROM `${DB}`.anon_sessions | |
| 1096 | WHERE first_seen_at >= $window | |
| 1097 | GROUP BY IFNULL(NULLIF($col,''), '$fb_esc') | |
| 1098 | ORDER BY n DESC | |
| 1099 | LIMIT 3 | |
| 1100 | ~, $ENV{SCRIPT_NAME}, __LINE__) }; | |
| 1101 | @rows = () unless @rows; | |
| 1102 | my $max = 0; | |
| 1103 | foreach my $r (@rows) { $max = $r->{n} if $r->{n} > $max; $grand_total += $r->{n}; } | |
| 1104 | $max = 1 if $max < 1; | |
| 1105 | my $cells = ''; | |
| 1106 | my $rk = 0; | |
| 1107 | foreach my $r (@rows) { | |
| 1108 | $rk++; | |
| 1109 | my $v = defined $r->{val} ? $r->{val} : $fb; | |
| 1110 | $v =~ s/&/&/g; $v =~ s/</</g; $v =~ s/>/>/g; $v =~ s/"/"/g; | |
| 1111 | my $n = $r->{n} || 0; | |
| 1112 | my $pct = int(100 * ($r->{n} || 0) / $max + 0.5); | |
| 1113 | $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>~; | |
| 1114 | } | |
| 1115 | my $hue = 217; # brand hue (ShopCart blue) | |
| 1116 | my $nn = scalar(@rows); | |
| 1117 | $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; | |
| 1118 | $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>~; | |
| 1119 | } | |
| 1120 | 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>~; | |
| 1121 | } | |
| 1122 | ||
| 1123 | ||
| 1124 | # Discovered/managed funnels widget. Shows a list of funnels from the | |
| 1125 | # funnels table (auto + user) with per-row Hide + a Show-Hidden toggle | |
| 1126 | # above. Reads ?hide=N / ?unhide=N on the SAME script for state changes | |
| 1127 | # so no separate action handler is required. | |
| 1128 | sub _afnpp_funnels_widget { | |
| 1129 | my ($db, $dbh, $DB, $q) = @_; | |
| 1130 | ||
| 1131 | # Handle hide/unhide via the SAME URL, then let the caller redirect | |
| 1132 | # or just fall through and re-render. | |
| 1133 | my $hide_id = int($q->param('hide') || 0); | |
| 1134 | my $unhide_id = int($q->param('unhide') || 0); | |
| 1135 | if ($hide_id) { | |
| 1136 | $db->db_readwrite($dbh, qq~UPDATE `${DB}`.funnels SET is_hidden=1 WHERE id='$hide_id'~, | |
| 1137 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 1138 | } | |
| 1139 | if ($unhide_id) { | |
| 1140 | $db->db_readwrite($dbh, qq~UPDATE `${DB}`.funnels SET is_hidden=0 WHERE id='$unhide_id'~, | |
| 1141 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 1142 | } | |
| 1143 | ||
| 1144 | my $show_hidden = $q->param('show_hidden') ? 1 : 0; | |
| 1145 | ||
| 1146 | my @visible = $db->db_readwrite_multiple($dbh, qq~ | |
| 1147 | SELECT id, name, source, is_hidden, steps | |
| 1148 | FROM `${DB}`.funnels | |
| 1149 | WHERE is_active=1 AND (is_hidden=0 OR is_hidden IS NULL) | |
| 1150 | ORDER BY (source='auto') ASC, created_at DESC | |
| 1151 | LIMIT 40 | |
| 1152 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 1153 | ||
| 1154 | my $hidden_n = 0; | |
| 1155 | my @hidden; | |
| 1156 | if ($show_hidden) { | |
| 1157 | @hidden = $db->db_readwrite_multiple($dbh, qq~ | |
| 1158 | SELECT id, name, source, is_hidden, steps | |
| 1159 | FROM `${DB}`.funnels | |
| 1160 | WHERE is_active=1 AND is_hidden=1 | |
| 1161 | ORDER BY (source='auto') ASC, created_at DESC | |
| 1162 | LIMIT 40 | |
| 1163 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 1164 | $hidden_n = scalar @hidden; | |
| 1165 | } else { | |
| 1166 | my $r = $db->db_readwrite($dbh, qq~ | |
| 1167 | SELECT COUNT(*) AS n FROM `${DB}`.funnels WHERE is_active=1 AND is_hidden=1 | |
| 1168 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 1169 | $hidden_n = ($r && $r->{n}) ? int($r->{n}) : 0; | |
| 1170 | } | |
| 1171 | ||
| 1172 | return '' unless scalar(@visible) || $hidden_n; | |
| 1173 | ||
| 1174 | my $row_html = ''; | |
| 1175 | my $row_of = sub { | |
| 1176 | my ($f, $is_hidden) = @_; | |
| 1177 | my $name = $f->{name} // 'Untitled'; | |
| 1178 | $name =~ s/&/&/g; $name =~ s/</</g; $name =~ s/>/>/g; | |
| 1179 | my $badge = ''; | |
| 1180 | if (($f->{source} || '') eq 'auto') { | |
| 1181 | $badge = qq~<span class="afnpp-fpick-badge">AUTO</span>~; | |
| 1182 | } | |
| 1183 | # Show first + last step labels as breadcrumb | |
| 1184 | my $trail = ''; | |
| 1185 | if ($f->{steps} && $f->{steps} =~ /"label":"([^"]+)"/) { | |
| 1186 | my $first = $1; | |
| 1187 | my $last = $first; | |
| 1188 | while ($f->{steps} =~ /"label":"([^"]+)"/g) { $last = $1; } | |
| 1189 | $trail = ($first eq $last) ? $first : "$first → $last"; | |
| 1190 | } | |
| 1191 | my $action = $is_hidden | |
| 1192 | ? qq~<a class="afnpp-fpick-unhide" href="?unhide=$f->{id}&show_hidden=1">Unhide</a>~ | |
| 1193 | : 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>~; | |
| 1194 | return qq~<div class="afnpp-fpick-row"><div class="afnpp-fpick-name">$name $badge</div><div class="afnpp-fpick-trail">$trail</div>$action</div>~; | |
| 1195 | }; | |
| 1196 | foreach my $f (@visible) { $row_html .= $row_of->($f, 0); } | |
| 1197 | ||
| 1198 | my $hidden_html = ''; | |
| 1199 | if ($show_hidden && scalar @hidden) { | |
| 1200 | my $hr = ''; | |
| 1201 | foreach my $f (@hidden) { $hr .= $row_of->($f, 1); } | |
| 1202 | $hidden_html = qq~<div class="afnpp-fpick-hidden"><div class="afnpp-fpick-hidden-head">Hidden funnels <a href="?">hide list</a></div>$hr</div>~; | |
| 1203 | } | |
| 1204 | ||
| 1205 | my $chip = ''; | |
| 1206 | if (!$show_hidden && $hidden_n > 0) { | |
| 1207 | $chip = qq~<a class="afnpp-fpick-chip" href="?show_hidden=1">Show $hidden_n hidden</a>~; | |
| 1208 | } | |
| 1209 | ||
| 1210 | return qq~ | |
| 1211 | <div class="afnpp-fpick-wrap"> | |
| 1212 | <div class="afnpp-fpick-head"> | |
| 1213 | <div> | |
| 1214 | <div class="afnpp-fpick-title">Discovered & saved funnels</div> | |
| 1215 | <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> | |
| 1216 | </div> | |
| 1217 | $chip | |
| 1218 | </div> | |
| 1219 | <div class="afnpp-fpick-list">$row_html</div> | |
| 1220 | $hidden_html | |
| 1221 | </div> | |
| 1222 | <style> | |
| 1223 | .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; } | |
| 1224 | .afnpp-fpick-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; gap: 12px; } | |
| 1225 | .afnpp-fpick-title { font-size: 17px; font-weight: 700; color: #fff; } | |
| 1226 | .afnpp-fpick-sub { font-size: 12px; color: #8893a4; margin-top: 4px; } | |
| 1227 | .afnpp-fpick-list { display: flex; flex-direction: column; gap: 6px; } | |
| 1228 | .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); } | |
| 1229 | .afnpp-fpick-name { color: #e8edf2; font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 1230 | .afnpp-fpick-trail { color: #7a8499; font-family: 'JetBrains Mono','Consolas',monospace; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 1231 | .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; } | |
| 1232 | .afnpp-fpick-hide { color: #7a8499; display: inline-flex; align-items: center; justify-content: center; padding: 4px; border-radius: 6px; } | |
| 1233 | .afnpp-fpick-hide:hover { background: rgba(255,255,255,0.08); color: #fff; } | |
| 1234 | .afnpp-fpick-unhide { background: rgba(59,130,246,0.15); color: #93c5fd; border: 1px solid rgba(59,130,246,0.35); padding: 4px 12px; border-radius: 6px; font-size: 12px; font-weight: 700; text-decoration: none; } | |
| 1235 | .afnpp-fpick-unhide:hover { background: rgba(59,130,246,0.28); color: #fff; } | |
| 1236 | .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; } | |
| 1237 | .afnpp-fpick-chip:hover { background: rgba(148,163,184,0.18); color: #fff; } | |
| 1238 | .afnpp-fpick-hidden { margin-top: 14px; padding-top: 12px; border-top: 1px dashed rgba(148,163,184,0.20); } | |
| 1239 | .afnpp-fpick-hidden-head { font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; } | |
| 1240 | .afnpp-fpick-hidden-head a { color: #94a3b8; text-decoration: underline; font-weight: 500; text-transform: none; letter-spacing: normal; margin-left: 8px; } | |
| 1241 | </style>~; | |
| 1242 | } |