Diff -- /var/www/vhosts/webstls.com/httpdocs/admin_funnels.cgi
Diff

/var/www/vhosts/webstls.com/httpdocs/admin_funnels.cgi

added on WebSTLs (webstls.com) at 2026-07-09 23:20:49

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