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

O Operator
Diff

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

added on local at 2026-07-09 23:28:57

Added
+1242
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 7725ea6756d9
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/affiliate.3dshawn.com';
16use CGI;
17use MODS::DBConnect;
18use MODS::Login;
19use MODS::AffSoft::Config;
20use MODS::AffSoft::Wrapper;
21use MODS::AnonPaths;
22
23my $q = CGI->new;
24my $auth = MODS::Login->new;
25my $wrap = MODS::AffSoft::Wrapper->new;
26my $db = MODS::DBConnect->new;
27my $cfg = MODS::AffSoft::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_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 &rarr; 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>
364window.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
416print "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#======================================================================
427sub 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
440sub fmt_num {
441 my ($n) = @_; $n = int($n || 0);
442 1 while $n =~ s/(\d)(\d{3})(?!\d)/$1,$2/;
443 return $n;
444}
445sub h_esc {
446 my ($s) = @_; return '' unless defined $s;
447 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g; $s =~ s/"/&quot;/g;
448 return $s;
449}
450
451sub _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
464sub step_color {
465 my ($i, $n) = @_;
466 my $hues = _site_funnel_palette();
467 return "hsl($hues->[$i % scalar @$hues], 92%, 60%)";
468}
469sub step_color_dark {
470 my ($i, $n) = @_;
471 my $hues = _site_funnel_palette();
472 return "hsl($hues->[$i % scalar @$hues], 80%, 38%)";
473}
474sub 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.
483sub 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
508sub 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/&/&amp;/g; $tt =~ s/</&lt;/g; $tt =~ s/>/&gt;/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 &#183; ${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/&/&amp;/g; $leak_tt =~ s/</&lt;/g; $leak_tt =~ s/>/&gt;/g;
671 my $chip_y = $y + 64;
672 my $chip_w = 210;
673 my $leak_lbl = "&#9760; -${drop_pct}% leak &middot; ${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
718sub 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 &middot; 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
738sub 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&amp;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
798sub 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">&#10003; $sn_n</span>~ : '';
827 my $an_block = $an ? qq~<span class="step-marker bad" title="$an abandoned here">&#10005; $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 &middot; ${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
856sub 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(168,85,247,0.40), transparent 70%),
864 radial-gradient(700px 240px at 88% 90%, rgba(124,58,237,0.44), transparent 70%),
865 linear-gradient(180deg, #0d1219 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.85; 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, #7c3aed 0%, transparent 70%); }
880.afnl-hero-orb.orb-b { width: 380px; height: 380px; right: -100px; top: -40px; background: radial-gradient(circle, #7c3aed 0%, transparent 70%); animation-delay: -3s; }
881.afnl-hero-orb.orb-c { width: 260px; height: 260px; left: 40%; bottom: -120px; background: radial-gradient(circle, #22d3ee 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(168,85,247,0.16); border: 1px solid rgba(168,85,247,0.42); border-radius: 999px; color: #d8b4fe; 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, #5b21b6 0%, #4c1d95 100%); color: #fff; border: none; box-shadow: 0 6px 18px rgba(76,29,149,0.38); }
894.afnl-toolbar-btn:hover { transform: translateY(-1px); box-shadow: 0 10px 24px rgba(76,29,149,0.52); }
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>
1066CSS
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.
1073sub 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/&/&amp;/g; $v =~ s/</&lt;/g; $v =~ s/>/&gt;/g; $v =~ s/"/&quot;/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 = 271; # brand hue (AffSoft purple)
1116 my $nn = scalar(@rows);
1117 $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;
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.
1128sub _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/&/&amp;/g; $name =~ s/</&lt;/g; $name =~ s/>/&gt;/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 &rarr; $last";
1190 }
1191 my $action = $is_hidden
1192 ? qq~<a class="afnpp-fpick-unhide" href="?unhide=$f->{id}&amp;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 &amp; saved funnels</div>
1215 <div class="afnpp-fpick-sub">Auto-discovered from your traffic (background hourly). Hide the ones you don&rsquo;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(34,197,94,0.15); color: #86efac; border: 1px solid rgba(34,197,94,0.35); padding: 4px 12px; border-radius: 6px; font-size: 12px; font-weight: 700; text-decoration: none; }
1235 .afnpp-fpick-unhide:hover { background: rgba(34,197,94,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}