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

O Operator
Diff

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

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

Added
+1136
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to cee2a349a2ee
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##################################################################################################################################
4# ______ ____ ____ ______ ______ ____ ___ __ ___ ______ _ __ ____ ____ __ __ _ __ _____ ____
5# / ____// __ \ / __ \ / ____/ / ____// __ \ / | / |/ // ____/| | / // __ \ / __ \ / //_/ | | / /|__ / / __ \
6# / / / / / // / / // __/ / /_ / /_/ // /| | / /|_/ // __/ | | /| / // / / // /_/ // ,< | | / / /_ < / / / /
7# / /___ / /_/ // /_/ // /___ / __/ / _, _// ___ | / / / // /___ | |/ |/ // /_/ // _, _// /| | | |/ / ___/ /_ / /_/ /
8# \____/ \____//_____//_____/ /_/ /_/ |_|/_/ |_|/_/ /_//_____/ |__/|__/ \____//_/ |_|/_/ |_| |___/ /____/(_)\____/
9#
10# - Written by: Shawn Pickering
11# - shawn@shawnpickering.com
12##################################################################################################################################
13use strict;
14use lib '/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com';
15
16
17#############################################################
18## Variables
19#############################################################
20
21
22
23############################################################
24# Modules
25############################################################
26use CGI; my $query = new CGI; my $form = $query->Vars;
27use MODS::DBConnect; my $db = new MODS::DBConnect;
28use MODS::Login; my $login = new MODS::Login;
29use MODS::PTMatrix::Config; my $config = new MODS::PTMatrix::Config; my $database_name = $config->settings('database_name');
30use MODS::PTMatrix::Wrapper; my $load = new MODS::PTMatrix::Wrapper;
31use MODS::AnonPaths;
32use Time::Local;
33
34
35
36#############################################################
37## Get form data
38#############################################################
39
40
41
42############################################################
43# Program Controls
44############################################################
45$|=1;
46my $userinfo = $login->login_verify();
47if(!$userinfo){
48 print qq~Status: 302 Found\nLocation: /login.cgi\n\n~;
49 exit;
50}
51if(!$userinfo->{is_admin} && !$userinfo->{is_super_admin} && !$userinfo->{_admin_is_super_admin}){
52 print qq~Status: 403 Forbidden\nContent-Type: text/plain\n\nAdmin access required.\n~;
53 exit;
54}
55
56&admin_funnels;
57
58
59############################################################
60# Subroutines
61############################################################
62sub fmt_num{
63 my($n) = @_;
64 $n = int($n || 0);
65 1 while $n =~ s/(\d)(\d{3})(?!\d)/$1,$2/;
66 return $n;
67}
68
69sub h_esc{
70 my($s) = @_;
71 return '' unless defined $s;
72 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g; $s =~ s/"/&quot;/g;
73 return $s;
74}
75
76sub epoch_is_stale{
77 #NULL last_seen is treated as stale so lingering "active" sessions
78 #can still be counted as abandoned.
79 my($ts) = @_;
80 return 1 unless $ts;
81 if($ts =~ /^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})/){
82 my $ep = eval { Time::Local::timelocal($6, $5, $4, $3, $2-1, $1-1900) } || 0;
83 return ($ep > 0 && (time - $ep) > 1800) ? 1 : 0;
84 }
85 return 0;
86}
87
88sub site_funnel_palette{
89 #HSL hue array per brand. Reads HTTP_HOST so the same code cloned across
90 #sister sites picks the right palette automatically.
91 my $host = lc($ENV{HTTP_HOST} || $ENV{SERVER_NAME} || '');
92 return [15, 28, 350, 340, 320, 305] if $host =~ /taskforge/;
93 return [22, 35, 55, 100, 150, 175] if $host =~ /abforge/;
94 return [60, 85, 130, 175, 200, 215] if $host =~ /(^|\.)shop\./;
95 return [160, 170, 180, 190, 200, 210] if $host =~ /repricer/;
96 return [265, 275, 285, 295, 310, 325] if $host =~ /affiliate/;
97 return [185, 195, 205, 215, 225, 240] if $host =~ /(crm|contactforge)/;
98 return [22, 320, 285, 250, 200, 168, 140, 90];
99}
100
101sub step_color{
102 my($i, $n) = @_;
103 my $hues = &site_funnel_palette;
104 return "hsl($hues->[$i % scalar @$hues], 92%, 60%)";
105}
106
107sub step_color_dark{
108 my($i, $n) = @_;
109 my $hues = &site_funnel_palette;
110 return "hsl($hues->[$i % scalar @$hues], 80%, 38%)";
111}
112
113sub icon_for_label{
114 #Small inline lucide-style icon based on the page path this stage
115 #represents. Returns only the inner path data - caller wraps in <svg>.
116 my($lbl) = @_;
117 my $l = lc($lbl // '');
118 return '<path d="M3 9.5l9-6.5 9 6.5V21H3z"/><path d="M9 21V12h6v9"/>'
119 if $l eq '/' || $l eq '/index.cgi' || $l eq '/home' || $l =~ m{^/index};
120 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"/>'
121 if $l =~ /pricing|plan|billing|tier|cost/;
122 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"/>'
123 if $l =~ /signup|register|join|trial|get-?started/;
124 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"/>'
125 if $l =~ /login|signin|auth/;
126 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"/>'
127 if $l =~ /blog|article|post|news/;
128 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"/>'
129 if $l =~ /feature|product|highlight|spec/;
130 return '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>'
131 if $l =~ /signed up|success|complete|thanks|welcome|paid|checkout/;
132 return '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>'
133 if $l =~ /search|find/;
134 return '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>'
135 if $l =~ /pending|active|recent/;
136 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"/>';
137}
138
139sub render_svg_funnel{
140 #Build the 3D-arrow funnel SVG plus HTML popover overlay layer.
141 my($bars, $stage_popovers_ref) = @_;
142 my $n = scalar @$bars;
143 return '<div class="afnl-empty">No data yet.</div>' unless $n;
144
145 my $W = 1200;
146 my $FUNNEL_W = 560;
147 my $FUNNEL_CX = int($FUNNEL_W / 2);
148 my $LABEL_X = 660;
149 my $BULLET_X = $LABEL_X - 20;
150 my $H_band = 96;
151 my $H_gap = 22;
152 my $top_pad = 16;
153 my $H = $top_pad + $n * $H_band + ($n - 1) * $H_gap + 32;
154
155 my $defs = '';
156 for my $i(0..$n-1){
157 my $c1 = &step_color($i, $n);
158 my $c2 = &step_color_dark($i, $n);
159 $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>~;
160 $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>~;
161 $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>~;
162 $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>~;
163 }
164 $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>~;
165 $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>~;
166 $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>~;
167 $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>~;
168 $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>~;
169
170 my $entered = $bars->[0]->{count} || 1;
171 my $min_pct = 10;
172 my @widths;
173 for my $i(0..$n-1){
174 my $count = $bars->[$i]->{count} || 0;
175 my $pct = $entered ? (100.0 * $count / $entered) : 0;
176 my $vw = ($pct < $min_pct) ? $min_pct : $pct;
177 push @widths, int($FUNNEL_W * $vw / 100);
178 }
179
180 my $bands = '';
181 my $conn = '';
182 my $hits = '';
183 for my $i(0..$n-1){
184 my $bw = $widths[$i];
185 my $x = int($FUNNEL_CX - $bw/2);
186 my $y = $top_pad + $i * ($H_band + $H_gap);
187 my $count = &fmt_num($bars->[$i]->{count});
188 my $pct = $bars->[$i]->{pct};
189 my $label = &h_esc($bars->[$i]->{label});
190 my $hv = $bars->[$i]->{hover} || {};
191 my $top_ref = ($hv->{referrers} && @{$hv->{referrers}}) ? $hv->{referrers}->[0]->{host} . " (" . $hv->{referrers}->[0]->{n} . ")" : '-';
192 my $top_utm = ($hv->{utm_sources} && @{$hv->{utm_sources}}) ? $hv->{utm_sources}->[0]->{src} . " (" . $hv->{utm_sources}->[0]->{n} . ")" : '-';
193 my $top_nxt = ($hv->{next_pages} && @{$hv->{next_pages}}) ? $hv->{next_pages}->[0]->{path} . " (" . $hv->{next_pages}->[0]->{n} . ")" : '-';
194 my $sn_n = $hv->{signups} || 0;
195 my $abn_n = $hv->{abandons} || 0;
196 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";
197 $tt =~ s/&/&amp;/g; $tt =~ s/</&lt;/g; $tt =~ s/>/&gt;/g;
198
199 my $stage_color = &step_color($i, $n);
200
201 my $tip = 26;
202 my $body_r = 16;
203 my $body_right = $x + $bw - $tip;
204 my $tip_x = $x + $bw;
205 my $tip_y = $y + int($H_band / 2);
206
207 my $arrow_d = "M $x," . ($y + $body_r) . " "
208 . "A $body_r,$body_r 0 0 1 " . ($x + $body_r) . ",$y "
209 . "L $body_right,$y "
210 . "L $tip_x,$tip_y "
211 . "L $body_right," . ($y + $H_band) . " "
212 . "L " . ($x + $body_r) . "," . ($y + $H_band) . " "
213 . "A $body_r,$body_r 0 0 1 $x," . ($y + $H_band - $body_r) . " "
214 . "Z";
215
216 my $shadow_dx = 4; my $shadow_dy = 6;
217 my $shadow_d = $arrow_d;
218 $shadow_d =~ s/(\d+(?:\.\d+)?),(\d+(?:\.\d+)?)/($1+$shadow_dx).",".($2+$shadow_dy)/ge;
219
220 $bands .= qq~<g class="fnl-band" data-step="$i"><title>$tt</title>~;
221 $bands .= qq~<path d="$shadow_d" fill="rgba(0,0,0,0.45)" filter="blur(2px)" opacity="0.85"/>~;
222 $bands .= qq~<path class="fnl-band-rect" d="$arrow_d" fill="url(#afnlG$i)"/>~;
223 $bands .= qq~<path d="$arrow_d" fill="url(#afnlGtop$i)" pointer-events="none"/>~;
224 my $hy = $y + 6;
225 my $hw = $bw - 32 - $tip;
226 my $hx = $x + 16;
227 $bands .= qq~<rect x="$hx" y="$hy" rx="3" ry="3" width="$hw" height="2.5" fill="white" opacity="0.32" pointer-events="none"/>~;
228 my $clip_id = "afnlClip$i";
229 $defs .= qq[<clipPath id="$clip_id"><path d="$arrow_d"/></clipPath>];
230 my $shine_w = 90;
231 my $shine_from = $x - $shine_w;
232 my $shine_to = $x + $bw;
233 my $shine_delay = sprintf('%.2f', $i * 0.45);
234 $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>~;
235
236 if($bw >= 130){
237 my $icon_d = &icon_for_label($bars->[$i]->{label});
238 my $ix = $x + 22;
239 my $iy = $y + int($H_band/2) - 13;
240 $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>~;
241 }
242
243 my $body_cx = int($x + ($bw - $tip) / 2);
244 my $cy_count = $y + 44;
245 my $count_size = ($bw > 240) ? 26 : (($bw > 130) ? 20 : 15);
246 $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>~;
247 my $cy_pct = $y + 66;
248 if($bw > 110){
249 $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>~;
250 }
251
252 my $arrow_start_x = $tip_x + 4;
253 my $line_y = $tip_y;
254 my $midx = int(($arrow_start_x + $BULLET_X) / 2);
255 my $arrow_path = "M $arrow_start_x,$line_y Q $midx,$line_y " . ($BULLET_X - 6) . ",$line_y";
256 $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) . qq~s"/>~;
257 $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) . qq~s"/>~;
258 $bands .= qq~<circle cx="$BULLET_X" cy="$line_y" r="3.5" fill="$stage_color"/>~;
259
260 my $lbl_y = $y + 30;
261 my $lbl_y2 = $y + 50;
262 $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>~;
263 $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>~;
264 $bands .= qq~</g>~;
265
266 if($i < $n - 1){
267 my $drop_pct = $bars->[$i+1]->{drop_pct} || 0;
268 if($drop_pct > 0.5){
269 my $lost_n = $bars->[$i]->{count} - $bars->[$i+1]->{count};
270 $lost_n = 0 if $lost_n < 0;
271 my $cur_lbl = &h_esc($bars->[$i]->{label});
272 my $next_lbl = &h_esc($bars->[$i+1]->{label});
273 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.";
274 $leak_tt =~ s/&/&amp;/g; $leak_tt =~ s/</&lt;/g; $leak_tt =~ s/>/&gt;/g;
275 my $chip_y = $y + 64;
276 my $chip_w = 210;
277 my $leak_lbl = "&#9760; -${drop_pct}% leak &middot; ${lost_n} lost";
278 my $drain_start_x = $tip_x - 2;
279 my $drain_start_y = $y + $H_band - 8;
280 my $drain_end_y = $chip_y + 9;
281 $bands .= qq~<g class="afnl-leak"><title>$leak_tt</title>~;
282 $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) . qq~,$drain_end_y" stroke="#ff5b5b" stroke-width="2" stroke-dasharray="3 4" fill="none" stroke-linecap="round" opacity="0.7" marker-end="url(#afnlLeakArrow)"/>~;
283 $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)"/>~;
284 $bands .= qq~<text x="~ . ($LABEL_X + 12) . qq~" y="~ . ($chip_y + 14) . qq~" font-family="-apple-system,sans-serif" font-size="11" font-weight="800" fill="#ffd5d5">$leak_lbl</text>~;
285 $bands .= qq~</g>~;
286 }
287 }
288
289 if($i < $n - 1){
290 my $bw2 = $widths[$i+1];
291 my $body_w1 = $bw - $tip;
292 my $body_w2 = $bw2 - $tip;
293 my $bx1 = $x;
294 my $bx1r = $x + $body_w1;
295 my $bx2 = int($FUNNEL_CX - $body_w2/2);
296 my $bx2r = $bx2 + $body_w2;
297 my $y2a = $y + $H_band;
298 my $y2b = $y + $H_band + $H_gap;
299 my $c = &step_color($i, $n);
300 $conn .= qq~<polygon points="$bx1,$y2a $bx1r,$y2a $bx2r,$y2b $bx2,$y2b" fill="$c" opacity="0.18"/>~;
301 my $body_cx_now = int($x + ($bw - $tip) / 2);
302 my $chev_y = int(($y2a + $y2b) / 2);
303 $conn .= qq~<path class="fnl-chev" d="M ~ . ($body_cx_now - 8) . "," . ($chev_y - 4) . qq~ L $body_cx_now,~ . ($chev_y + 4) . qq~ L ~ . ($body_cx_now + 8) . "," . ($chev_y - 4) . qq~" stroke="white" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" opacity="0.55" style="animation-delay:~ . ($i * 0.25) . qq~s"/>~;
304 }
305
306 my $row_top_pct = sprintf('%.3f', 100 * $y / $H);
307 my $row_h_pct = sprintf('%.3f', 100 * $H_band / $H);
308 my $popover_html = ($stage_popovers_ref && $stage_popovers_ref->[$i]) ? $stage_popovers_ref->[$i] : '';
309 $hits .= qq~<div class="afnl-hit" data-step="$i" style="top:${row_top_pct}%; height:${row_h_pct}%;">$popover_html</div>~;
310 }
311 my $aurora = qq~<rect x="0" y="0" width="$W" height="$H" fill="url(#afnlAurora)" pointer-events="none"/>~;
312 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>~;
313}
314
315sub afnl_hero{
316 my($title, $sub) = @_;
317 my $t = &h_esc($title);
318 return qq~
319 <div class="afnl-hero">
320 <div class="afnl-hero-orb orb-a"></div>
321 <div class="afnl-hero-orb orb-b"></div>
322 <div class="afnl-hero-orb orb-c"></div>
323 <div class="afnl-hero-grid"></div>
324 <div class="afnl-hero-content">
325 <div class="afnl-hero-pill">
326 <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>
327 <span>Admin &middot; Platform Funnel</span>
328 </div>
329 <h1 class="afnl-hero-title">$t</h1>
330 <p class="afnl-hero-sub">$sub</p>
331 </div>
332 </div>~;
333}
334
335sub admin_top_paths_panel{
336 my($rows, $total_signups, $selected, $arrow, $days) = @_;
337 return '' unless ref($rows) eq 'ARRAY';
338 if(!scalar @$rows){
339 return qq~
340 <div class="afnl-dom-wrap">
341 <div class="afnl-dom-head">
342 <div>
343 <div class="afnl-dom-title">Top Acquisition Paths</div>
344 <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>
345 </div>
346 </div>
347 <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>
348 </div>~;
349 }
350
351 my $max = 0;
352 foreach my $r(@$rows){$max = $r->{n} if $r->{n} > $max;}
353 $max = 1 if $max < 1;
354
355 my $cells = '';
356 my $rank = 0;
357 foreach my $r(@$rows){
358 $rank++;
359 my $path_disp = &h_esc($r->{path_sequence});
360 my $cnt = &fmt_num($r->{n});
361 my $sn = &fmt_num($r->{signups} || 0);
362 my $an = &fmt_num($r->{abandons} || 0);
363 my $bar_pct = int(100.0 * $r->{n} / $max + 0.5);
364 my $is_sel = (defined($selected) && length($selected) && $selected eq $r->{path_sequence}) ? 'is-selected' : '';
365 my $share_pct = $total_signups ? sprintf('%.1f', 100.0 * $r->{n} / $total_signups) : '0.0';
366 my $enc = $r->{path_sequence};
367 $enc =~ s/([^A-Za-z0-9])/sprintf("%%%02X", ord($1))/eg;
368 $cells .= qq~
369 <a href="?days=$days&amp;path=$enc" class="afnl-path-row $is_sel" style="--pct:${bar_pct}%">
370 <div class="afnl-path-rank">$rank</div>
371 <div class="afnl-path-name" title="$path_disp">$path_disp</div>
372 <div class="afnl-path-stat ok"><span class="dot"></span>$sn</div>
373 <div class="afnl-path-stat bad"><span class="dot"></span>$an</div>
374 <div class="afnl-path-share">${share_pct}%</div>
375 <div class="afnl-path-cnt">$cnt</div>
376 </a>~;
377 }
378
379 return qq~
380 <div class="afnl-dom-wrap">
381 <div class="afnl-dom-head">
382 <div>
383 <div class="afnl-dom-title">Top Acquisition Paths</div>
384 <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>
385 </div>
386 <div class="afnl-path-legend">
387 <span><span class="dot ok"></span> signed up</span>
388 <span><span class="dot bad"></span> abandoned</span>
389 </div>
390 </div>
391 <div class="afnl-path-list">$cells</div>
392 </div>~;
393}
394
395sub admin_path_funnel_drilldown{
396 my($steps, $path_raw, $total, $signups, $abandons, $arrow) = @_;
397 return '' unless ref($steps) eq 'ARRAY' && scalar @$steps;
398 return '' unless length($path_raw // '');
399
400 my $path_disp = &h_esc($path_raw);
401 my $total_fmt = &fmt_num($total);
402 my $signup_fmt = &fmt_num($signups);
403 my $aban_fmt = &fmt_num($abandons);
404 my $continued = $total - $signups - $abandons;
405 $continued = 0 if $continued < 0;
406 my $cont_fmt = &fmt_num($continued);
407
408 my $entered = $total || 1;
409 my $bars = '';
410 my $idx = 0;
411 foreach my $s(@$steps){
412 $idx++;
413 my $step_path = &h_esc($s->{step});
414 my $r = $s->{reached};
415 my $sn = $s->{signup};
416 my $an = $s->{abandon};
417 my $reached_pct = $entered ? sprintf('%.1f', 100 * $r / $entered) : '0.0';
418 my $bar_w = $entered ? int(100 * $r / $entered) : 0;
419 $bar_w = 4 if $bar_w < 4 && $r > 0;
420 my $reached_n = &fmt_num($r);
421 my $sn_n = &fmt_num($sn);
422 my $an_n = &fmt_num($an);
423 my $sn_block = $sn ? qq~<span class="step-marker ok" title="$sn signed up here">&#10003; $sn_n</span>~ : '';
424 my $an_block = $an ? qq~<span class="step-marker bad" title="$an abandoned here">&#10005; $an_n</span>~ : '';
425 $bars .= qq~
426 <div class="afnl-path-step">
427 <div class="afnl-path-step-head">
428 <div class="afnl-path-step-num">$idx</div>
429 <div class="afnl-path-step-name" title="$step_path">$step_path</div>
430 <div class="afnl-path-step-meta">$reached_n &middot; ${reached_pct}%</div>
431 </div>
432 <div class="afnl-path-step-bar"><div class="afnl-path-step-fill" style="width:${bar_w}%"></div></div>
433 <div class="afnl-path-step-markers">$sn_block $an_block</div>
434 </div>~;
435 }
436
437 return qq~
438 <div class="afnl-pathdrill">
439 <div class="afnl-pathdrill-head">
440 <div class="lbl">Path drill-in</div>
441 <div class="path-string">$path_disp</div>
442 <div class="afnl-pathdrill-stats">
443 <span>Sessions <strong>$total_fmt</strong></span>
444 <span class="ok">Signed up <strong>$signup_fmt</strong></span>
445 <span class="bad">Abandoned <strong>$aban_fmt</strong></span>
446 <span class="dim">Still active <strong>$cont_fmt</strong></span>
447 </div>
448 </div>
449 <div class="afnl-pathdrill-steps">$bars</div>
450 </div>~;
451}
452
453sub silenced_query_multi{
454 #DBConnect prints DB errors to STDOUT which would dirty the CGI header
455 #stream. Redirect STDOUT to /dev/null for the query duration.
456 my($dbh, $sql) = @_;
457 local *OLDOUT;
458 open(OLDOUT, '>&', \*STDOUT) or do {};
459 open(STDOUT, '>', '/dev/null') or do {};
460 my @rows = eval { $db->db_readwrite_multiple($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__) };
461 @rows = () unless @rows;
462 open(STDOUT, '>&', \*OLDOUT) or do {};
463 return @rows;
464}
465
466sub build_path_funnel_steps{
467 #Walk each session path and count how many reached each step prefix,
468 #plus terminal outcomes at the step (signup/abandon).
469 my($dbh, $selected_path_raw, $arrow, $date_clause) = @_;
470 my @steps = split /\s+\Q$arrow\E\s+/, $selected_path_raw;
471 my @out;
472 return @out unless @steps;
473
474 my $clause = $date_clause->('s.first_seen_at');
475 my @all_sessions = &silenced_query_multi($dbh, qq~SELECT s.id, s.end_reason, s.last_seen_at, GROUP_CONCAT(p.page_path ORDER BY p.sequence SEPARATOR ' $arrow ') AS path_sequence FROM `${database_name}`.anon_sessions s JOIN `${database_name}`.anon_pageviews p ON p.session_id=s.id WHERE $clause GROUP BY s.id~);
476
477 for(my $i = 0; $i < @steps; $i++){
478 my $prefix = join(" $arrow ", @steps[0..$i]);
479 my($reached, $stop_here_abandon, $signup_here) = (0, 0, 0);
480 foreach my $sess(@all_sessions){
481 my $seq = $sess->{path_sequence} // '';
482 next unless index($seq, $prefix) == 0;
483 $reached++;
484 my $is_full_match = ($seq eq $prefix);
485 my $is_abandon =
486 $sess->{end_reason} eq 'abandoned' || $sess->{end_reason} eq 'bounced' ||
487 ($sess->{end_reason} eq 'active' && &epoch_is_stale($sess->{last_seen_at}));
488 $stop_here_abandon++ if $is_full_match && $is_abandon;
489 $signup_here++ if $is_full_match && $sess->{end_reason} eq 'signed_up';
490 }
491 push @out, {
492 idx => $i,
493 step => $steps[$i],
494 reached => $reached,
495 abandon => $stop_here_abandon,
496 signup => $signup_here,
497 };
498 }
499 return @out;
500}
501
502sub admin_funnels{
503 my %qparams = %{ $query->Vars };
504 my($range_opts, $preset_val, $range_from, $range_to) = MODS::AnonPaths::parse_range_params(\%qparams);
505 my $days = (ref($range_opts) eq 'HASH' && $range_opts->{days}) ? $range_opts->{days} : 30;
506 $days = 7 if $days < 1;
507 $days = 365 if $days > 365;
508
509 my $dbh = $db->db_connect();
510
511 my $is_range_mode = ($range_opts->{from} && $range_opts->{to}) ? 1 : 0;
512 my $range_from_sql = $is_range_mode ? "'$range_opts->{from} 00:00:00'" : '';
513 my $range_to_sql = $is_range_mode ? "'$range_opts->{to} 23:59:59'" : '';
514 my $date_clause = sub {
515 my($col) = @_;
516 return $is_range_mode
517 ? "$col BETWEEN $range_from_sql AND $range_to_sql"
518 : "$col >= DATE_SUB(NOW(), INTERVAL $days DAY)";
519 };
520
521 #Honor ?path=... so the big Platform funnel rebuilds against the selected
522 #acquisition path's actual stages, not the modal-journey ones.
523 my $selected_path_early = $query->param('path') || '';
524 $selected_path_early =~ s/[\x00-\x1f]//g;
525 $selected_path_early = substr($selected_path_early, 0, 1000);
526 $range_opts->{selected_path} = $selected_path_early if length $selected_path_early;
527
528 my $entered = 0;
529 my @bars;
530 ($entered, @bars) = MODS::AnonPaths::external_funnel_stages($db, $dbh, $range_opts);
531
532 my $biggest = { step => '-', pct => 0 };
533 for my $k(1 .. $#bars){
534 if($bars[$k]->{drop_pct} > $biggest->{pct}){
535 $biggest = { step => $bars[$k]->{label}, pct => $bars[$k]->{drop_pct} };
536 }
537 }
538
539 my $converted = $bars[-1]->{count};
540 my $conv_pct = $entered ? sprintf('%.1f', 100.0 * $converted / $entered) : 0;
541
542 #Daily signups sparkline
543 my @sparks = &silenced_query_multi($dbh, qq~SELECT DATE(created_at) AS d, COUNT(*) AS n FROM `${database_name}`.users WHERE ~ . $date_clause->('created_at') . qq~ GROUP BY DATE(created_at) ORDER BY d ASC~);
544
545 #Top acquisition PATHS - ordered page sequences taken by anonymous visitors.
546 my $arrow = chr(0xE2) . chr(0x86) . chr(0x92);
547 my $tp_clause = $date_clause->('s.first_seen_at');
548 my @top_paths = &silenced_query_multi($dbh, qq~SELECT path_sequence, COUNT(*) AS n, SUM(CASE WHEN end_reason='signed_up' THEN 1 ELSE 0 END) AS signups, SUM(CASE WHEN end_reason IN ('abandoned','bounced') THEN 1 WHEN end_reason='active' AND last_seen_at < DATE_SUB(NOW(), INTERVAL 30 MINUTE) THEN 1 ELSE 0 END) AS abandons FROM ( SELECT s.id, s.end_reason, s.last_seen_at, GROUP_CONCAT(p.page_path ORDER BY p.sequence SEPARATOR ' $arrow ') AS path_sequence FROM `${database_name}`.anon_sessions s JOIN `${database_name}`.anon_pageviews p ON p.session_id=s.id WHERE $tp_clause GROUP BY s.id ) AS sess WHERE path_sequence IS NOT NULL AND path_sequence != '' GROUP BY path_sequence ORDER BY n DESC LIMIT 10~);
549
550 #Drill-in: if ?path=... is set, build a per-step funnel for that path
551 my $selected_path_raw = $query->param('path') || '';
552 $selected_path_raw =~ s/[\x00-\x1f]//g;
553 $selected_path_raw = substr($selected_path_raw, 0, 1000);
554 my @path_funnel_steps;
555 my($selected_total, $selected_signups, $selected_abandons) = (0, 0, 0);
556 if(length $selected_path_raw){
557 @path_funnel_steps = &build_path_funnel_steps($dbh, $selected_path_raw, $arrow, $date_clause);
558 if(@path_funnel_steps){
559 $selected_total = $path_funnel_steps[0]->{reached};
560 $selected_signups = 0;
561 $selected_abandons = 0;
562 $selected_signups += $_->{signup} for @path_funnel_steps;
563 $selected_abandons += $_->{abandon} for @path_funnel_steps;
564 }
565 }
566
567 $db->db_disconnect($dbh);
568
569 #Render
570 my $days_opts = join('', map {
571 my $sel = ($_ == $days) ? 'selected' : '';
572 qq~<option value="$_" $sel>Last $_ days</option>~;
573 } (7, 14, 30, 60, 90, 180));
574
575 my @stage_popovers = map { MODS::AnonPaths::render_stage_popover($_) } @bars;
576 my $svg = &render_svg_funnel(\@bars, \@stage_popovers);
577 my $sparkbars = '';
578 my $max_spark = 1;
579 foreach my $r(@sparks){$max_spark = $r->{n} if $r->{n} > $max_spark;}
580 foreach my $r(@sparks){
581 my $h = int(($r->{n} / $max_spark) * 100);
582 $sparkbars .= qq~<div class="afnl-spark-col" title="$r->{d}: $r->{n} signups"><div class="afnl-spark-bar" style="height:${h}%"></div></div>~;
583 }
584
585 my $step_cards = '';
586 my $idx = 0;
587 foreach my $b(@bars){
588 my $cnt = &fmt_num($b->{count});
589 my $drop_class = ($b->{drop_pct} && $b->{drop_pct} > 40) ? 'high' : (($b->{drop_pct} && $b->{drop_pct} > 20) ? 'mid' : 'low');
590 my $color = &step_color($idx, scalar @bars);
591 $idx++;
592 my $popover = MODS::AnonPaths::render_stage_popover($b);
593 my $label_esc = &h_esc($b->{label} // '');
594 my $hv = $b->{hover} || {};
595 my $top_ref_host = ($hv->{referrers} && @{$hv->{referrers}}) ? $hv->{referrers}->[0]->{host} : '(direct)';
596 my $top_ref_n = ($hv->{referrers} && @{$hv->{referrers}}) ? $hv->{referrers}->[0]->{n} : 0;
597 my $top_utm_src = ($hv->{utm_sources} && @{$hv->{utm_sources}}) ? $hv->{utm_sources}->[0]->{src} : '(none)';
598 my $top_utm_n = ($hv->{utm_sources} && @{$hv->{utm_sources}}) ? $hv->{utm_sources}->[0]->{n} : 0;
599 my $tref_esc = &h_esc($top_ref_host);
600 my $tutm_esc = &h_esc($top_utm_src);
601 $step_cards .= qq~
602 <div class="afnl-step ap-pop-host" style="--step-color:$color">
603 <div class="afnl-step-num">$idx</div>
604 <div class="afnl-step-label">$label_esc</div>
605 <div class="afnl-step-meta">$cnt &middot; $b->{pct}%</div>
606 <div class="afnl-step-src">
607 <span class="afnl-step-src-lbl">via</span>
608 <span class="afnl-step-src-val" title="Top referring host">$tref_esc</span>
609 <span class="afnl-step-src-sep">&middot;</span>
610 <span class="afnl-step-src-lbl">utm</span>
611 <span class="afnl-step-src-val" title="Top UTM source">$tutm_esc</span>
612 </div>
613 <div class="afnl-step-drop $drop_class" title="Drop = the % of visitors who reached this stage but did not continue to the next">
614 <span class="val">$b->{drop_pct}%</span>
615 </div>
616 $popover
617 </div>~;
618 }
619
620 my $css = &afnl_css . MODS::AnonPaths::popover_css();
621 my $hero = &afnl_hero('Conversion Funnel',
622 'Where visitors leak between landing and signing up. <strong>Find the leak, plug the leak.</strong>');
623 my $range_controls = MODS::AnonPaths::render_range_controls($preset_val, $range_from, $range_to);
624
625 my $entered_fmt = &fmt_num($entered);
626 my $converted_fmt = &fmt_num($converted);
627 my $biggest_pct = $biggest->{pct};
628 my $biggest_step = &h_esc($biggest->{step});
629
630 my $is_drill_mode = length($selected_path_early) ? 1 : 0;
631 my $drill_disp = &h_esc($selected_path_early);
632 my $funnel_title_lbl = $is_drill_mode
633 ? qq{Drill-in: <span class="afnl-drill-path">$drill_disp</span>}
634 : 'Platform funnel';
635 my $funnel_sub_lbl = $is_drill_mode
636 ? 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>}
637 : '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.';
638 my $funnel_section = qq{
639<div class="afnl-viz-wrap">
640 <div class="afnl-viz-glow"></div>
641 <div class="afnl-viz-head">
642 <div class="afnl-viz-title">
643 $funnel_title_lbl
644 <span class="afnl-info" tabindex="0">?
645 <span class="afnl-info-pop">
646 <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>
647 <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>
648 <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.
649 </span>
650 </span>
651 </div>
652 <div class="afnl-viz-sub">$funnel_sub_lbl</div>
653 </div>
654 $svg
655</div>
656};
657
658 my $afnpp_html = &afnpp_render_panel($db, $dbh, $database_name, $days);
659 my $top_paths_html = &admin_top_paths_panel(\@top_paths, $entered, $selected_path_raw, $arrow, $days);
660 my $drilldown_html = &admin_path_funnel_drilldown(\@path_funnel_steps, $selected_path_raw, $selected_total, $selected_signups, $selected_abandons, $arrow);
661
662 my $body = qq~
663$css
664$hero
665
666<div class="afnl-toolbar">
667 <form method="GET" action="/admin_funnels.cgi" class="afnl-toolbar-form">
668 $range_controls
669 <a class="afnl-toolbar-btn" href="/admin_funnels.cgi">
670 <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>
671 Edit steps
672 </a>
673 <a class="afnl-toolbar-btn ghost" href="/admin_funnels.cgi">
674 <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>
675 All funnels
676 </a>
677 </form>
678</div>
679
680${\ _afnpp_funnels_widget($db, $dbh, $database_name, $query) }
681$afnpp_html
682$top_paths_html
683$drilldown_html
684
685<div class="afnl-stats-grid">
686 <div class="afnl-stat orb-orange">
687 <div class="lbl">Visitors landed</div><div class="val">$entered_fmt</div>
688 <div class="sub">Unique anonymous sessions in the last $days days</div>
689 </div>
690 <div class="afnl-stat orb-pink">
691 <div class="lbl">Signed up</div><div class="val">$converted_fmt</div>
692 <div class="sub">Visitors who completed signup</div>
693 </div>
694 <div class="afnl-stat orb-violet">
695 <div class="lbl">Land &rarr; signup rate</div><div class="val grad-text">$conv_pct%</div>
696 <div class="sub">Of every 100 visitors</div>
697 </div>
698 <div class="afnl-stat orb-cyan">
699 <div class="lbl">Biggest leak</div><div class="val">$biggest_pct%</div>
700 <div class="sub">at <strong>$biggest_step</strong></div>
701 </div>
702</div>
703
704$funnel_section
705
706<div class="afnl-step-grid">
707 <div class="afnl-step-header">
708 <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>
709 <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>
710 <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>
711 <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>
712 <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 &le; 20%, amber 20-40%, red > 40%.">Drop</div>
713 </div>
714 $step_cards
715</div>
716
717<script>
718window.addEventListener('DOMContentLoaded', function(){
719 document.querySelectorAll('.fnl-band').forEach(function(el, i){
720 setTimeout(function(){ el.classList.add('lit'); }, 100 + i * 110);
721 });
722 document.querySelectorAll('.afnl-stat').forEach(function(el, i){
723 setTimeout(function(){ el.classList.add('lit'); }, 60 + i * 80);
724 });
725 document.querySelectorAll('.afnl-step').forEach(function(el, i){
726 setTimeout(function(){ el.classList.add('lit'); }, 320 + i * 70);
727 });
728
729 var FOLLOW_OFFSET = 18;
730 document.addEventListener('mousemove', function(e){
731 var host = e.target.closest && e.target.closest('.ap-pop-host, .afnl-hit');
732 if (!host) return;
733 var pop = host.querySelector(':scope > .ap-pop');
734 if (!pop) return;
735 if (pop.offsetWidth === 0) {
736 requestAnimationFrame(function(){ positionPop(pop, e.clientX, e.clientY); });
737 } else {
738 positionPop(pop, e.clientX, e.clientY);
739 }
740 }, { passive: true });
741
742 function positionPop(pop, cx, cy){
743 var pw = pop.offsetWidth, ph = pop.offsetHeight;
744 var vw = window.innerWidth, vh = window.innerHeight;
745 var x = cx + FOLLOW_OFFSET;
746 var y = cy - ph / 2;
747 if (x + pw > vw - 8) x = cx - pw - FOLLOW_OFFSET;
748 if (x < 8) x = 8;
749 if (y < 8) y = 8;
750 if (y + ph > vh - 8) y = vh - ph - 8;
751 pop.style.position = 'fixed';
752 pop.style.left = x + 'px';
753 pop.style.top = y + 'px';
754 pop.style.transform = 'none';
755 }
756});
757</script>
758~;
759
760 print qq~Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n~;
761 $load->render({
762 userinfo => $userinfo,
763 page_key => 'admin_funnels',
764 title => 'Conversion Funnel',
765 body => $body,
766 });
767}
768
769sub afnl_css{
770 return <<'CSS';
771<style>
772.afnl-hero {
773 position: relative; margin: -8px -8px 24px; padding: 68px 140px 72px 40px !important;
774 border-radius: 22px; overflow: hidden;
775 background:
776 radial-gradient(900px 280px at 14% 0%, rgba(185,72,84,0.40), transparent 70%),
777 radial-gradient(700px 240px at 88% 90%, rgba(167,139,250,0.44), transparent 70%),
778 linear-gradient(180deg, #0d1219 0%, #060912 100%);
779 border: 1px solid rgba(255,255,255,0.06);
780 box-shadow: 0 28px 60px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.04);
781}
782.afnl-hero-grid {
783 position: absolute; inset: 0;
784 background-image:
785 linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px),
786 linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px);
787 background-size: 36px 36px;
788 mask-image: radial-gradient(ellipse at 50% 30%, black 0%, transparent 70%);
789 pointer-events: none;
790}
791.afnl-hero-orb { position: absolute; border-radius: 50%; filter: blur(60px); opacity: 0.85; pointer-events: none; animation: afnlOrb 12s ease-in-out infinite; }
792.afnl-hero-orb.orb-a { width: 320px; height: 320px; left: -60px; top: -80px; background: radial-gradient(circle, #b94854 0%, transparent 70%); }
793.afnl-hero-orb.orb-b { width: 380px; height: 380px; right: -100px; top: -40px; background: radial-gradient(circle, #a78bfa 0%, transparent 70%); animation-delay: -3s; }
794.afnl-hero-orb.orb-c { width: 260px; height: 260px; left: 40%; bottom: -120px; background: radial-gradient(circle, #22d3ee 0%, transparent 70%); animation-delay: -7s; }
795@keyframes afnlOrb { 0%, 100% { transform: translate(0,0) scale(1); } 50% { transform: translate(20px,-16px) scale(1.05); } }
796.afnl-hero-content { position: relative; z-index: 1; max-width: 760px; }
797.afnl-hero-pill { display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px; background: rgba(185,72,84,0.16); border: 1px solid rgba(185,72,84,0.42); border-radius: 999px; color: #f0a3aa; font-size: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 24px; }
798.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); }
799.afnl-hero-sub { margin: 0; color: rgba(207,214,224,0.85); font-size: 17px; line-height: 1.55; }
800.afnl-hero-sub strong { color: #fff; }
801.afnl-toolbar { display: flex; justify-content: flex-start; margin: 0 0 18px; flex-wrap: wrap; gap: 12px; }
802.afnl-toolbar-form { display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap; }
803.afnl-field { display: inline-flex; flex-direction: column; gap: 4px; }
804.afnl-field-lbl { font-size: 11px; color: #6f7787; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
805.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; }
806.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, #b94854 0%, #9f3b48 100%); color: #fff; border: none; box-shadow: 0 6px 18px rgba(185,72,84,0.32); }
807.afnl-toolbar-btn:hover { transform: translateY(-1px); box-shadow: 0 10px 24px rgba(185,72,84,0.46); }
808.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; }
809.afnl-toolbar-btn.ghost:hover { background: rgba(255,255,255,0.07); }
810.afnl-stats-grid { display: grid; gap: 16px; margin: 0 0 24px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
811.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); }
812.afnl-stat.lit { opacity: 1; transform: none; }
813.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; }
814.afnl-stat.orb-orange::after { background: #f97316; }
815.afnl-stat.orb-pink::after { background: #ec4899; }
816.afnl-stat.orb-violet::after { background: #a855f7; }
817.afnl-stat.orb-cyan::after { background: #22d3ee; }
818.afnl-stat .lbl { font-size: 11px; color: #8893a4; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
819.afnl-stat .val { font-size: 32px; font-weight: 800; color: #fff; margin: 8px 0 6px; line-height: 1; position: relative; }
820.afnl-stat .val.grad-text { background: linear-gradient(90deg, #f97316, #ec4899, #a855f7); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
821.afnl-stat .sub { font-size: 12px; color: #7a8392; position: relative; }
822.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; }
823.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; }
824.afnl-viz-head { position: relative; margin-bottom: 14px; }
825.afnl-viz-title { font-size: 18px; font-weight: 700; color: #fff; }
826.afnl-viz-sub { font-size: 12px; color: #8893a4; margin-top: 4px; }
827.afnl-drill-path { font-family: 'SF Mono', Menlo, monospace; font-size: 14px; color: #c4a3f0; word-break: break-all; }
828.afnl-clear { color: #5aa9ff; text-decoration: none; font-weight: 600; margin-left: 6px; }
829.afnl-clear:hover { color: #7cc1ff; text-decoration: underline; }
830.afnl-svg-stage { position: relative; padding: 6px 0 0; isolation: isolate; }
831.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; }
832.afnl-svg { width: 100%; height: auto; max-height: 760px; display: block; position: relative; z-index: 1; }
833.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; }
834.fnl-band.lit { opacity: 1; transform: scaleX(1); }
835.fnl-band-rect { transition: filter 0.25s ease; }
836.fnl-band:hover .fnl-band-rect { filter: brightness(1.15) drop-shadow(0 0 16px rgba(168,85,247,0.6)); }
837.fnl-band-icon { opacity: 0.92; }
838.fnl-shine { mix-blend-mode: screen; }
839.fnl-arrow { stroke-dasharray: 6 8; animation: afnl-dash-flow 1.4s linear infinite; }
840.fnl-bullet { animation: afnl-bullet-pulse 1.8s ease-in-out infinite; transform-origin: center; transform-box: fill-box; }
841.fnl-chev { animation: afnl-chev-float 1.6s ease-in-out infinite; transform-origin: center; transform-box: fill-box; }
842.fnl-leak-drain { stroke-dasharray: 3 4; animation: afnl-leak-drain 1.1s linear infinite; }
843.fnl-leak-chip { animation: afnl-leak-pulse 2.4s ease-in-out infinite; transform-origin: center; transform-box: fill-box; }
844@keyframes afnl-dash-flow { to { stroke-dashoffset: -28; } }
845@keyframes afnl-bullet-pulse { 0%, 100% { transform: scale(1); opacity: 0.35; } 50% { transform: scale(1.5); opacity: 0.05; } }
846@keyframes afnl-chev-float { 0%, 100% { transform: translateY(-2px); opacity: 0.45; } 50% { transform: translateY(3px); opacity: 0.9; } }
847@keyframes afnl-leak-drain { to { stroke-dashoffset: -14; } }
848@keyframes afnl-leak-pulse { 0%, 100% { opacity: 0.85; } 50% { opacity: 1; filter: drop-shadow(0 0 8px rgba(239,68,68,0.55)); } }
849@keyframes afnl-aurora { 0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.9; } 50% { transform: translate(20px, -10px) scale(1.05); opacity: 1; } }
850.afnl-hit-layer { position: absolute; inset: 0; pointer-events: none; z-index: 2; }
851.afnl-hit { position: absolute; left: 0; right: 0; pointer-events: auto; cursor: help; }
852.afnl-hit .ap-pop { left: 50%; top: 100%; transform: translate(-50%, -6px); }
853.afnl-hit:hover .ap-pop, .afnl-hit:focus-within .ap-pop { opacity: 1; transform: translate(-50%, 8px); }
854.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; }
855.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; }
856.afnl-dom-head { position: relative; margin-bottom: 16px; max-width: 720px; }
857.afnl-dom-title { font-size: 17px; font-weight: 700; color: #fff; }
858.afnl-dom-sub { font-size: 12px; color: #8893a4; margin-top: 4px; line-height: 1.55; }
859.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; }
860.afnl-spark-title { font-size: 15px; font-weight: 700; color: #fff; }
861.afnl-spark-sub { font-size: 12px; color: #7a8392; margin-top: 3px; }
862.afnl-spark-wrap { display: flex; align-items: flex-end; gap: 4px; height: 80px; margin-top: 14px; }
863.afnl-spark-col { flex: 1; min-width: 6px; display: flex; align-items: flex-end; }
864.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; }
865.afnl-spark-col:hover .afnl-spark-bar { background: linear-gradient(180deg, #fb9c4d 0%, #f072ad 100%); }
866.afnl-step-grid { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; }
867.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; }
868.afnl-step-header .afnl-step-h-drop { text-align: right; }
869.afnl-step-header .col-h { position: relative; cursor: help; }
870.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; }
871.afnl-step-header .col-h:hover::after, .afnl-step-header .col-h:focus::after { opacity: 1; transform: translateY(0); }
872.afnl-step-header .col-h.afnl-step-h-drop::after { right: 0; left: auto; }
873.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; }
874.afnl-step.lit { opacity: 1; transform: none; }
875.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; }
876.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; }
877.afnl-step > * { position: relative; z-index: 1; }
878.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); }
879.afnl-step:hover::after { opacity: 0.6; }
880.afnl-step:hover::before { opacity: 0.3; }
881.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); }
882.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; }
883.afnl-step-meta { font-size: 12.5px; color: #cfd6e0; font-weight: 600; white-space: nowrap; }
884.afnl-step-src { font-size: 11px; color: #6f7787; display: flex; gap: 6px; align-items: baseline; min-width: 0; overflow: hidden; white-space: nowrap; }
885.afnl-step-src-lbl { color: #5a6270; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; font-size: 9.5px; flex-shrink: 0; }
886.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; }
887.afnl-step-src-sep { color: #3a3f4a; flex-shrink: 0; }
888.afnl-step-drop { text-align: right; padding: 5px 10px; border-radius: 7px; cursor: help; font-weight: 700; justify-self: end; }
889.afnl-step-drop .val { font-size: 14px; }
890.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; }
891.afnl-info:hover, .afnl-info:focus { background: rgba(196,163,240,0.32); outline: none; }
892.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; }
893.afnl-info-pop strong { color: #fff; font-weight: 700; }
894.afnl-info:hover .afnl-info-pop, .afnl-info:focus .afnl-info-pop { opacity: 1; pointer-events: auto; transform: translateY(0); }
895.afnl-step-drop .lbl { font-size: 9px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.8; }
896.afnl-step-drop .val { font-size: 17px; font-weight: 800; line-height: 1; margin-top: 2px; }
897.afnl-step-drop.high { background: rgba(239,68,68,0.14); color: #ff8a8a; }
898.afnl-step-drop.mid { background: rgba(234,179,8,0.12); color: #fbbf24; }
899.afnl-step-drop.low { background: rgba(34,197,94,0.10); color: #4ade80; }
900.afnl-empty { text-align: center; padding: 60px 40px; color: #8893a4; }
901.afnl-path-legend { display: flex; gap: 14px; font-size: 11px; color: #8893a4; }
902.afnl-path-legend .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; vertical-align: 1px; margin-right: 4px; }
903.afnl-path-legend .dot.ok { background: #22c55e; }
904.afnl-path-legend .dot.bad { background: #ef4444; }
905.afnl-path-list { position: relative; display: flex; flex-direction: column; gap: 4px; }
906.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; }
907.afnl-path-row:hover { background: rgba(255,255,255,0.05); }
908.afnl-path-row.is-selected { background: rgba(168,85,247,0.12); border: 1px solid rgba(168,85,247,0.3); }
909.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); }
910.afnl-path-row > * { position: relative; z-index: 1; }
911.afnl-path-rank { color: #6f7787; font-weight: 700; font-size: 11px; text-align: center; }
912.afnl-path-name { color: #cfd6e0; font-family: 'SF Mono', Menlo, monospace; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
913.afnl-path-stat { font-size: 11px; font-weight: 600; text-align: right; display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px; }
914.afnl-path-stat.ok { color: #4ade80; }
915.afnl-path-stat.bad { color: #f87171; }
916.afnl-path-stat .dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; }
917.afnl-path-stat.ok .dot { background: #22c55e; }
918.afnl-path-stat.bad .dot { background: #ef4444; }
919.afnl-path-share { color: #8893a4; font-size: 11px; text-align: right; font-weight: 600; }
920.afnl-path-cnt { color: #fff; font-weight: 700; font-size: 13px; text-align: right; }
921.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; }
922.afnl-pathdrill-head .lbl { font-size: 11px; color: #c4a3f0; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 6px; }
923.afnl-pathdrill-head .path-string { color: #fff; font-family: 'SF Mono', Menlo, monospace; font-size: 13px; word-break: break-all; margin-bottom: 14px; }
924.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; }
925.afnl-pathdrill-stats strong { color: #fff; margin-left: 6px; }
926.afnl-pathdrill-stats .ok strong { color: #4ade80; }
927.afnl-pathdrill-stats .bad strong { color: #f87171; }
928.afnl-pathdrill-stats .dim strong { color: #cbd5e1; }
929.afnl-pathdrill-steps { display: flex; flex-direction: column; gap: 14px; }
930.afnl-path-step { display: grid; gap: 6px; }
931.afnl-path-step-head { display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 10px; }
932.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; }
933.afnl-path-step-name { color: #cfd6e0; font-family: 'SF Mono', Menlo, monospace; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
934.afnl-path-step-meta { color: #8893a4; font-size: 12px; font-weight: 600; }
935.afnl-path-step-bar { height: 10px; background: rgba(255,255,255,0.04); border-radius: 5px; overflow: hidden; }
936.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); }
937.afnl-path-step-markers { display: flex; gap: 8px; margin-left: 38px; }
938.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; }
939.afnl-path-step-markers .step-marker.ok { background: rgba(34,197,94,0.18); color: #4ade80; }
940.afnl-path-step-markers .step-marker.bad { background: rgba(239,68,68,0.18); color: #f87171; }
941
942/* --- Top Acquisition Paths (4-column dimensional view) --- */
943.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; }
944.afnpp-head { margin-bottom: 14px; }
945.afnpp-title { font-size: 18px; font-weight: 700; color: #fff; }
946.afnpp-sub { font-size: 12px; color: #8893a4; margin-top: 4px; }
947.afnpp-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
948.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); }
949.afnpp-col-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 10px; }
950.afnpp-col-label { font-weight: 700; color: #fff; font-size: 13px; }
951.afnpp-col-sub { font-size: 10px; color: #7a8499; text-transform: uppercase; letter-spacing: 0.08em; font-weight: 700; }
952.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; }
953.afnpp-bar { position: absolute; inset: 0; z-index: 0; }
954.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)); }
955.afnpp-rank { position: relative; z-index: 1; color: #7a8499; font-size: 11px; font-weight: 700; }
956.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; }
957.afnpp-cnt { position: relative; z-index: 1; color: #fff; font-weight: 700; font-size: 14px; }
958@media (max-width: 900px) { .afnpp-grid { grid-template-columns: 1fr 1fr; } }
959</style>
960CSS
961}
962
963
964# 4-column "Top Acquisition Paths" panel (referrer / utm source / utm campaign / landing page).
965# Ported from ABForge customer /funnels.cgi so every site's admin funnel has parity.
966# Uses anon_sessions columns present on all 7 sister-site schemas.
967sub afnpp_render_panel {
968 my ($db, $dbh, $DB, $days) = @_;
969 $days = int($days || 30);
970 $days = 730 if $days > 730;
971 $days = 1 if $days < 1;
972 my $window = "DATE_SUB(NOW(), INTERVAL $days DAY)";
973 my @dims = (
974 { key=>'referer_host', label=>'Referrer', fb=>'(direct)' },
975 { key=>'utm_source', label=>'UTM source', fb=>'(none)' },
976 { key=>'utm_campaign', label=>'UTM campaign', fb=>'(none)' },
977 { key=>'landing_path', label=>'Landing page', fb=>'/' },
978 );
979 my $cols_html = '';
980 my $grand_total = 0;
981 my $ci = 0;
982 foreach my $d (@dims) {
983 $ci++;
984 my $col = $d->{key};
985 my $fb = $d->{fb};
986 my $fb_esc = $fb; $fb_esc =~ s/'/''/g;
987 my @rows = eval { $db->db_readwrite_multiple($dbh, qq~
988 SELECT IFNULL(NULLIF($col,''), '$fb_esc') AS val, COUNT(*) AS n
989 FROM `${DB}`.anon_sessions
990 WHERE first_seen_at >= $window
991 GROUP BY IFNULL(NULLIF($col,''), '$fb_esc')
992 ORDER BY n DESC
993 LIMIT 3
994 ~, $ENV{SCRIPT_NAME}, __LINE__) };
995 @rows = () unless @rows;
996 my $max = 0;
997 foreach my $r (@rows) { $max = $r->{n} if $r->{n} > $max; $grand_total += $r->{n}; }
998 $max = 1 if $max < 1;
999 my $cells = '';
1000 my $rk = 0;
1001 foreach my $r (@rows) {
1002 $rk++;
1003 my $v = defined $r->{val} ? $r->{val} : $fb;
1004 $v =~ s/&/&amp;/g; $v =~ s/</&lt;/g; $v =~ s/>/&gt;/g; $v =~ s/"/&quot;/g;
1005 my $n = $r->{n} || 0;
1006 my $pct = int(100 * ($r->{n} || 0) / $max + 0.5);
1007 $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>~;
1008 }
1009 my $hue = 354; # brand hue (PTMatrix burgundy)
1010 my $nn = scalar(@rows);
1011 $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;
1012 $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>~;
1013 }
1014 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>~;
1015}
1016
1017
1018# Discovered/managed funnels widget. Shows a list of funnels from the
1019# funnels table (auto + user) with per-row Hide + a Show-Hidden toggle
1020# above. Reads ?hide=N / ?unhide=N on the SAME script for state changes
1021# so no separate action handler is required.
1022sub _afnpp_funnels_widget {
1023 my ($db, $dbh, $DB, $q) = @_;
1024
1025 # Handle hide/unhide via the SAME URL, then let the caller redirect
1026 # or just fall through and re-render.
1027 my $hide_id = int($q->param('hide') || 0);
1028 my $unhide_id = int($q->param('unhide') || 0);
1029 if ($hide_id) {
1030 $db->db_readwrite($dbh, qq~UPDATE `${DB}`.funnels SET is_hidden=1 WHERE id='$hide_id'~,
1031 $ENV{SCRIPT_NAME}, __LINE__);
1032 }
1033 if ($unhide_id) {
1034 $db->db_readwrite($dbh, qq~UPDATE `${DB}`.funnels SET is_hidden=0 WHERE id='$unhide_id'~,
1035 $ENV{SCRIPT_NAME}, __LINE__);
1036 }
1037
1038 my $show_hidden = $q->param('show_hidden') ? 1 : 0;
1039
1040 my @visible = $db->db_readwrite_multiple($dbh, qq~
1041 SELECT id, name, source, is_hidden, steps
1042 FROM `${DB}`.funnels
1043 WHERE is_active=1 AND (is_hidden=0 OR is_hidden IS NULL)
1044 ORDER BY (source='auto') ASC, created_at DESC
1045 LIMIT 40
1046 ~, $ENV{SCRIPT_NAME}, __LINE__);
1047
1048 my $hidden_n = 0;
1049 my @hidden;
1050 if ($show_hidden) {
1051 @hidden = $db->db_readwrite_multiple($dbh, qq~
1052 SELECT id, name, source, is_hidden, steps
1053 FROM `${DB}`.funnels
1054 WHERE is_active=1 AND is_hidden=1
1055 ORDER BY (source='auto') ASC, created_at DESC
1056 LIMIT 40
1057 ~, $ENV{SCRIPT_NAME}, __LINE__);
1058 $hidden_n = scalar @hidden;
1059 } else {
1060 my $r = $db->db_readwrite($dbh, qq~
1061 SELECT COUNT(*) AS n FROM `${DB}`.funnels WHERE is_active=1 AND is_hidden=1
1062 ~, $ENV{SCRIPT_NAME}, __LINE__);
1063 $hidden_n = ($r && $r->{n}) ? int($r->{n}) : 0;
1064 }
1065
1066 return '' unless scalar(@visible) || $hidden_n;
1067
1068 my $row_html = '';
1069 my $row_of = sub {
1070 my ($f, $is_hidden) = @_;
1071 my $name = $f->{name} // 'Untitled';
1072 $name =~ s/&/&amp;/g; $name =~ s/</&lt;/g; $name =~ s/>/&gt;/g;
1073 my $badge = '';
1074 if (($f->{source} || '') eq 'auto') {
1075 $badge = qq~<span class="afnpp-fpick-badge">AUTO</span>~;
1076 }
1077 # Show first + last step labels as breadcrumb
1078 my $trail = '';
1079 if ($f->{steps} && $f->{steps} =~ /"label":"([^"]+)"/) {
1080 my $first = $1;
1081 my $last = $first;
1082 while ($f->{steps} =~ /"label":"([^"]+)"/g) { $last = $1; }
1083 $trail = ($first eq $last) ? $first : "$first &rarr; $last";
1084 }
1085 my $action = $is_hidden
1086 ? qq~<a class="afnpp-fpick-unhide" href="?unhide=$f->{id}&amp;show_hidden=1">Unhide</a>~
1087 : 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>~;
1088 return qq~<div class="afnpp-fpick-row"><div class="afnpp-fpick-name">$name $badge</div><div class="afnpp-fpick-trail">$trail</div>$action</div>~;
1089 };
1090 foreach my $f (@visible) { $row_html .= $row_of->($f, 0); }
1091
1092 my $hidden_html = '';
1093 if ($show_hidden && scalar @hidden) {
1094 my $hr = '';
1095 foreach my $f (@hidden) { $hr .= $row_of->($f, 1); }
1096 $hidden_html = qq~<div class="afnpp-fpick-hidden"><div class="afnpp-fpick-hidden-head">Hidden funnels <a href="?">hide list</a></div>$hr</div>~;
1097 }
1098
1099 my $chip = '';
1100 if (!$show_hidden && $hidden_n > 0) {
1101 $chip = qq~<a class="afnpp-fpick-chip" href="?show_hidden=1">Show $hidden_n hidden</a>~;
1102 }
1103
1104 return qq~
1105 <div class="afnpp-fpick-wrap">
1106 <div class="afnpp-fpick-head">
1107 <div>
1108 <div class="afnpp-fpick-title">Discovered &amp; saved funnels</div>
1109 <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>
1110 </div>
1111 $chip
1112 </div>
1113 <div class="afnpp-fpick-list">$row_html</div>
1114 $hidden_html
1115 </div>
1116 <style>
1117 .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; }
1118 .afnpp-fpick-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; gap: 12px; }
1119 .afnpp-fpick-title { font-size: 17px; font-weight: 700; color: #fff; }
1120 .afnpp-fpick-sub { font-size: 12px; color: #8893a4; margin-top: 4px; }
1121 .afnpp-fpick-list { display: flex; flex-direction: column; gap: 6px; }
1122 .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); }
1123 .afnpp-fpick-name { color: #e8edf2; font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1124 .afnpp-fpick-trail { color: #7a8499; font-family: 'JetBrains Mono','Consolas',monospace; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1125 .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; }
1126 .afnpp-fpick-hide { color: #7a8499; display: inline-flex; align-items: center; justify-content: center; padding: 4px; border-radius: 6px; }
1127 .afnpp-fpick-hide:hover { background: rgba(255,255,255,0.08); color: #fff; }
1128 .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; }
1129 .afnpp-fpick-unhide:hover { background: rgba(34,197,94,0.28); color: #fff; }
1130 .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; }
1131 .afnpp-fpick-chip:hover { background: rgba(148,163,184,0.18); color: #fff; }
1132 .afnpp-fpick-hidden { margin-top: 14px; padding-top: 12px; border-top: 1px dashed rgba(148,163,184,0.20); }
1133 .afnpp-fpick-hidden-head { font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; }
1134 .afnpp-fpick-hidden-head a { color: #94a3b8; text-decoration: underline; font-weight: 500; text-transform: none; letter-spacing: normal; margin-left: 8px; }
1135 </style>~;
1136}