Diff -- /var/www/vhosts/3dshawn.com/abforge.3dshawn.com/admin_funnels.cgi
Diff

/var/www/vhosts/3dshawn.com/abforge.3dshawn.com/admin_funnels.cgi

added on local at 2026-07-09 23:20:46

Added
+1120
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to f78cc86de1e0
Restore this content →
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#======================================================================
12use strict;
13use warnings;
14
15use lib '/var/www/vhosts/3dshawn.com/abforge.3dshawn.com';
16use CGI;
17use MODS::DBConnect;
18use MODS::Login;
19use MODS::ABForge::Config;
20use MODS::ABForge::Wrapper;
21use MODS::AnonPaths;
22
23my $q = CGI->new;
24my $auth = MODS::Login->new;
25my $wrap = MODS::ABForge::Wrapper->new;
26my $db = MODS::DBConnect->new;
27my $cfg = MODS::ABForge::Config->new;
28my $DB = $cfg->settings('database_name');
29
30my $userinfo = $auth->login_verify();
31unless ($userinfo) {
32 print "Status: 302 Found\nLocation: /login.cgi\n\n";
33 exit;
34}
35unless ($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
40my %qparams = %{ $q->Vars };
41my ($range_opts, $preset_val, $range_from, $range_to) = MODS::AnonPaths::parse_range_params(\%qparams);
42my $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
46my $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.
52my $is_range_mode = ($range_opts->{from} && $range_opts->{to}) ? 1 : 0;
53my $range_from_sql = $is_range_mode ? "'$range_opts->{from} 00:00:00'" : '';
54my $range_to_sql = $is_range_mode ? "'$range_opts->{to} 23:59:59'" : '';
55# date_clause(column) -> WHERE fragment that covers the current window
56my $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.
66my $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
71my $entered = 0;
72my @bars;
73($entered, @bars) = MODS::AnonPaths::external_funnel_stages($db, $dbh, $range_opts);
74
75# Top biggest drop
76my $biggest = { step => '-', pct => 0 };
77for 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
83my $converted = $bars[-1]->{count};
84my $conv_pct = $entered ? sprintf('%.1f', 100.0 * $converted / $entered) : 0;
85
86# Daily signups sparkline data (silenced, like safe_count)
87my @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).
108my $arrow = chr(0xE2) . chr(0x86) . chr(0x92); # → as UTF-8 bytes
109my @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.
142my $selected_path_raw = $q->param('path') || '';
143$selected_path_raw =~ s/[\x00-\x1f]//g;
144$selected_path_raw = substr($selected_path_raw, 0, 1000);
145my @path_funnel_steps;
146my ($selected_total, $selected_signups, $selected_abandons) = (0, 0, 0);
147if (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
206sub _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 --------------------------------------------------------
218my $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
223my @stage_popovers = map { MODS::AnonPaths::render_stage_popover($_) } @bars;
224my $svg = render_svg_funnel(\@bars, \@stage_popovers);
225my $sparkbars = '';
226my $max_spark = 1;
227foreach my $r (@sparks) { $max_spark = $r->{n} if $r->{n} > $max_spark; }
228foreach 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
233my $step_cards = '';
234my $idx = 0;
235foreach 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 &middot; $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">&middot;</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
268my $css = afnl_css() . MODS::AnonPaths::popover_css();
269my $hero = afnl_hero('Conversion Funnel',
270 'Where visitors leak between landing and signing up. <strong>Find the leak, plug the leak.</strong>');
271my $range_controls = MODS::AnonPaths::render_range_controls($preset_val, $range_from, $range_to);
272
273my $entered_fmt = fmt_num($entered);
274my $converted_fmt = fmt_num($converted);
275my $biggest_pct = $biggest->{pct};
276my $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.
280my $is_drill_mode = length($selected_path_early) ? 1 : 0;
281my $drill_disp = h_esc($selected_path_early);
282my $funnel_title_lbl = $is_drill_mode
283 ? qq{Drill-in: <span class="afnl-drill-path">$drill_disp</span>}
284 : 'Platform funnel';
285my $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">&larr; 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.';
288my $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 &mdash; 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
308my $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_render_panel($db, $dbh, $DB, $days) }
327${\ admin_top_paths_panel(\@top_paths, $entered, $selected_path_raw, $arrow, $days) }
328${\ admin_path_funnel_drilldown(\@path_funnel_steps, $selected_path_raw, $selected_total, $selected_signups, $selected_abandons, $arrow) }
329
330<div class="afnl-stats-grid">
331 <div class="afnl-stat orb-orange">
332 <div class="lbl">Visitors landed</div><div class="val">$entered_fmt</div>
333 <div class="sub">Unique anonymous sessions in the last $days days</div>
334 </div>
335 <div class="afnl-stat orb-pink">
336 <div class="lbl">Signed up</div><div class="val">$converted_fmt</div>
337 <div class="sub">Visitors who completed signup</div>
338 </div>
339 <div class="afnl-stat orb-violet">
340 <div class="lbl">Land &rarr; signup rate</div><div class="val grad-text">$conv_pct%</div>
341 <div class="sub">Of every 100 visitors</div>
342 </div>
343 <div class="afnl-stat orb-cyan">
344 <div class="lbl">Biggest leak</div><div class="val">$biggest_pct%</div>
345 <div class="sub">at <strong>$biggest_step</strong></div>
346 </div>
347</div>
348
349$funnel_section
350
351<div class="afnl-step-grid">
352 <div class="afnl-step-header">
353 <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>
354 <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>
355 <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>
356 <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>
357 <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>
358 </div>
359 $step_cards
360</div>
361
362<script>
363window.addEventListener('DOMContentLoaded', function(){
364 document.querySelectorAll('.fnl-band').forEach(function(el, i){
365 setTimeout(function(){ el.classList.add('lit'); }, 100 + i * 110);
366 });
367 document.querySelectorAll('.afnl-stat').forEach(function(el, i){
368 setTimeout(function(){ el.classList.add('lit'); }, 60 + i * 80);
369 });
370 document.querySelectorAll('.afnl-step').forEach(function(el, i){
371 setTimeout(function(){ el.classList.add('lit'); }, 320 + i * 70);
372 });
373
374 /* ---- Cursor-follow popovers ----
375 For every popover host (.ap-pop-host / .afnl-hit), reposition the
376 inner .ap-pop to follow the mouse, anchored just to the right of
377 the cursor. Clamped to viewport. Falls back to the CSS-positioned
378 popover if JS fails. */
379 var FOLLOW_OFFSET = 18;
380 document.addEventListener('mousemove', function(e){
381 /* Popovers are pointer-events:none so the cursor passes through them.
382 Whatever's under the popover catches the event, so the popover
383 still tracks the cursor even when overlapping. */
384 var host = e.target.closest && e.target.closest('.ap-pop-host, .afnl-hit');
385 if (!host) return;
386 var pop = host.querySelector(':scope > .ap-pop');
387 if (!pop) return;
388 /* Wait one frame so the popover has dimensions to read */
389 if (pop.offsetWidth === 0) {
390 requestAnimationFrame(function(){ positionPop(pop, e.clientX, e.clientY); });
391 } else {
392 positionPop(pop, e.clientX, e.clientY);
393 }
394 }, { passive: true });
395
396 function positionPop(pop, cx, cy){
397 var pw = pop.offsetWidth, ph = pop.offsetHeight;
398 var vw = window.innerWidth, vh = window.innerHeight;
399 var x = cx + FOLLOW_OFFSET;
400 var y = cy - ph / 2;
401 /* If would overflow right, flip to left of cursor */
402 if (x + pw > vw - 8) x = cx - pw - FOLLOW_OFFSET;
403 if (x < 8) x = 8;
404 if (y < 8) y = 8;
405 if (y + ph > vh - 8) y = vh - ph - 8;
406 pop.style.position = 'fixed';
407 pop.style.left = x + 'px';
408 pop.style.top = y + 'px';
409 pop.style.transform = 'none';
410 }
411});
412</script>
413~;
414
415print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n";
416$wrap->render({
417 userinfo => $userinfo,
418 page_key => 'admin_funnels',
419 title => 'Conversion Funnel',
420 body => $body,
421});
422
423#======================================================================
424# Helpers
425#======================================================================
426sub safe_count {
427 my ($db, $dbh, $sql) = @_;
428 # DBConnect prints DB errors to STDOUT, which would corrupt the CGI
429 # header stream and 500 the page. Capture+discard during the query.
430 local *OLDOUT;
431 open(OLDOUT, '>&', \*STDOUT) or do {};
432 open(STDOUT, '>', '/dev/null') or do {};
433 my $r = eval { $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__) };
434 open(STDOUT, '>&', \*OLDOUT) or do {};
435 return 0 unless $r && defined $r->{n};
436 return int($r->{n});
437}
438
439sub fmt_num {
440 my ($n) = @_; $n = int($n || 0);
441 1 while $n =~ s/(\d)(\d{3})(?!\d)/$1,$2/;
442 return $n;
443}
444sub h_esc {
445 my ($s) = @_; return '' unless defined $s;
446 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g; $s =~ s/"/&quot;/g;
447 return $s;
448}
449
450sub _site_funnel_palette {
451 # HSL hue array per brand. Reads from HTTP_HOST so the same code
452 # cloned across sister sites picks the right palette automatically.
453 my $host = lc($ENV{HTTP_HOST} || $ENV{SERVER_NAME} || '');
454 return [15, 28, 350, 340, 320, 305] if $host =~ /taskforge/; # warm reds/pinks
455 return [22, 35, 55, 100, 150, 175] if $host =~ /abforge/; # orange -> amber -> green
456 return [60, 85, 130, 175, 200, 215] if $host =~ /(^|\.)shop\./; # olive/green -> teal -> sky
457 return [160, 170, 180, 190, 200, 210] if $host =~ /repricer/; # emerald greens
458 return [265, 275, 285, 295, 310, 325] if $host =~ /affiliate/; # violet/purple
459 return [185, 195, 205, 215, 225, 240] if $host =~ /(crm|contactforge)/; # cyan -> blue -> teal
460 return [22, 320, 285, 250, 200, 168, 140, 90]; # default: original rainbow
461}
462
463sub step_color {
464 my ($i, $n) = @_;
465 my $hues = _site_funnel_palette();
466 return "hsl($hues->[$i % scalar @$hues], 92%, 60%)";
467}
468sub step_color_dark {
469 my ($i, $n) = @_;
470 my $hues = _site_funnel_palette();
471 return "hsl($hues->[$i % scalar @$hues], 80%, 38%)";
472}
473sub step_color_deep {
474 my ($i, $n) = @_;
475 my $hues = _site_funnel_palette();
476 return "hsl($hues->[$i % scalar @$hues], 70%, 18%)";
477}
478
479# Pick a small inline icon based on the page-path the stage represents.
480# Returns just the inner <path> data — caller wraps in <svg>. Tiny 24x24
481# viewBox using lucide-style stroke paths.
482sub icon_for_label {
483 my ($lbl) = @_;
484 my $l = lc($lbl // '');
485 return '<path d="M3 9.5l9-6.5 9 6.5V21H3z"/><path d="M9 21V12h6v9"/>'
486 if $l eq '/' || $l eq '/index.cgi' || $l eq '/home' || $l =~ m{^/index};
487 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"/>'
488 if $l =~ /pricing|plan|billing|tier|cost/;
489 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"/>'
490 if $l =~ /signup|register|join|trial|get-?started/;
491 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"/>'
492 if $l =~ /login|signin|auth/;
493 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"/>'
494 if $l =~ /blog|article|post|news/;
495 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"/>'
496 if $l =~ /feature|product|highlight|spec/;
497 return '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>'
498 if $l =~ /signed up|success|complete|thanks|welcome|paid|checkout/;
499 return '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>'
500 if $l =~ /search|find/;
501 return '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>'
502 if $l =~ /pending|active|recent/;
503 # default: page-with-fold icon
504 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"/>';
505}
506
507sub render_svg_funnel {
508 my ($bars, $stage_popovers_ref) = @_;
509 my $n = scalar @$bars;
510 return '<div class="afnl-empty">No data yet.</div>' unless $n;
511
512 # Layout: funnel cone lives in the LEFT ~48% of the canvas. Right half
513 # is for side labels + leak chips. A curved colored arrow links each
514 # band to its label so the eye can follow which is which.
515 my $W = 1200;
516 my $FUNNEL_W = 560; # max width the cone is allowed
517 my $FUNNEL_CX = int($FUNNEL_W / 2); # cone centered in the funnel area
518 my $LABEL_X = 660; # where side label text starts
519 my $BULLET_X = $LABEL_X - 20; # where the arrow bullet sits
520 my $H_band = 96;
521 my $H_gap = 22;
522 my $top_pad = 16;
523 my $H = $top_pad + $n * $H_band + ($n - 1) * $H_gap + 32;
524
525 my $defs = '';
526 for my $i (0..$n-1) {
527 my $c1 = step_color($i, $n);
528 my $c2 = step_color_dark($i, $n);
529 # Main fill: bright brand color across the whole band, fading
530 # slightly toward the dark brand variant only at the arrow tip.
531 # No near-black edge stops -- keeps the brand color forward.
532 $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>~;
533 # Vertical overlay: just a top sheen highlight. No bottom shadow.
534 $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>~;
535 # 3D shadow color (darker variant for the back/under polygon)
536 $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>~;
537 # Arrowhead marker per stage, colored to match the band
538 $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>~;
539 }
540 $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>~;
541 $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>~;
542 # Radial aurora behind the cone
543 $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>~;
544 # Glow filter
545 $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>~;
546 # Shine sweep gradient (animated via CSS)
547 $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>~;
548
549 my $entered = $bars->[0]->{count} || 1;
550 my $min_pct = 10;
551 my @widths;
552 for my $i (0..$n-1) {
553 my $count = $bars->[$i]->{count} || 0;
554 my $pct = $entered ? (100.0 * $count / $entered) : 0;
555 my $vw = ($pct < $min_pct) ? $min_pct : $pct;
556 push @widths, int($FUNNEL_W * $vw / 100);
557 }
558
559 my $bands = '';
560 my $conn = '';
561 my $hits = ''; # HTML hit-area overlays for rich popovers
562 for my $i (0..$n-1) {
563 my $bw = $widths[$i];
564 my $x = int($FUNNEL_CX - $bw/2);
565 my $y = $top_pad + $i * ($H_band + $H_gap);
566 my $count = fmt_num($bars->[$i]->{count});
567 my $pct = $bars->[$i]->{pct};
568 my $label = h_esc($bars->[$i]->{label});
569 my $hv = $bars->[$i]->{hover} || {};
570 my $top_ref = ($hv->{referrers} && @{$hv->{referrers}}) ? $hv->{referrers}->[0]->{host} . " (" . $hv->{referrers}->[0]->{n} . ")" : '-';
571 my $top_utm = ($hv->{utm_sources} && @{$hv->{utm_sources}}) ? $hv->{utm_sources}->[0]->{src} . " (" . $hv->{utm_sources}->[0]->{n} . ")" : '-';
572 my $top_nxt = ($hv->{next_pages} && @{$hv->{next_pages}}) ? $hv->{next_pages}->[0]->{path} . " (" . $hv->{next_pages}->[0]->{n} . ")" : '-';
573 my $sn_n = $hv->{signups} || 0;
574 my $abn_n = $hv->{abandons} || 0;
575 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";
576 $tt =~ s/&/&amp;/g; $tt =~ s/</&lt;/g; $tt =~ s/>/&gt;/g;
577
578 my $stage_color = step_color($i, $n);
579
580 # ----- 3D arrow-ribbon shape: rounded-left body with an arrow
581 # point on the right that aims at the side label.
582 my $tip = 26; # depth of the arrow point
583 my $body_r = 16; # corner radius on the left
584 my $body_right = $x + $bw - $tip; # where the body ends and the tip starts
585 my $tip_x = $x + $bw; # arrow point x
586 my $tip_y = $y + int($H_band / 2);
587
588 my $arrow_d = "M $x,${\ ($y + $body_r) } "
589 . "A $body_r,$body_r 0 0 1 ${\ ($x + $body_r) },$y "
590 . "L $body_right,$y "
591 . "L $tip_x,$tip_y "
592 . "L $body_right,${\ ($y + $H_band) } "
593 . "L ${\ ($x + $body_r) },${\ ($y + $H_band) } "
594 . "A $body_r,$body_r 0 0 1 $x,${\ ($y + $H_band - $body_r) } "
595 . "Z";
596
597 # 3D shadow polygon: same shape, offset down/right, darker, behind
598 my $shadow_dx = 4; my $shadow_dy = 6;
599 my $shadow_d = $arrow_d;
600 $shadow_d =~ s/(\d+(?:\.\d+)?),(\d+(?:\.\d+)?)/($1+$shadow_dx).",".($2+$shadow_dy)/ge;
601
602 $bands .= qq~<g class="fnl-band" data-step="$i"><title>$tt</title>~;
603 # Shadow first (below)
604 $bands .= qq~<path d="$shadow_d" fill="rgba(0,0,0,0.45)" filter="blur(2px)" opacity="0.85"/>~;
605 # Main fill
606 $bands .= qq~<path class="fnl-band-rect" d="$arrow_d" fill="url(#afnlG$i)"/>~;
607 # Vertical overlay: top sheen + bottom shadow
608 $bands .= qq~<path d="$arrow_d" fill="url(#afnlGtop$i)" pointer-events="none"/>~;
609 # Inner top highlight stripe
610 my $hy = $y + 6;
611 my $hw = $bw - 32 - $tip;
612 my $hx = $x + 16;
613 $bands .= qq~<rect x="$hx" y="$hy" rx="3" ry="3" width="$hw" height="2.5" fill="white" opacity="0.32" pointer-events="none"/>~;
614 # Animated shine sweep across the band (clipped to the arrow shape)
615 my $clip_id = "afnlClip$i";
616 $defs .= qq[<clipPath id="$clip_id"><path d="$arrow_d"/></clipPath>];
617 my $shine_w = 90;
618 my $shine_from = $x - $shine_w;
619 my $shine_to = $x + $bw;
620 my $shine_delay = sprintf('%.2f', $i * 0.45);
621 $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>~;
622
623 # ----- Icon inside the band, left side (in the rounded body, not the tip)
624 if ($bw >= 130) {
625 my $icon_d = icon_for_label($bars->[$i]->{label});
626 my $ix = $x + 22;
627 my $iy = $y + int($H_band/2) - 13;
628 $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>~;
629 }
630
631 # ----- Inside the band: count (big) + percent (small under it),
632 # centered in the body portion (excluding the arrow tip).
633 my $body_cx = int($x + ($bw - $tip) / 2);
634 my $cy_count = $y + 44;
635 my $count_size = ($bw > 240) ? 26 : (($bw > 130) ? 20 : 15);
636 $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>~;
637 my $cy_pct = $y + 66;
638 if ($bw > 110) {
639 $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>~;
640 }
641
642 # ----- Curved animated arrow from arrow tip -> bullet -> label
643 my $arrow_start_x = $tip_x + 4;
644 my $line_y = $tip_y;
645 my $midx = int(($arrow_start_x + $BULLET_X) / 2);
646 my $arrow_path = "M $arrow_start_x,$line_y Q $midx,$line_y ${\ ($BULLET_X - 6) },$line_y";
647 $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"/>~;
648 # Pulsing bullet at the label end
649 $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"/>~;
650 $bands .= qq~<circle cx="$BULLET_X" cy="$line_y" r="3.5" fill="$stage_color"/>~;
651
652 # ----- Side label (page path + meta)
653 my $lbl_y = $y + 30;
654 my $lbl_y2 = $y + 50;
655 $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>~;
656 $bands .= qq~<text x="$LABEL_X" y="$lbl_y2" font-family="-apple-system,sans-serif" font-size="11.5" fill="#8893a4">$count visitors &#183; ${pct}% of landed</text>~;
657 $bands .= qq~</g>~;
658
659 # ----- Leak indicator: pinned BELOW THIS BAND'S label, with a
660 # downward red arrow suggesting drainage from the source band.
661 if ($i < $n - 1) {
662 my $drop_pct = $bars->[$i+1]->{drop_pct} || 0;
663 if ($drop_pct > 0.5) {
664 my $lost_n = $bars->[$i]->{count} - $bars->[$i+1]->{count};
665 $lost_n = 0 if $lost_n < 0;
666 my $cur_lbl = h_esc($bars->[$i]->{label});
667 my $next_lbl = h_esc($bars->[$i+1]->{label});
668 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.";
669 $leak_tt =~ s/&/&amp;/g; $leak_tt =~ s/</&lt;/g; $leak_tt =~ s/>/&gt;/g;
670 my $chip_y = $y + 64;
671 my $chip_w = 210;
672 my $leak_lbl = "&#9760; -${drop_pct}% leak &middot; ${lost_n} lost";
673 # Drain emerges from just inside the arrow-tip and curves down to the chip
674 my $drain_start_x = $tip_x - 2;
675 my $drain_start_y = $y + $H_band - 8;
676 my $drain_end_y = $chip_y + 9;
677 $bands .= qq~<g class="afnl-leak"><title>$leak_tt</title>~;
678 $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)"/>~;
679 $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)"/>~;
680 $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>~;
681 $bands .= qq~</g>~;
682 }
683 }
684
685 # ----- Soft trapezoid between band i body and band i+1 body
686 # (uses body widths, NOT including arrow tip, so the cone shape
687 # flows visually down the funnel.)
688 if ($i < $n - 1) {
689 my $bw2 = $widths[$i+1];
690 my $body_w1 = $bw - $tip;
691 my $body_w2 = $bw2 - $tip;
692 my $bx1 = $x;
693 my $bx1r = $x + $body_w1;
694 my $bx2 = int($FUNNEL_CX - $body_w2/2);
695 my $bx2r = $bx2 + $body_w2;
696 my $y2a = $y + $H_band;
697 my $y2b = $y + $H_band + $H_gap;
698 my $c = step_color($i, $n);
699 $conn .= qq~<polygon points="$bx1,$y2a $bx1r,$y2a $bx2r,$y2b $bx2,$y2b" fill="$c" opacity="0.18"/>~;
700 # Animated downward chevron showing flow (centered on body, not tip)
701 my $body_cx_now = int($x + ($bw - $tip) / 2);
702 my $chev_y = int(($y2a + $y2b) / 2);
703 $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"/>~;
704 }
705
706 # ----- HTML hit-area overlay (whole row) for rich popover hover
707 my $row_top_pct = sprintf('%.3f', 100 * $y / $H);
708 my $row_h_pct = sprintf('%.3f', 100 * $H_band / $H);
709 my $popover_html = ($stage_popovers_ref && $stage_popovers_ref->[$i]) ? $stage_popovers_ref->[$i] : '';
710 $hits .= qq~<div class="afnl-hit" data-step="$i" style="top:${row_top_pct}%; height:${row_h_pct}%;">$popover_html</div>~;
711 }
712 # Aurora background rect sits behind everything in the SVG
713 my $aurora = qq~<rect x="0" y="0" width="$W" height="$H" fill="url(#afnlAurora)" pointer-events="none"/>~;
714 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>~;
715}
716
717sub afnl_hero {
718 my ($title, $sub) = @_;
719 my $t = h_esc($title);
720 return qq~
721 <div class="afnl-hero">
722 <div class="afnl-hero-orb orb-a"></div>
723 <div class="afnl-hero-orb orb-b"></div>
724 <div class="afnl-hero-orb orb-c"></div>
725 <div class="afnl-hero-grid"></div>
726 <div class="afnl-hero-content">
727 <div class="afnl-hero-pill">
728 <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>
729 <span>Admin &middot; Platform Funnel</span>
730 </div>
731 <h1 class="afnl-hero-title">$t</h1>
732 <p class="afnl-hero-sub">$sub</p>
733 </div>
734 </div>~;
735}
736
737sub admin_top_paths_panel {
738 my ($rows, $total_signups, $selected, $arrow, $days) = @_;
739 return '' unless ref($rows) eq 'ARRAY';
740 if (!scalar @$rows) {
741 return qq~
742 <div class="afnl-dom-wrap">
743 <div class="afnl-dom-head">
744 <div>
745 <div class="afnl-dom-title">Top Acquisition Paths</div>
746 <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>
747 </div>
748 </div>
749 <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>
750 </div>~;
751 }
752
753 my $max = 0;
754 foreach my $r (@$rows) { $max = $r->{n} if $r->{n} > $max; }
755 $max = 1 if $max < 1;
756
757 my $cells = '';
758 my $rank = 0;
759 foreach my $r (@$rows) {
760 $rank++;
761 my $path_disp = h_esc($r->{path_sequence});
762 my $cnt = fmt_num($r->{n});
763 my $sn = fmt_num($r->{signups} || 0);
764 my $an = fmt_num($r->{abandons} || 0);
765 my $bar_pct = int(100.0 * $r->{n} / $max + 0.5);
766 my $is_sel = (defined($selected) && length($selected) && $selected eq $r->{path_sequence}) ? 'is-selected' : '';
767 my $share_pct = $total_signups ? sprintf('%.1f', 100.0 * $r->{n} / $total_signups) : '0.0';
768 # URL-encode the path for the link
769 my $enc = $r->{path_sequence}; $enc =~ s/([^A-Za-z0-9])/sprintf("%%%02X", ord($1))/eg;
770 $cells .= qq~
771 <a href="?days=$days&amp;path=$enc" class="afnl-path-row $is_sel" style="--pct:${bar_pct}%">
772 <div class="afnl-path-rank">$rank</div>
773 <div class="afnl-path-name" title="$path_disp">$path_disp</div>
774 <div class="afnl-path-stat ok"><span class="dot"></span>$sn</div>
775 <div class="afnl-path-stat bad"><span class="dot"></span>$an</div>
776 <div class="afnl-path-share">${share_pct}%</div>
777 <div class="afnl-path-cnt">$cnt</div>
778 </a>~;
779 }
780
781 return qq~
782 <div class="afnl-dom-wrap">
783 <div class="afnl-dom-head">
784 <div>
785 <div class="afnl-dom-title">Top Acquisition Paths</div>
786 <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>
787 </div>
788 <div class="afnl-path-legend">
789 <span><span class="dot ok"></span> signed up</span>
790 <span><span class="dot bad"></span> abandoned</span>
791 </div>
792 </div>
793 <div class="afnl-path-list">$cells</div>
794 </div>~;
795}
796
797sub admin_path_funnel_drilldown {
798 my ($steps, $path_raw, $total, $signups, $abandons, $arrow) = @_;
799 return '' unless ref($steps) eq 'ARRAY' && scalar @$steps;
800 return '' unless length($path_raw // '');
801
802 my $path_disp = h_esc($path_raw);
803 my $total_fmt = fmt_num($total);
804 my $signup_fmt = fmt_num($signups);
805 my $aban_fmt = fmt_num($abandons);
806 my $continued = $total - $signups - $abandons;
807 $continued = 0 if $continued < 0;
808 my $cont_fmt = fmt_num($continued);
809
810 my $entered = $total || 1;
811 my $bars = '';
812 my $idx = 0;
813 foreach my $s (@$steps) {
814 $idx++;
815 my $step_path = h_esc($s->{step});
816 my $r = $s->{reached};
817 my $sn = $s->{signup};
818 my $an = $s->{abandon};
819 my $reached_pct = $entered ? sprintf('%.1f', 100 * $r / $entered) : '0.0';
820 my $bar_w = $entered ? int(100 * $r / $entered) : 0;
821 $bar_w = 4 if $bar_w < 4 && $r > 0;
822 my $reached_n = fmt_num($r);
823 my $sn_n = fmt_num($sn);
824 my $an_n = fmt_num($an);
825 my $sn_block = $sn ? qq~<span class="step-marker ok" title="$sn signed up here">&#10003; $sn_n</span>~ : '';
826 my $an_block = $an ? qq~<span class="step-marker bad" title="$an abandoned here">&#10005; $an_n</span>~ : '';
827 $bars .= qq~
828 <div class="afnl-path-step">
829 <div class="afnl-path-step-head">
830 <div class="afnl-path-step-num">$idx</div>
831 <div class="afnl-path-step-name" title="$step_path">$step_path</div>
832 <div class="afnl-path-step-meta">$reached_n &middot; ${reached_pct}%</div>
833 </div>
834 <div class="afnl-path-step-bar"><div class="afnl-path-step-fill" style="width:${bar_w}%"></div></div>
835 <div class="afnl-path-step-markers">$sn_block $an_block</div>
836 </div>~;
837 }
838
839 return qq~
840 <div class="afnl-pathdrill">
841 <div class="afnl-pathdrill-head">
842 <div class="lbl">Path drill-in</div>
843 <div class="path-string">$path_disp</div>
844 <div class="afnl-pathdrill-stats">
845 <span>Sessions <strong>$total_fmt</strong></span>
846 <span class="ok">Signed up <strong>$signup_fmt</strong></span>
847 <span class="bad">Abandoned <strong>$aban_fmt</strong></span>
848 <span class="dim">Still active <strong>$cont_fmt</strong></span>
849 </div>
850 </div>
851 <div class="afnl-pathdrill-steps">$bars</div>
852 </div>~;
853}
854
855sub afnl_css {
856 return <<'CSS';
857<style>
858.afnl-hero.afnl-hero {
859 position: relative; margin: -8px -8px 24px; padding: 68px 140px 72px 40px !important;
860 border-radius: 22px; overflow: hidden;
861 background:
862 radial-gradient(900px 280px at 14% 0%, rgba(249,115,22,0.40), transparent 70%),
863 radial-gradient(700px 240px at 88% 90%, rgba(168,85,247,0.44), transparent 70%),
864 linear-gradient(180deg, #0d1219 0%, #060912 100%);
865 border: 1px solid rgba(255,255,255,0.06);
866 box-shadow: 0 28px 60px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.04);
867}
868.afnl-hero-grid {
869 position: absolute; inset: 0;
870 background-image:
871 linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px),
872 linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px);
873 background-size: 36px 36px;
874 mask-image: radial-gradient(ellipse at 50% 30%, black 0%, transparent 70%);
875 pointer-events: none;
876}
877.afnl-hero-orb { position: absolute; border-radius: 50%; filter: blur(60px); opacity: 0.85; pointer-events: none; animation: afnlOrb 12s ease-in-out infinite; }
878.afnl-hero-orb.orb-a { width: 320px; height: 320px; left: -60px; top: -80px; background: radial-gradient(circle, #f97316 0%, transparent 70%); }
879.afnl-hero-orb.orb-b { width: 380px; height: 380px; right: -100px; top: -40px; background: radial-gradient(circle, #a855f7 0%, transparent 70%); animation-delay: -3s; }
880.afnl-hero-orb.orb-c { width: 260px; height: 260px; left: 40%; bottom: -120px; background: radial-gradient(circle, #22d3ee 0%, transparent 70%); animation-delay: -7s; }
881@keyframes afnlOrb { 0%, 100% { transform: translate(0,0) scale(1); } 50% { transform: translate(20px,-16px) scale(1.05); } }
882.afnl-hero-content { position: relative; z-index: 1; max-width: 760px; }
883.afnl-hero-pill { display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px; background: rgba(249,115,22,0.16); border: 1px solid rgba(249,115,22,0.42); border-radius: 999px; color: #ffae6e; font-size: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 24px; }
884.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); }
885.afnl-hero-sub { margin: 0; color: rgba(207,214,224,0.85); font-size: 17px; line-height: 1.55; }
886.afnl-hero-sub strong { color: #fff; }
887.afnl-toolbar { display: flex; justify-content: flex-start; margin: 0 0 18px; flex-wrap: wrap; gap: 12px; }
888.afnl-toolbar-form { display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap; }
889.afnl-field { display: inline-flex; flex-direction: column; gap: 4px; }
890.afnl-field-lbl { font-size: 11px; color: #6f7787; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
891.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; }
892.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, #f97316 0%, #ea580c 100%); color: #fff; border: none; box-shadow: 0 6px 18px rgba(249,115,22,0.32); }
893.afnl-toolbar-btn:hover { transform: translateY(-1px); box-shadow: 0 10px 24px rgba(249,115,22,0.46); }
894.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; }
895.afnl-toolbar-btn.ghost:hover { background: rgba(255,255,255,0.07); }
896.afnl-stats-grid { display: grid; gap: 16px; margin: 0 0 24px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
897.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); }
898.afnl-stat.lit { opacity: 1; transform: none; }
899.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; }
900.afnl-stat.orb-orange::after { background: #f97316; }
901.afnl-stat.orb-pink::after { background: #ec4899; }
902.afnl-stat.orb-violet::after { background: #a855f7; }
903.afnl-stat.orb-cyan::after { background: #22d3ee; }
904.afnl-stat .lbl { font-size: 11px; color: #8893a4; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
905.afnl-stat .val { font-size: 32px; font-weight: 800; color: #fff; margin: 8px 0 6px; line-height: 1; position: relative; }
906.afnl-stat .val.grad-text { background: linear-gradient(90deg, #f97316, #ec4899, #a855f7); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
907.afnl-stat .sub { font-size: 12px; color: #7a8392; position: relative; }
908.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; }
909.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; }
910.afnl-viz-head { position: relative; margin-bottom: 14px; }
911.afnl-viz-title { font-size: 18px; font-weight: 700; color: #fff; }
912.afnl-viz-sub { font-size: 12px; color: #8893a4; margin-top: 4px; }
913.afnl-drill-path { font-family: 'SF Mono', Menlo, monospace; font-size: 14px; color: #c4a3f0; word-break: break-all; }
914.afnl-clear { color: #5aa9ff; text-decoration: none; font-weight: 600; margin-left: 6px; }
915.afnl-clear:hover { color: #7cc1ff; text-decoration: underline; }
916.afnl-svg-stage { position: relative; padding: 6px 0 0; isolation: isolate; }
917.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; }
918.afnl-svg { width: 100%; height: auto; max-height: 760px; display: block; position: relative; z-index: 1; }
919.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; }
920.fnl-band.lit { opacity: 1; transform: scaleX(1); }
921.fnl-band-rect { transition: filter 0.25s ease; }
922.fnl-band:hover .fnl-band-rect { filter: brightness(1.15) drop-shadow(0 0 16px rgba(168,85,247,0.6)); }
923.fnl-band-icon { opacity: 0.92; }
924.fnl-shine { mix-blend-mode: screen; }
925/* Animated curved arrow from band to label */
926.fnl-arrow { stroke-dasharray: 6 8; animation: afnl-dash-flow 1.4s linear infinite; }
927.fnl-bullet { animation: afnl-bullet-pulse 1.8s ease-in-out infinite; transform-origin: center; transform-box: fill-box; }
928/* Animated downward chevron between bands */
929.fnl-chev { animation: afnl-chev-float 1.6s ease-in-out infinite; transform-origin: center; transform-box: fill-box; }
930/* Leak: drain line + chip pulse */
931.fnl-leak-drain { stroke-dasharray: 3 4; animation: afnl-leak-drain 1.1s linear infinite; }
932.fnl-leak-chip { animation: afnl-leak-pulse 2.4s ease-in-out infinite; transform-origin: center; transform-box: fill-box; }
933@keyframes afnl-dash-flow { to { stroke-dashoffset: -28; } }
934@keyframes afnl-bullet-pulse { 0%, 100% { transform: scale(1); opacity: 0.35; } 50% { transform: scale(1.5); opacity: 0.05; } }
935@keyframes afnl-chev-float { 0%, 100% { transform: translateY(-2px); opacity: 0.45; } 50% { transform: translateY(3px); opacity: 0.9; } }
936@keyframes afnl-leak-drain { to { stroke-dashoffset: -14; } }
937@keyframes afnl-leak-pulse { 0%, 100% { opacity: 0.85; } 50% { opacity: 1; filter: drop-shadow(0 0 8px rgba(239,68,68,0.55)); } }
938@keyframes afnl-aurora { 0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.9; } 50% { transform: translate(20px, -10px) scale(1.05); opacity: 1; } }
939/* HTML hit-area overlay (full row width over each band) */
940.afnl-hit-layer { position: absolute; inset: 0; pointer-events: none; z-index: 2; }
941.afnl-hit { position: absolute; left: 0; right: 0; pointer-events: auto; cursor: help; }
942.afnl-hit .ap-pop { left: 50%; top: 100%; transform: translate(-50%, -6px); }
943.afnl-hit:hover .ap-pop, .afnl-hit:focus-within .ap-pop { opacity: 1; transform: translate(-50%, 8px); }
944.afnl-hit:hover ~ .afnl-svg .fnl-band[data-step] { } /* placeholder */
945/* Top Acquisition Channels (admin) */
946.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; }
947.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; }
948.afnl-dom-head { position: relative; margin-bottom: 16px; max-width: 720px; }
949.afnl-dom-title { font-size: 17px; font-weight: 700; color: #fff; }
950.afnl-dom-sub { font-size: 12px; color: #8893a4; margin-top: 4px; line-height: 1.55; }
951.afnl-dom-list { position: relative; display: flex; flex-direction: column; gap: 4px; }
952.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; }
953.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); }
954.afnl-dom-row > * { position: relative; z-index: 1; }
955.afnl-dom-rank { color: #6f7787; font-weight: 700; font-size: 11px; text-align: center; }
956.afnl-dom-name { color: #cfd6e0; font-family: 'SF Mono', Menlo, monospace; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
957.afnl-dom-share { color: #8893a4; font-size: 11px; text-align: right; font-weight: 600; }
958.afnl-dom-cnt { color: #fff; font-weight: 700; font-size: 13px; text-align: right; }
959
960.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; }
961.afnl-spark-title { font-size: 15px; font-weight: 700; color: #fff; }
962.afnl-spark-sub { font-size: 12px; color: #7a8392; margin-top: 3px; }
963.afnl-spark-wrap { display: flex; align-items: flex-end; gap: 4px; height: 80px; margin-top: 14px; }
964.afnl-spark-col { flex: 1; min-width: 6px; display: flex; align-items: flex-end; }
965.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; }
966.afnl-spark-col:hover .afnl-spark-bar { background: linear-gradient(180deg, #fb9c4d 0%, #f072ad 100%); }
967.afnl-step-grid { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; }
968.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; }
969.afnl-step-header .afnl-step-h-drop { text-align: right; }
970.afnl-step-header .col-h { position: relative; cursor: help; }
971.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; }
972.afnl-step-header .col-h:hover::after, .afnl-step-header .col-h:focus::after { opacity: 1; transform: translateY(0); }
973.afnl-step-header .col-h.afnl-step-h-drop::after { right: 0; left: auto; }
974.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; }
975.afnl-step.lit { opacity: 1; transform: none; }
976.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; }
977.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; }
978.afnl-step > * { position: relative; z-index: 1; }
979.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); }
980.afnl-step:hover::after { opacity: 0.6; }
981.afnl-step:hover::before { opacity: 0.3; }
982.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); }
983.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; }
984.afnl-step-meta { font-size: 12.5px; color: #cfd6e0; font-weight: 600; white-space: nowrap; }
985.afnl-step-src { font-size: 11px; color: #6f7787; display: flex; gap: 6px; align-items: baseline; min-width: 0; overflow: hidden; white-space: nowrap; }
986.afnl-step-src-lbl { color: #5a6270; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; font-size: 9.5px; flex-shrink: 0; }
987.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; }
988.afnl-step-src-sep { color: #3a3f4a; flex-shrink: 0; }
989.afnl-step-drop { text-align: right; padding: 5px 10px; border-radius: 7px; cursor: help; font-weight: 700; justify-self: end; }
990.afnl-step-drop .val { font-size: 14px; }
991.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; }
992.afnl-info:hover, .afnl-info:focus { background: rgba(196,163,240,0.32); outline: none; }
993.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; }
994.afnl-info-pop strong { color: #fff; font-weight: 700; }
995.afnl-info:hover .afnl-info-pop, .afnl-info:focus .afnl-info-pop { opacity: 1; pointer-events: auto; transform: translateY(0); }
996.afnl-step-drop .lbl { font-size: 9px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.8; }
997.afnl-step-drop .val { font-size: 17px; font-weight: 800; line-height: 1; margin-top: 2px; }
998.afnl-step-drop.high { background: rgba(239,68,68,0.14); color: #ff8a8a; }
999.afnl-step-drop.mid { background: rgba(234,179,8,0.12); color: #fbbf24; }
1000.afnl-step-drop.low { background: rgba(34,197,94,0.10); color: #4ade80; }
1001.afnl-empty { text-align: center; padding: 60px 40px; color: #8893a4; }
1002
1003/* Top Acquisition PATHS */
1004.afnl-path-legend { display: flex; gap: 14px; font-size: 11px; color: #8893a4; }
1005.afnl-path-legend .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; vertical-align: 1px; margin-right: 4px; }
1006.afnl-path-legend .dot.ok { background: #22c55e; }
1007.afnl-path-legend .dot.bad { background: #ef4444; }
1008.afnl-path-list { position: relative; display: flex; flex-direction: column; gap: 4px; }
1009.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; }
1010.afnl-path-row:hover { background: rgba(255,255,255,0.05); }
1011.afnl-path-row.is-selected { background: rgba(168,85,247,0.12); border: 1px solid rgba(168,85,247,0.3); }
1012.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); }
1013.afnl-path-row > * { position: relative; z-index: 1; }
1014.afnl-path-rank { color: #6f7787; font-weight: 700; font-size: 11px; text-align: center; }
1015.afnl-path-name { color: #cfd6e0; font-family: 'SF Mono', Menlo, monospace; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1016.afnl-path-stat { font-size: 11px; font-weight: 600; text-align: right; display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px; }
1017.afnl-path-stat.ok { color: #4ade80; }
1018.afnl-path-stat.bad { color: #f87171; }
1019.afnl-path-stat .dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; }
1020.afnl-path-stat.ok .dot { background: #22c55e; }
1021.afnl-path-stat.bad .dot { background: #ef4444; }
1022.afnl-path-share { color: #8893a4; font-size: 11px; text-align: right; font-weight: 600; }
1023.afnl-path-cnt { color: #fff; font-weight: 700; font-size: 13px; text-align: right; }
1024
1025/* Path drill-in funnel */
1026.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; }
1027.afnl-pathdrill-head .lbl { font-size: 11px; color: #c4a3f0; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 6px; }
1028.afnl-pathdrill-head .path-string { color: #fff; font-family: 'SF Mono', Menlo, monospace; font-size: 13px; word-break: break-all; margin-bottom: 14px; }
1029.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; }
1030.afnl-pathdrill-stats strong { color: #fff; margin-left: 6px; }
1031.afnl-pathdrill-stats .ok strong { color: #4ade80; }
1032.afnl-pathdrill-stats .bad strong { color: #f87171; }
1033.afnl-pathdrill-stats .dim strong { color: #cbd5e1; }
1034.afnl-pathdrill-steps { display: flex; flex-direction: column; gap: 14px; }
1035.afnl-path-step { display: grid; gap: 6px; }
1036.afnl-path-step-head { display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 10px; }
1037.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; }
1038.afnl-path-step-name { color: #cfd6e0; font-family: 'SF Mono', Menlo, monospace; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1039.afnl-path-step-meta { color: #8893a4; font-size: 12px; font-weight: 600; }
1040.afnl-path-step-bar { height: 10px; background: rgba(255,255,255,0.04); border-radius: 5px; overflow: hidden; }
1041.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); }
1042.afnl-path-step-markers { display: flex; gap: 8px; margin-left: 38px; }
1043.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; }
1044.afnl-path-step-markers .step-marker.ok { background: rgba(34,197,94,0.18); color: #4ade80; }
1045.afnl-path-step-markers .step-marker.bad { background: rgba(239,68,68,0.18); color: #f87171; }
1046
1047/* --- Top Acquisition Paths (4-column dimensional view) --- */
1048.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; }
1049.afnpp-head { margin-bottom: 14px; }
1050.afnpp-title { font-size: 18px; font-weight: 700; color: #fff; }
1051.afnpp-sub { font-size: 12px; color: #8893a4; margin-top: 4px; }
1052.afnpp-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
1053.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); }
1054.afnpp-col-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 10px; }
1055.afnpp-col-label { font-weight: 700; color: #fff; font-size: 13px; }
1056.afnpp-col-sub { font-size: 10px; color: #7a8499; text-transform: uppercase; letter-spacing: 0.08em; font-weight: 700; }
1057.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; }
1058.afnpp-bar { position: absolute; inset: 0; z-index: 0; }
1059.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)); }
1060.afnpp-rank { position: relative; z-index: 1; color: #7a8499; font-size: 11px; font-weight: 700; }
1061.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; }
1062.afnpp-cnt { position: relative; z-index: 1; color: #fff; font-weight: 700; font-size: 14px; }
1063@media (max-width: 900px) { .afnpp-grid { grid-template-columns: 1fr 1fr; } }
1064</style>
1065CSS
1066}
1067
1068
1069# 4-column "Top Acquisition Paths" panel (referrer / utm source / utm campaign / landing page).
1070# Ported from ABForge customer /funnels.cgi so every site's admin funnel has parity.
1071# Uses anon_sessions columns present on all 7 sister-site schemas.
1072sub afnpp_render_panel {
1073 my ($db, $dbh, $DB, $days) = @_;
1074 $days = int($days || 30);
1075 $days = 730 if $days > 730;
1076 $days = 1 if $days < 1;
1077 my $window = "DATE_SUB(NOW(), INTERVAL $days DAY)";
1078 my @dims = (
1079 { key=>'referer_host', label=>'Referrer', fb=>'(direct)' },
1080 { key=>'utm_source', label=>'UTM source', fb=>'(none)' },
1081 { key=>'utm_campaign', label=>'UTM campaign', fb=>'(none)' },
1082 { key=>'landing_path', label=>'Landing page', fb=>'/' },
1083 );
1084 my $cols_html = '';
1085 my $grand_total = 0;
1086 my $ci = 0;
1087 foreach my $d (@dims) {
1088 $ci++;
1089 my $col = $d->{key};
1090 my $fb = $d->{fb};
1091 my $fb_esc = $fb; $fb_esc =~ s/'/''/g;
1092 my @rows = eval { $db->db_readwrite_multiple($dbh, qq~
1093 SELECT IFNULL(NULLIF($col,''), '$fb_esc') AS val, COUNT(*) AS n
1094 FROM `${DB}`.anon_sessions
1095 WHERE first_seen_at >= $window
1096 GROUP BY IFNULL(NULLIF($col,''), '$fb_esc')
1097 ORDER BY n DESC
1098 LIMIT 3
1099 ~, $ENV{SCRIPT_NAME}, __LINE__) };
1100 @rows = () unless @rows;
1101 my $max = 0;
1102 foreach my $r (@rows) { $max = $r->{n} if $r->{n} > $max; $grand_total += $r->{n}; }
1103 $max = 1 if $max < 1;
1104 my $cells = '';
1105 my $rk = 0;
1106 foreach my $r (@rows) {
1107 $rk++;
1108 my $v = defined $r->{val} ? $r->{val} : $fb;
1109 $v =~ s/&/&amp;/g; $v =~ s/</&lt;/g; $v =~ s/>/&gt;/g; $v =~ s/"/&quot;/g;
1110 my $n = $r->{n} || 0;
1111 my $pct = int(100 * ($r->{n} || 0) / $max + 0.5);
1112 $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>~;
1113 }
1114 my $hue = 21; # brand hue (ABForge orange)
1115 my $nn = scalar(@rows);
1116 $cells = '<div class="afnpp-row" style="--pct:0%"><div class="afnpp-rank">&mdash;</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;
1117 $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>~;
1118 }
1119 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>~;
1120}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help