Diff -- /var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/admin_funnels.cgi
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
to cee2a349a2ee
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 | ################################################################################################################################## | |
| 13 | use strict; | |
| 14 | use lib '/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com'; | |
| 15 | ||
| 16 | ||
| 17 | ############################################################# | |
| 18 | ## Variables | |
| 19 | ############################################################# | |
| 20 | ||
| 21 | ||
| 22 | ||
| 23 | ############################################################ | |
| 24 | # Modules | |
| 25 | ############################################################ | |
| 26 | use CGI; my $query = new CGI; my $form = $query->Vars; | |
| 27 | use MODS::DBConnect; my $db = new MODS::DBConnect; | |
| 28 | use MODS::Login; my $login = new MODS::Login; | |
| 29 | use MODS::PTMatrix::Config; my $config = new MODS::PTMatrix::Config; my $database_name = $config->settings('database_name'); | |
| 30 | use MODS::PTMatrix::Wrapper; my $load = new MODS::PTMatrix::Wrapper; | |
| 31 | use MODS::AnonPaths; | |
| 32 | use Time::Local; | |
| 33 | ||
| 34 | ||
| 35 | ||
| 36 | ############################################################# | |
| 37 | ## Get form data | |
| 38 | ############################################################# | |
| 39 | ||
| 40 | ||
| 41 | ||
| 42 | ############################################################ | |
| 43 | # Program Controls | |
| 44 | ############################################################ | |
| 45 | $|=1; | |
| 46 | my $userinfo = $login->login_verify(); | |
| 47 | if(!$userinfo){ | |
| 48 | print qq~Status: 302 Found\nLocation: /login.cgi\n\n~; | |
| 49 | exit; | |
| 50 | } | |
| 51 | if(!$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 | ############################################################ | |
| 62 | sub 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 | ||
| 69 | sub h_esc{ | |
| 70 | my($s) = @_; | |
| 71 | return '' unless defined $s; | |
| 72 | $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; $s =~ s/"/"/g; | |
| 73 | return $s; | |
| 74 | } | |
| 75 | ||
| 76 | sub 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 | ||
| 88 | sub 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 | ||
| 101 | sub step_color{ | |
| 102 | my($i, $n) = @_; | |
| 103 | my $hues = &site_funnel_palette; | |
| 104 | return "hsl($hues->[$i % scalar @$hues], 92%, 60%)"; | |
| 105 | } | |
| 106 | ||
| 107 | sub step_color_dark{ | |
| 108 | my($i, $n) = @_; | |
| 109 | my $hues = &site_funnel_palette; | |
| 110 | return "hsl($hues->[$i % scalar @$hues], 80%, 38%)"; | |
| 111 | } | |
| 112 | ||
| 113 | sub 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 | ||
| 139 | sub 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/&/&/g; $tt =~ s/</</g; $tt =~ s/>/>/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 · ${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/&/&/g; $leak_tt =~ s/</</g; $leak_tt =~ s/>/>/g; | |
| 275 | my $chip_y = $y + 64; | |
| 276 | my $chip_w = 210; | |
| 277 | my $leak_lbl = "☠ -${drop_pct}% leak · ${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 | ||
| 315 | sub 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 · 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 | ||
| 335 | sub 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&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 | ||
| 395 | sub 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">✓ $sn_n</span>~ : ''; | |
| 424 | my $an_block = $an ? qq~<span class="step-marker bad" title="$an abandoned here">✕ $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 · ${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 | ||
| 453 | sub 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 | ||
| 466 | sub 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 | ||
| 502 | sub 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 · $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">·</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">← 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 — 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 → 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 ≤ 20%, amber 20-40%, red > 40%.">Drop</div> | |
| 713 | </div> | |
| 714 | $step_cards | |
| 715 | </div> | |
| 716 | ||
| 717 | <script> | |
| 718 | window.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 | ||
| 769 | sub 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> | |
| 960 | CSS | |
| 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. | |
| 967 | sub 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/&/&/g; $v =~ s/</</g; $v =~ s/>/>/g; $v =~ s/"/"/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">—</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. | |
| 1022 | sub _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/&/&/g; $name =~ s/</</g; $name =~ s/>/>/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 → $last"; | |
| 1084 | } | |
| 1085 | my $action = $is_hidden | |
| 1086 | ? qq~<a class="afnpp-fpick-unhide" href="?unhide=$f->{id}&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 & saved funnels</div> | |
| 1109 | <div class="afnpp-fpick-sub">Auto-discovered from your traffic (background hourly). Hide the ones you don’t care about; bring them back any time.</div> | |
| 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 | } |