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

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

added on local at 2026-07-02 13:10:06

Added
+1828
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to c713358dd06a
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# ABForge - Traffic Funnels
4#
5# Modes (single CGI):
6# /funnels.cgi - LIST: every funnel + mini bar
7# /funnels.cgi?id=N - REPORT: the WOW visualisation
8# /funnels.cgi?id=N&edit=1 - BUILDER: edit steps
9# /funnels.cgi?new=1 - BUILDER: fresh funnel
10#
11# Data model:
12# funnels.steps is JSON: [{kind, match, label}, ...]
13# kind = 'pageview' (match against page_path) or 'event' (event_name)
14#
15# Reporting algorithm (per site, per date window):
16# 1. Find every session that hit step-1's match.
17# 2. Walk that session forward through later events/pageviews; record
18# the highest step index reached (steps must be hit IN ORDER).
19# 3. Per step: count distinct sessions reaching >= that step.
20# This is the standard sequential-funnel definition.
21#======================================================================
22use strict;
23use warnings;
24
25use lib '/var/www/vhosts/3dshawn.com/abforge.3dshawn.com';
26use CGI;
27use MODS::Template;
28use MODS::DBConnect;
29use MODS::Login;
30use MODS::ABForge::Config;
31use MODS::ABForge::Wrapper;
32use MODS::ABForge::Sites;
33
34my $q = CGI->new;
35my $auth = MODS::Login->new;
36my $wrap = MODS::ABForge::Wrapper->new;
37my $tfile = MODS::Template->new;
38my $cfg = MODS::ABForge::Config->new;
39my $db = MODS::DBConnect->new;
40my $sites = MODS::ABForge::Sites->new;
41my $DB = $cfg->settings('database_name');
42
43my $userinfo = $auth->login_verify();
44unless ($userinfo) {
45 print "Status: 302 Found\nLocation: /login.cgi\n\n";
46 exit;
47}
48
49my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'funnels';
50
51if ($entry eq 'funnel_action') {
52 _handle_action($q);
53 exit;
54}
55
56my $dbh = $db->db_connect();
57my @sites_list = $sites->list_for_user($dbh, $DB, $userinfo->{user_id});
58my $site_id = int($q->param('site') || ($sites_list[0] ? $sites_list[0]->{id} : 0));
59my $days = int($q->param('days') || 30);
60$days = 7 if $days < 1;
61$days = 365 if $days > 365;
62
63my $fid = int($q->param('id') || 0);
64my $new = $q->param('new') ? 1 : 0;
65my $edit = $q->param('edit') ? 1 : 0;
66
67my $body;
68if ($new || ($fid && $edit)) {
69 $body = render_builder($db, $dbh, $DB, $userinfo, $site_id, $fid, \@sites_list, $days);
70} elsif ($fid) {
71 $body = render_report($db, $dbh, $DB, $userinfo, $site_id, $fid, \@sites_list, $days);
72} else {
73 $body = render_list($db, $dbh, $DB, $userinfo, $site_id, \@sites_list, $days);
74}
75
76$db->db_disconnect($dbh);
77
78print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n";
79$wrap->render({
80 userinfo => $userinfo,
81 page_key => 'funnels',
82 title => 'Traffic Funnels',
83 body => $body,
84 sites => \@sites_list,
85});
86
87#======================================================================
88# LIST VIEW
89#======================================================================
90sub render_list {
91 my ($db, $dbh, $DB, $userinfo, $site_id, $sites_aref, $days) = @_;
92
93 my $q = CGI->new;
94 my $show_hidden = $q->param('show_hidden') ? 1 : 0;
95
96 # Pull visible funnels always, hidden only when the user asked for them.
97 my @funnels = list_funnels($db, $dbh, $DB, $site_id, 0);
98 my @hidden_funnels = $show_hidden
99 ? grep { $_->{is_hidden} } list_funnels($db, $dbh, $DB, $site_id, 1)
100 : ();
101
102 # Get a count of hidden ones even when not showing them, so the
103 # "Show N hidden" chip can display an accurate count.
104 my $hidden_count = 0;
105 unless ($show_hidden) {
106 my $cnt = $db->db_readwrite($dbh, qq~
107 SELECT COUNT(*) AS n FROM `${DB}`.funnels
108 WHERE site_id='$site_id' AND is_active=1 AND is_hidden=1
109 ~, $ENV{SCRIPT_NAME}, __LINE__);
110 $hidden_count = ($cnt && $cnt->{n}) ? int($cnt->{n}) : 0;
111 }
112
113 # Pre-compute conversion rate for each funnel's overall path so the
114 # list cards can show a real number instead of a placeholder.
115 my $card_of = sub {
116 my ($f) = @_;
117 my $steps = parse_steps($f->{steps});
118 my $stats = compute_funnel_stats($db, $dbh, $DB, $site_id, $steps, $days);
119 return {
120 id => $f->{id},
121 name => $f->{name},
122 source => $f->{source} || 'user',
123 is_hidden => $f->{is_hidden} ? 1 : 0,
124 steps_count => scalar @$steps,
125 first_label => ($steps->[0] && $steps->[0]->{label}) ? $steps->[0]->{label} : 'Step 1',
126 last_label => ($steps->[-1] && $steps->[-1]->{label}) ? $steps->[-1]->{label} : 'Step N',
127 entered => fmt_num($stats->{entered}),
128 converted => fmt_num($stats->{converted}),
129 conv_pct => $stats->{conv_pct},
130 bars => $stats->{bars},
131 };
132 };
133 my @cards = map { $card_of->($_) } @funnels;
134 my @hidden_cards = map { $card_of->($_) } @hidden_funnels;
135
136 my $site_options = site_options_html($sites_aref, $site_id);
137 my $days_options = days_options_html($days);
138 my $cards_html = '';
139
140 if (!scalar @cards) {
141 $cards_html = qq~
142 <div class="fnl-empty">
143 <div class="fnl-empty-orb"></div>
144 <h2>No funnels yet</h2>
145 <p>Build a funnel to see where visitors drop off between any two events on your site &mdash; from landing page to checkout, signup to first-purchase, anything you can name.</p>
146 <a class="fnl-cta-big" href="/funnels.cgi?new=1&amp;site=$site_id">
147 <span>Build your first funnel</span>
148 <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M5 12h14M13 5l7 7-7 7"/></svg>
149 </a>
150 </div>~;
151 } else {
152 foreach my $c (@cards) {
153 my $bars = '';
154 foreach my $b (@{ $c->{bars} || [] }) {
155 my $w = int(($b->{pct} || 0) * 0.6 + 0.5);
156 $bars .= qq~<div class="fnl-spark-bar" style="width:${w}px"><span></span></div>~;
157 }
158 my $auto_badge = ($c->{source} eq 'auto')
159 ? qq~<span class="fnl-card-badge" title="Auto-discovered from your traffic">AUTO</span>~
160 : '';
161 $cards_html .= qq~
162 <div class="fnl-card-wrap">
163 <a class="fnl-card" href="/funnels.cgi?id=$c->{id}&amp;site=$site_id&amp;days=$days">
164 <div class="fnl-card-head">
165 <div class="fnl-card-name">$c->{name} $auto_badge</div>
166 <div class="fnl-card-steps">$c->{steps_count} steps</div>
167 </div>
168 <div class="fnl-card-path">
169 <span>$c->{first_label}</span>
170 <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M5 12h14M13 5l7 7-7 7"/></svg>
171 <span>$c->{last_label}</span>
172 </div>
173 <div class="fnl-spark">$bars</div>
174 <div class="fnl-card-stats">
175 <div><div class="lbl">Entered</div><div class="val">$c->{entered}</div></div>
176 <div><div class="lbl">Converted</div><div class="val">$c->{converted}</div></div>
177 <div><div class="lbl">Rate</div><div class="val grad">$c->{conv_pct}%</div></div>
178 </div>
179 </a>
180 <form method="POST" action="/funnel_action.cgi" class="fnl-card-hide"
181 onsubmit="return confirm('Hide this funnel? You can bring it back from the Show hidden chip above.');">
182 <input type="hidden" name="id" value="$c->{id}">
183 <input type="hidden" name="hide" value="1">
184 <button type="submit" title="Hide this funnel">
185 <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>
186 </button>
187 </form>
188 </div>~;
189 }
190 }
191 # Hidden section — only rendered when ?show_hidden=1
192 my $hidden_html = '';
193 if ($show_hidden && scalar @hidden_cards) {
194 my $rows = '';
195 foreach my $c (@hidden_cards) {
196 my $auto_badge = ($c->{source} eq 'auto')
197 ? qq~<span class="fnl-card-badge">AUTO</span>~
198 : '';
199 $rows .= qq~
200 <div class="fnl-hidden-row">
201 <div class="fnl-hidden-name">$c->{name} $auto_badge</div>
202 <div class="fnl-hidden-path">$c->{first_label} &rarr; $c->{last_label}</div>
203 <form method="POST" action="/funnel_action.cgi" style="margin:0;">
204 <input type="hidden" name="id" value="$c->{id}">
205 <input type="hidden" name="unhide" value="1">
206 <button type="submit" class="fnl-hidden-unhide">Unhide</button>
207 </form>
208 </div>~;
209 }
210 $hidden_html = qq~
211 <div class="fnl-hidden-wrap">
212 <div class="fnl-hidden-head">Hidden funnels
213 (<a href="/funnels.cgi?site=$site_id&amp;days=$days">hide list</a>)</div>
214 $rows
215 </div>~;
216 }
217
218 my $css = funnel_css();
219 my $hero = funnel_hero_banner('Traffic Funnels',
220 'Every visitor takes a journey through your site. Some convert. Most don\'t. <strong>This is where you find out why.</strong>');
221
222 my $hidden_chip = '';
223 if (!$show_hidden && $hidden_count > 0) {
224 $hidden_chip = qq~
225 <a class="fnl-hidden-chip" href="/funnels.cgi?site=$site_id&amp;days=$days&amp;show_hidden=1">
226 <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
227 Show $hidden_count hidden
228 </a>~;
229 }
230
231 return qq~
232$css
233$hero
234
235<div class="fnl-toolbar">
236 <form method="GET" action="/funnels.cgi" class="fnl-toolbar-form">
237 <label class="fnl-field">
238 <span class="fnl-field-lbl">Site</span>
239 <select name="site" onchange="this.form.submit()">$site_options</select>
240 </label>
241 <label class="fnl-field">
242 <span class="fnl-field-lbl">Window</span>
243 <select name="days" onchange="this.form.submit()">$days_options</select>
244 </label>
245 <a class="fnl-cta-pill" href="/funnels.cgi?new=1&amp;site=$site_id">
246 <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M12 5v14M5 12h14"/></svg>
247 New funnel
248 </a>
249 $hidden_chip
250 </form>
251</div>
252
253<div class="fnl-grid">$cards_html</div>
254$hidden_html
255
256<style>
257.fnl-card-wrap { position: relative; }
258.fnl-card-hide { position: absolute; top: 10px; right: 10px; margin: 0; z-index: 2; }
259.fnl-card-hide button { background: rgba(0,0,0,0.35); border: 1px solid rgba(255,255,255,0.15); color: #cbd5e1; padding: 6px; border-radius: 8px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
260.fnl-card-hide button:hover { background: rgba(0,0,0,0.6); color: #fff; border-color: rgba(255,255,255,0.3); }
261.fnl-card-badge { background: rgba(249,115,22,0.18); color: #ffae6e; border: 1px solid rgba(249,115,22,0.42); padding: 2px 8px; font-size: 10px; font-weight: 700; border-radius: 999px; letter-spacing: 0.06em; margin-left: 6px; vertical-align: middle; }
262.fnl-hidden-chip { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; background: rgba(148,163,184,0.10); color: #cbd5e1; border: 1px solid rgba(148,163,184,0.25); border-radius: 999px; font-size: 12px; font-weight: 700; text-decoration: none; letter-spacing: 0.05em; text-transform: uppercase; margin-left: 8px; }
263.fnl-hidden-chip:hover { background: rgba(148,163,184,0.18); color: #fff; }
264.fnl-hidden-wrap { background: rgba(148,163,184,0.05); border: 1px dashed rgba(148,163,184,0.20); border-radius: 14px; padding: 18px 22px; margin: 22px 0; }
265.fnl-hidden-head { font-size: 12px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 12px; }
266.fnl-hidden-head a { color: #94a3b8; text-decoration: underline; font-weight: 500; text-transform: none; letter-spacing: normal; margin-left: 8px; }
267.fnl-hidden-row { display: flex; align-items: center; gap: 16px; padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.05); }
268.fnl-hidden-row:last-child { border-bottom: none; }
269.fnl-hidden-name { flex: 0 0 260px; color: #cbd5e1; font-weight: 600; }
270.fnl-hidden-path { flex: 1; color: #7a8499; font-family: 'JetBrains Mono','Consolas',monospace; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
271.fnl-hidden-unhide { background: rgba(34,197,94,0.15); color: #86efac; border: 1px solid rgba(34,197,94,0.35); padding: 6px 14px; border-radius: 6px; font-size: 12px; font-weight: 700; cursor: pointer; }
272.fnl-hidden-unhide:hover { background: rgba(34,197,94,0.28); color: #fff; }
273</style>
274~;
275}
276
277#======================================================================
278# REPORT VIEW - the WOW visualisation
279#======================================================================
280sub render_report {
281 my ($db, $dbh, $DB, $userinfo, $site_id, $fid, $sites_aref, $days) = @_;
282
283 my $f = load_funnel($db, $dbh, $DB, $fid);
284 return render_notfound() unless $f && $f->{id};
285 $site_id ||= $f->{site_id};
286
287 my $steps = parse_steps($f->{steps});
288
289 # Optional acquisition-path filter. When ?path_dim=referrer_host&path_val=google.com
290 # is set, the funnel scopes to only sessions that came in via that path,
291 # so the user can drill into how each entry-source converts.
292 my $path_dim = $q->param('path_dim') || '';
293 my $path_val = $q->param('path_val');
294 $path_val = '' unless defined $path_val;
295 my $path_filter = (length($path_dim) && length($path_val) && path_dim_column($path_dim))
296 ? { dim => $path_dim, val => $path_val }
297 : undef;
298
299 my $top_paths = compute_top_paths($db, $dbh, $DB, $site_id, $days);
300 my $stats = compute_funnel_stats($db, $dbh, $DB, $site_id, $steps, $days, $path_filter);
301
302 my $entered = $stats->{entered};
303 my $converted = $stats->{converted};
304 my $conv_pct = $stats->{conv_pct};
305 my $avg_seconds = $stats->{avg_seconds};
306 my $avg_time = fmt_duration($avg_seconds);
307 my $biggest_drop = $stats->{biggest_drop} || { step => '-', pct => 0 };
308
309 # Render the SVG funnel. Each stage is a horizontal "trapezoid" band
310 # whose width is proportional to the visitor count remaining at that
311 # step. Drop-off "leaks" are small red bars off to the right.
312 my $svg = render_svg_funnel($stats->{bars}, $steps);
313
314 # Step detail cards under the funnel.
315 my $step_cards = '';
316 my $i = 0;
317 foreach my $b (@{ $stats->{bars} || [] }) {
318 $i++;
319 my $top_exits = top_exits_for_step($db, $dbh, $DB, $site_id, $steps, $i - 1, $days);
320 my $exits_html = '';
321 if (@$top_exits) {
322 foreach my $e (@$top_exits) {
323 my $p = h_esc($e->{path});
324 my $c = fmt_num($e->{cnt});
325 $exits_html .= qq~<li><span class="pth">$p</span><span class="cnt">$c</span></li>~;
326 }
327 $exits_html = qq~<ul class="fnl-exits">$exits_html</ul>~;
328 } else {
329 $exits_html = '<div class="fnl-exits-empty">No exit-page data yet.</div>';
330 }
331
332 my $drop_class = ($b->{drop_pct} && $b->{drop_pct} > 40) ? 'high' : (($b->{drop_pct} && $b->{drop_pct} > 20) ? 'mid' : 'low');
333 my $color_dot = step_color($i - 1, scalar @{ $stats->{bars} });
334 my $label = h_esc($b->{label});
335 my $cnt = fmt_num($b->{count});
336 my $pct = $b->{pct};
337 my $drop = $b->{drop_pct} || 0;
338
339 $step_cards .= qq~
340 <div class="fnl-step" style="--step-color:$color_dot">
341 <div class="fnl-step-head">
342 <div class="fnl-step-num">$i</div>
343 <div class="fnl-step-info">
344 <div class="fnl-step-label">$label</div>
345 <div class="fnl-step-meta">$cnt visitors &middot; $pct% of total entrants</div>
346 </div>
347 <div class="fnl-step-drop $drop_class">
348 <div class="lbl">Drop-off</div>
349 <div class="val">$drop%</div>
350 </div>
351 </div>
352 <div class="fnl-step-exits-head">Top exit pages on this step</div>
353 $exits_html
354 </div>~;
355 }
356
357 my $site_options = site_options_html($sites_aref, $site_id);
358 my $days_options = days_options_html($days);
359 my $name = h_esc($f->{name});
360
361 my $css = funnel_css();
362 my $hero = funnel_hero_banner($name,
363 'Every visitor enters at the top. Where they leak out is where your money does.');
364
365 my $biggest_drop_step = h_esc($biggest_drop->{step} || '-');
366 my $biggest_drop_pct = $biggest_drop->{pct} || 0;
367
368 return qq~
369$css
370$hero
371
372<div class="fnl-toolbar">
373 <form method="GET" action="/funnels.cgi" class="fnl-toolbar-form">
374 <input type="hidden" name="id" value="$fid"/>
375 <label class="fnl-field">
376 <span class="fnl-field-lbl">Site</span>
377 <select name="site" onchange="this.form.submit()">$site_options</select>
378 </label>
379 <label class="fnl-field">
380 <span class="fnl-field-lbl">Window</span>
381 <select name="days" onchange="this.form.submit()">$days_options</select>
382 </label>
383 <a class="fnl-toolbar-btn" href="/funnels.cgi?id=$fid&amp;edit=1&amp;site=$site_id">
384 <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>
385 Edit steps
386 </a>
387 <a class="fnl-toolbar-btn ghost" href="/funnels.cgi?site=$site_id">
388 <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>
389 All funnels
390 </a>
391 </form>
392</div>
393
394${\ render_top_paths_panel($top_paths, $fid, $site_id, $days, $path_filter) }
395
396<div class="fnl-stats-grid">
397 <div class="fnl-stat orb-orange">
398 <div class="lbl">Entered funnel</div>
399 <div class="val">${\ fmt_num($entered) }</div>
400 <div class="sub">Sessions hit step 1 in the last $days days</div>
401 </div>
402 <div class="fnl-stat orb-pink">
403 <div class="lbl">Completed</div>
404 <div class="val">${\ fmt_num($converted) }</div>
405 <div class="sub">Sessions reached the final step</div>
406 </div>
407 <div class="fnl-stat orb-violet">
408 <div class="lbl">Conversion rate</div>
409 <div class="val grad-text">$conv_pct%</div>
410 <div class="sub">End-to-end through the entire funnel</div>
411 </div>
412 <div class="fnl-stat orb-cyan">
413 <div class="lbl">Avg time to convert</div>
414 <div class="val">$avg_time</div>
415 <div class="sub">From step 1 to final step (completers only)</div>
416 </div>
417</div>
418
419<div class="fnl-viz-wrap">
420 <div class="fnl-viz-glow"></div>
421 <div class="fnl-viz-head">
422 <div>
423 <div class="fnl-viz-title">The funnel</div>
424 <div class="fnl-viz-sub">Hover any band to see the exact count + drop. Click a band to drill in.</div>
425 </div>
426 <div class="fnl-biggest-drop">
427 <div class="lbl">Biggest leak</div>
428 <div class="val">$biggest_drop_pct%</div>
429 <div class="sub">at <strong>$biggest_drop_step</strong></div>
430 </div>
431 </div>
432 $svg
433</div>
434
435<div class="fnl-step-grid">$step_cards</div>
436
437<script>
438// Stagger-reveal animation: each band fades in left-to-right.
439window.addEventListener('DOMContentLoaded', function() {
440 var bands = document.querySelectorAll('.fnl-band');
441 bands.forEach(function(el, i) {
442 setTimeout(function() { el.classList.add('lit'); }, 120 + i * 110);
443 });
444 var steps = document.querySelectorAll('.fnl-step');
445 steps.forEach(function(el, i) {
446 setTimeout(function() { el.classList.add('lit'); }, 320 + i * 80);
447 });
448 var stats = document.querySelectorAll('.fnl-stat');
449 stats.forEach(function(el, i) {
450 setTimeout(function() { el.classList.add('lit'); }, 60 + i * 90);
451 });
452});
453</script>
454~;
455}
456
457#======================================================================
458# BUILDER VIEW
459#======================================================================
460sub render_builder {
461 my ($db, $dbh, $DB, $userinfo, $site_id, $fid, $sites_aref, $days) = @_;
462
463 my $f = $fid ? load_funnel($db, $dbh, $DB, $fid) : {};
464 $f ||= {};
465 $site_id ||= $f->{site_id} || ($sites_aref->[0] ? $sites_aref->[0]->{id} : 0);
466
467 my $name = h_esc($f->{name} || '');
468 my $steps = parse_steps($f->{steps} || '');
469 if (!scalar @$steps) {
470 $steps = [
471 { kind => 'pageview', match => '/', label => 'Landing page' },
472 { kind => 'pageview', match => '/checkout', label => 'Checkout opened' },
473 { kind => 'pageview', match => '/thank-you', label => 'Purchase complete' },
474 ];
475 }
476
477 my $steps_html = '';
478 my $i = 0;
479 foreach my $s (@$steps) {
480 $i++;
481 my $kind = $s->{kind} || 'pageview';
482 my $match = h_esc($s->{match} || '');
483 my $label = h_esc($s->{label} || "Step $i");
484 my $color = step_color($i - 1, scalar @$steps);
485 my $sel_pv = ($kind eq 'pageview') ? 'selected' : '';
486 my $sel_ev = ($kind eq 'event') ? 'selected' : '';
487 $steps_html .= qq~
488 <div class="fnl-edit-step" style="--step-color:$color" data-step="$i">
489 <div class="fnl-edit-step-num">$i</div>
490 <div class="fnl-edit-step-fields">
491 <label class="fnl-edit-field">
492 <span>Label</span>
493 <input type="text" name="label[]" value="$label" placeholder="e.g. Landing page" />
494 </label>
495 <label class="fnl-edit-field">
496 <span>Match type</span>
497 <select name="kind[]">
498 <option value="pageview" $sel_pv>Page URL contains</option>
499 <option value="event" $sel_ev>Event name equals</option>
500 </select>
501 </label>
502 <label class="fnl-edit-field grow">
503 <span>Pattern</span>
504 <input type="text" name="match[]" value="$match" placeholder="/checkout or add_to_cart" />
505 </label>
506 </div>
507 <button type="button" class="fnl-edit-step-rm" onclick="fnlRm(this)" title="Remove step">
508 <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M18 6 6 18M6 6l12 12"/></svg>
509 </button>
510 </div>~;
511 }
512
513 my $site_options = site_options_html($sites_aref, $site_id);
514 my $action = $fid ? "/funnel_action.cgi?id=$fid" : '/funnel_action.cgi?new=1';
515 my $delete_btn = $fid ? qq~<a class="fnl-edit-del" href="/funnel_action.cgi?id=$fid&amp;delete=1" onclick="return confirm('Delete this funnel?')">Delete funnel</a>~ : '';
516
517 my $css = funnel_css();
518 my $hero = funnel_hero_banner($fid ? "Editing: $name" : 'New funnel',
519 'Add up to 8 ordered steps. Each step is either a page URL pattern or a custom event name fired from your tracker.');
520
521 return qq~
522$css
523$hero
524
525<form method="POST" action="$action" class="fnl-edit-form">
526 <input type="hidden" name="site_id" value="$site_id"/>
527 <div class="fnl-edit-head">
528 <label class="fnl-edit-field grow">
529 <span>Funnel name</span>
530 <input type="text" name="name" value="$name" placeholder="Acquisition funnel, signup flow, etc."/>
531 </label>
532 <label class="fnl-edit-field">
533 <span>Site</span>
534 <select name="site_id">$site_options</select>
535 </label>
536 </div>
537
538 <div id="fnl-edit-steps" class="fnl-edit-steps">
539 $steps_html
540 </div>
541
542 <button type="button" class="fnl-edit-add" onclick="fnlAdd()">
543 <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M12 5v14M5 12h14"/></svg>
544 Add another step
545 </button>
546
547 <div class="fnl-edit-bottom">
548 $delete_btn
549 <a class="fnl-edit-cancel" href="/funnels.cgi">Cancel</a>
550 <button type="submit" class="fnl-edit-save">
551 <span>Save funnel</span>
552 <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M20 6 9 17l-5-5"/></svg>
553 </button>
554 </div>
555</form>
556
557<script>
558var fnlStepN = ${\ scalar @$steps };
559function fnlColor(i, n) {
560 // mirrors the Perl step_color()
561 var hues = [22, 320, 285, 250, 200, 168, 140, 90];
562 var h = hues[i % hues.length];
563 return 'hsl(' + h + ', 92%, 60%)';
564}
565function fnlRenum() {
566 var rows = document.querySelectorAll('#fnl-edit-steps .fnl-edit-step');
567 fnlStepN = rows.length;
568 rows.forEach(function(el, i){
569 el.querySelector('.fnl-edit-step-num').textContent = (i + 1);
570 el.style.setProperty('--step-color', fnlColor(i, rows.length));
571 });
572}
573function fnlAdd() {
574 if (fnlStepN >= 8) return;
575 var c = document.getElementById('fnl-edit-steps');
576 var d = document.createElement('div');
577 d.className = 'fnl-edit-step';
578 d.style.setProperty('--step-color', fnlColor(fnlStepN, fnlStepN + 1));
579 d.innerHTML =
580 '<div class="fnl-edit-step-num">' + (fnlStepN + 1) + '</div>' +
581 '<div class="fnl-edit-step-fields">' +
582 ' <label class="fnl-edit-field"><span>Label</span><input type="text" name="label[]" placeholder="Step name"/></label>' +
583 ' <label class="fnl-edit-field"><span>Match type</span><select name="kind[]"><option value="pageview">Page URL contains</option><option value="event">Event name equals</option></select></label>' +
584 ' <label class="fnl-edit-field grow"><span>Pattern</span><input type="text" name="match[]" placeholder="/checkout or add_to_cart"/></label>' +
585 '</div>' +
586 '<button type="button" class="fnl-edit-step-rm" onclick="fnlRm(this)" title="Remove step"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M18 6 6 18M6 6l12 12"/></svg></button>';
587 c.appendChild(d);
588 fnlRenum();
589 d.querySelector('input[name="label[]"]').focus();
590}
591function fnlRm(btn) {
592 var row = btn.closest('.fnl-edit-step');
593 if (!row) return;
594 if (document.querySelectorAll('#fnl-edit-steps .fnl-edit-step').length <= 2) {
595 alert('A funnel needs at least 2 steps.'); return;
596 }
597 row.remove(); fnlRenum();
598}
599</script>
600~;
601}
602
603#======================================================================
604# ACQUISITION PATHS
605#======================================================================
606# Maps the public dimension key to its column on tracking_sessions, plus
607# a friendly label + fallback for NULL/empty values. The order of the
608# array is the order columns render in the panel.
609sub path_dimensions {
610 return (
611 { key => 'referrer_host', col => 'referrer_host', label => 'Referrer', icon => 'globe', fallback => '(direct)' },
612 { key => 'utm_source', col => 'utm_source', label => 'UTM source', icon => 'tag', fallback => '(none)' },
613 { key => 'utm_campaign', col => 'utm_campaign', label => 'UTM campaign', icon => 'megaphone', fallback => '(none)' },
614 { key => 'landing_path', col => 'landing_path', label => 'Landing page', icon => 'page', fallback => '/' },
615 );
616}
617
618sub path_dim_column {
619 my ($key) = @_;
620 foreach my $d (path_dimensions()) {
621 return $d->{col} if $d->{key} eq ($key || '');
622 }
623 return undef;
624}
625
626sub path_filter_sql {
627 my ($DB, $path_filter) = @_;
628 return ('', '') unless $path_filter && ref($path_filter) eq 'HASH';
629 my $col = path_dim_column($path_filter->{dim}) or return ('', '');
630 my $val = $path_filter->{val}; $val =~ s/[\\']/\\$&/g;
631 # The pattern "(direct)" / "(none)" / "/" sentinel means "match the
632 # NULL/empty/default bucket". For real values it's an exact match.
633 my $join = " JOIN `${DB}`.tracking_sessions ts ON ts.id = e.session_id ";
634 my $where;
635 if ($val eq '(direct)') {
636 $where = " AND (ts.$col IS NULL OR ts.$col='') ";
637 } elsif ($val eq '(none)') {
638 $where = " AND (ts.$col IS NULL OR ts.$col='') ";
639 } else {
640 $where = " AND ts.$col='$val' ";
641 }
642 return ($join, $where);
643}
644
645sub compute_top_paths {
646 my ($db, $dbh, $DB, $site_id, $days) = @_;
647 my %out;
648 return \%out unless $site_id;
649 my $window = "DATE_SUB(NOW(), INTERVAL $days DAY)";
650
651 foreach my $dim (path_dimensions()) {
652 my $col = $dim->{col};
653 my $fb_token = $dim->{fallback};
654 my $fb_esc = $fb_token; $fb_esc =~ s/'/''/g;
655 # Bucket NULL / empty into the dimension's fallback label so the
656 # panel always renders a row for "(direct)" / "(none)" etc. -- the
657 # most informative cohort for sites with few referrers.
658 my @rows = $db->db_readwrite_multiple($dbh, qq~
659 SELECT IFNULL(NULLIF($col,''), '$fb_esc') AS val, COUNT(*) AS n
660 FROM `${DB}`.tracking_sessions
661 WHERE site_id='$site_id'
662 AND first_seen_at >= $window
663 GROUP BY IFNULL(NULLIF($col,''), '$fb_esc')
664 ORDER BY n DESC
665 LIMIT 10
666 ~, $ENV{SCRIPT_NAME}, __LINE__);
667 $out{ $dim->{key} } = { label => $dim->{label}, key => $dim->{key}, rows => \@rows };
668 }
669 return \%out;
670}
671
672#----------------------------------------------------------------------
673# Render the 4-column "Top Acquisition Paths" panel that sits above the
674# funnel viz. Each cell is clickable -- clicking re-renders the entire
675# report scoped to sessions that came in via that path.
676#----------------------------------------------------------------------
677sub render_top_paths_panel {
678 my ($top_paths, $fid, $site_id, $days, $path_filter) = @_;
679
680 my @order = path_dimensions();
681 my $total = 0;
682 foreach my $d (@order) {
683 my $rows = $top_paths->{ $d->{key} }->{rows} || [];
684 foreach my $r (@$rows) { $total += $r->{n}; }
685 }
686 return '<div class="fnl-paths-empty">No acquisition data yet &mdash; install the tracker on your site so we can see where visitors are coming from.</div>'
687 unless $total;
688
689 my $cur_dim = $path_filter ? $path_filter->{dim} : '';
690 my $cur_val = $path_filter ? $path_filter->{val} : '';
691 my $cur_label = '';
692 foreach my $d (@order) {
693 if ($d->{key} eq $cur_dim) { $cur_label = $d->{label}; last; }
694 }
695
696 my $clear_html = '';
697 if ($path_filter) {
698 my $cv = h_esc($cur_val);
699 $clear_html = qq~
700 <div class="fnl-path-active">
701 <div class="fnl-path-active-pill">
702 <span class="lbl">Filtered:</span>
703 <strong>$cur_label</strong>
704 <span class="val">$cv</span>
705 <a class="fnl-path-clear" href="/funnels.cgi?id=$fid&amp;site=$site_id&amp;days=$days" title="Clear filter">
706 <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M18 6 6 18M6 6l12 12"/></svg>
707 </a>
708 </div>
709 </div>~;
710 }
711
712 my $cols = '';
713 my $col_idx = 0;
714 foreach my $d (@order) {
715 $col_idx++;
716 my $rows = $top_paths->{ $d->{key} }->{rows} || [];
717 next unless @$rows;
718
719 my $max = 0;
720 foreach my $r (@$rows) { $max = $r->{n} if $r->{n} > $max; }
721 $max = 1 if $max < 1;
722
723 my $cells = '';
724 my $rank = 0;
725 foreach my $r (@$rows) {
726 $rank++;
727 my $val = $r->{val};
728 my $val_disp = h_esc($val);
729 my $cnt = fmt_num($r->{n});
730 my $pct = int(100.0 * $r->{n} / $max + 0.5);
731 my $active = ($cur_dim eq $d->{key} && $cur_val eq $val) ? 'active' : '';
732 my $val_q = url_q($val);
733 my $href = qq~/funnels.cgi?id=$fid&amp;site=$site_id&amp;days=$days&amp;path_dim=$d->{key}&amp;path_val=$val_q~;
734 $cells .= qq~
735 <a class="fnl-path-row $active" href="$href" style="--pct:${pct}%">
736 <div class="fnl-path-rank">$rank</div>
737 <div class="fnl-path-bar"><span></span></div>
738 <div class="fnl-path-val" title="$val_disp">$val_disp</div>
739 <div class="fnl-path-cnt">$cnt</div>
740 </a>~;
741 }
742 my $hue_start = 21; # brand hue (ABForge orange)
743 $cols .= qq~
744 <div class="fnl-path-col" style="--col-hue:$hue_start">
745 <div class="fnl-path-col-head">
746 <div class="fnl-path-col-label">$d->{label}</div>
747 <div class="fnl-path-col-sub">Top ${\ scalar(@$rows) }</div>
748 </div>
749 <div class="fnl-path-list">$cells</div>
750 </div>~;
751 }
752
753 return qq~
754 <div class="fnl-paths-wrap">
755 <div class="fnl-paths-head">
756 <div>
757 <div class="fnl-paths-title">Top Acquisition Paths</div>
758 <div class="fnl-paths-sub">Where this site's visitors came from. <strong>Click any row</strong> to scope the funnel to that path &mdash; spot which sources convert and which leak.</div>
759 </div>
760 </div>
761 $clear_html
762 <div class="fnl-paths-grid">$cols</div>
763 </div>~;
764}
765
766sub url_q {
767 my ($s) = @_;
768 return '' unless defined $s;
769 $s =~ s/([^A-Za-z0-9\-_.~])/sprintf('%%%02X', ord($1))/eg;
770 return $s;
771}
772
773#======================================================================
774# DATA
775#======================================================================
776sub list_funnels {
777 my ($db, $dbh, $DB, $site_id, $include_hidden) = @_;
778 return () unless $site_id;
779 my $hidden_clause = $include_hidden ? '' : ' AND (is_hidden=0 OR is_hidden IS NULL) ';
780 my @rows = $db->db_readwrite_multiple($dbh, qq~
781 SELECT id, site_id, name, steps, is_active, is_hidden, source, created_at
782 FROM `${DB}`.funnels
783 WHERE site_id='$site_id' AND is_active=1
784 $hidden_clause
785 ORDER BY (source='auto') ASC, created_at DESC
786 LIMIT 100
787 ~, $ENV{SCRIPT_NAME}, __LINE__);
788 return @rows;
789}
790
791sub load_funnel {
792 my ($db, $dbh, $DB, $fid) = @_;
793 return {} unless $fid;
794 my $r = $db->db_readwrite($dbh, qq~
795 SELECT id, site_id, name, steps, is_active, created_at
796 FROM `${DB}`.funnels
797 WHERE id='$fid' AND is_active=1
798 LIMIT 1
799 ~, $ENV{SCRIPT_NAME}, __LINE__);
800 return $r || {};
801}
802
803sub parse_steps {
804 my ($json) = @_;
805 return [] unless defined $json && length $json;
806 my @out;
807 # Tolerant regex parse -- we control the writes from funnel_action.cgi
808 # so the shape is reliable. Avoids pulling in JSON::PP at CGI start.
809 while ($json =~ m/\{\s*"kind"\s*:\s*"([^"]+)"\s*,\s*"match"\s*:\s*"((?:\\.|[^"\\])*)"\s*,\s*"label"\s*:\s*"((?:\\.|[^"\\])*)"\s*\}/g) {
810 my ($k, $m, $l) = ($1, $2, $3);
811 $m =~ s/\\"/"/g; $l =~ s/\\"/"/g;
812 $m =~ s/\\\\/\\/g; $l =~ s/\\\\/\\/g;
813 push @out, { kind => $k, match => $m, label => $l };
814 }
815 return \@out;
816}
817
818#----------------------------------------------------------------------
819# Core funnel math: for each step return how many distinct sessions
820# reached that step or later, IN ORDER. We walk one session at a time
821# because the in-order constraint is too awkward for a single SQL.
822#----------------------------------------------------------------------
823sub compute_funnel_stats {
824 my ($db, $dbh, $DB, $site_id, $steps, $days, $path_filter) = @_;
825 my @bars;
826 my $n = scalar @$steps;
827 return { entered => 0, converted => 0, conv_pct => 0, avg_seconds => 0, bars => [] } unless $site_id && $n;
828
829 # 1) Find the universe of sessions that COULD have entered (hit step 1).
830 # If a path filter is active, scope this to sessions that came in via
831 # that acquisition path. Subsequent steps inherit the scope because
832 # they only consider sessions in this initial set.
833 my $first = $steps->[0];
834 my @step1_sessions = sessions_matching($db, $dbh, $DB, $site_id, $first, $days, $path_filter);
835 my $entered = scalar @step1_sessions;
836
837 # 2) For each subsequent step, walk forward through that session's
838 # events/pageviews and find the first match AFTER the prior step's
839 # timestamp. Track per-session "max step reached".
840 my %max_step; # session_id -> 0..N-1 (highest index hit, in order)
841 my %time_to_first; # session_id -> seconds from step 1 to step 1 (=0)
842 my %time_to_last; # session_id -> seconds from step 1 to highest step
843
844 foreach my $sd (@step1_sessions) {
845 my $sid = $sd->{session_id};
846 my $cursor = $sd->{occurred_at_epoch};
847 $max_step{$sid} = 0;
848 $time_to_first{$sid} = 0;
849 $time_to_last{$sid} = 0;
850 for my $i (1 .. $n - 1) {
851 my $step = $steps->[$i];
852 my $hit = next_match($db, $dbh, $DB, $site_id, $sid, $step, $cursor);
853 last unless $hit;
854 $max_step{$sid} = $i;
855 $time_to_last{$sid} = $hit - $sd->{occurred_at_epoch};
856 $cursor = $hit;
857 }
858 }
859
860 # 3) Per-step counts, percentages, drop-offs.
861 my $converted = 0;
862 my $biggest_drop = { step => '-', pct => 0 };
863 my $sum_complete_time = 0;
864 foreach my $sid (keys %max_step) {
865 if ($max_step{$sid} == $n - 1) {
866 $converted++;
867 $sum_complete_time += $time_to_last{$sid};
868 }
869 }
870 my $avg_seconds = $converted ? int($sum_complete_time / $converted + 0.5) : 0;
871
872 my $prev_count = $entered;
873 for my $i (0 .. $n - 1) {
874 my $count = 0;
875 foreach my $sid (keys %max_step) {
876 $count++ if $max_step{$sid} >= $i;
877 }
878 my $pct = $entered ? sprintf('%.1f', 100.0 * $count / $entered) : 0;
879 my $drop_pct = ($i == 0 || !$prev_count)
880 ? 0
881 : sprintf('%.1f', 100.0 * ($prev_count - $count) / $prev_count);
882 push @bars, {
883 label => $steps->[$i]->{label} || ('Step ' . ($i + 1)),
884 count => $count,
885 pct => $pct,
886 drop_pct => $drop_pct,
887 };
888 if ($i > 0 && $drop_pct > $biggest_drop->{pct}) {
889 $biggest_drop = { step => $steps->[$i]->{label} || ('Step ' . ($i + 1)), pct => $drop_pct };
890 }
891 $prev_count = $count;
892 }
893
894 return {
895 entered => $entered,
896 converted => $converted,
897 conv_pct => $entered ? sprintf('%.1f', 100.0 * $converted / $entered) : 0,
898 avg_seconds => $avg_seconds,
899 biggest_drop => $biggest_drop,
900 bars => \@bars,
901 };
902}
903
904sub sessions_matching {
905 my ($db, $dbh, $DB, $site_id, $step, $days, $path_filter) = @_;
906 my $kind = $step->{kind} || 'pageview';
907 my $match = $step->{match} || '';
908 $match =~ s/[\\']/\\$&/g;
909 my $window = "DATE_SUB(NOW(), INTERVAL $days DAY)";
910
911 # When an acquisition-path filter is set, restrict the step-1 universe
912 # to sessions whose tracking_sessions row matches that path.
913 my ($path_join, $path_where) = path_filter_sql($DB, $path_filter);
914
915 my $tbl = ($kind eq 'event') ? 'events' : 'pageviews';
916 my $type_where = ($kind eq 'event') ? "AND e.event_name='$match'"
917 : "AND e.page_path LIKE '%$match%'";
918 my @rows = $db->db_readwrite_multiple($dbh, qq~
919 SELECT e.session_id, UNIX_TIMESTAMP(MIN(e.occurred_at)) AS occurred_at_epoch
920 FROM `${DB}`.$tbl e
921 $path_join
922 WHERE e.site_id='$site_id'
923 $type_where
924 AND e.occurred_at >= $window
925 $path_where
926 GROUP BY e.session_id
927 LIMIT 50000
928 ~, $ENV{SCRIPT_NAME}, __LINE__);
929 return @rows;
930}
931
932sub next_match {
933 my ($db, $dbh, $DB, $site_id, $session_id, $step, $after_epoch) = @_;
934 my $kind = $step->{kind} || 'pageview';
935 my $match = $step->{match} || '';
936 $match =~ s/[\\']/\\$&/g;
937
938 my $r;
939 if ($kind eq 'event') {
940 $r = $db->db_readwrite($dbh, qq~
941 SELECT UNIX_TIMESTAMP(MIN(occurred_at)) AS t
942 FROM `${DB}`.events
943 WHERE site_id='$site_id'
944 AND session_id='$session_id'
945 AND event_name='$match'
946 AND UNIX_TIMESTAMP(occurred_at) >= '$after_epoch'
947 ~, $ENV{SCRIPT_NAME}, __LINE__);
948 } else {
949 $r = $db->db_readwrite($dbh, qq~
950 SELECT UNIX_TIMESTAMP(MIN(occurred_at)) AS t
951 FROM `${DB}`.pageviews
952 WHERE site_id='$site_id'
953 AND session_id='$session_id'
954 AND page_path LIKE '%$match%'
955 AND UNIX_TIMESTAMP(occurred_at) >= '$after_epoch'
956 ~, $ENV{SCRIPT_NAME}, __LINE__);
957 }
958 return ($r && $r->{t}) ? $r->{t} : 0;
959}
960
961sub top_exits_for_step {
962 my ($db, $dbh, $DB, $site_id, $steps, $step_idx, $days) = @_;
963 # "Exit" = last page_path seen in the session, where the session got
964 # at least to this step but no further. Not perfect but useful.
965 return [] unless $site_id;
966 return [] if $step_idx >= scalar @$steps - 1; # no exits at the final step
967 my $window = "DATE_SUB(NOW(), INTERVAL $days DAY)";
968
969 my $step = $steps->[$step_idx];
970 my $kind = $step->{kind} || 'pageview';
971 my $match = $step->{match} || '';
972 $match =~ s/[\\']/\\$&/g;
973
974 my $session_filter;
975 if ($kind eq 'event') {
976 $session_filter = qq~ (SELECT DISTINCT session_id FROM `${DB}`.events
977 WHERE site_id='$site_id' AND event_name='$match'
978 AND occurred_at >= $window) ~;
979 } else {
980 $session_filter = qq~ (SELECT DISTINCT session_id FROM `${DB}`.pageviews
981 WHERE site_id='$site_id' AND page_path LIKE '%$match%'
982 AND occurred_at >= $window) ~;
983 }
984
985 my @rows = $db->db_readwrite_multiple($dbh, qq~
986 SELECT exit_page AS path, COUNT(*) AS cnt
987 FROM `${DB}`.tracking_sessions
988 WHERE site_id='$site_id'
989 AND exit_page IS NOT NULL AND exit_page != ''
990 AND last_seen_at >= $window
991 AND id IN $session_filter
992 GROUP BY exit_page
993 ORDER BY cnt DESC
994 LIMIT 5
995 ~, $ENV{SCRIPT_NAME}, __LINE__);
996 return \@rows;
997}
998
999#======================================================================
1000# SVG funnel - the visual centerpiece
1001#======================================================================
1002sub render_svg_funnel {
1003 my ($bars, $steps) = @_;
1004 my $n = scalar @$bars;
1005 return '<div class="fnl-empty">No data yet.</div>' unless $n;
1006
1007 # SVG layout: each step is a horizontal trapezoid, stacked vertically.
1008 # Top band is full width; subsequent bands narrow proportionally to
1009 # their visitor count. The taper between bands is rendered as a
1010 # connecting polygon so the whole thing looks like a 3-D funnel.
1011 my $W = 1080; # viewbox width
1012 my $H_band = 88; # height per band
1013 my $H_gap = 22; # vertical gap between bands
1014 my $top_pad = 10;
1015 my $H = $top_pad + $n * $H_band + ($n - 1) * $H_gap + 36;
1016
1017 # Defs: per-step gradient.
1018 my $defs = '';
1019 for my $i (0 .. $n - 1) {
1020 my $c1 = step_color($i, $n);
1021 my $c2 = step_color_dark($i, $n);
1022 $defs .= qq~<linearGradient id="fnlG$i" x1="0" y1="0" x2="1" y2="0">
1023 <stop offset="0%" stop-color="$c1"/>
1024 <stop offset="100%" stop-color="$c2"/>
1025 </linearGradient>~;
1026 }
1027 # Drop-leak gradient (red).
1028 $defs .= qq~<linearGradient id="fnlLeak" x1="0" y1="0" x2="1" y2="0">
1029 <stop offset="0%" stop-color="#ff5b5b" stop-opacity="0.95"/>
1030 <stop offset="100%" stop-color="#7a1f1f" stop-opacity="0.0"/>
1031 </linearGradient>~;
1032
1033 my $entered = $bars->[0]->{count} || 1;
1034 my $min_pct = 8; # never draw a band narrower than this % of full
1035
1036 my $bands = '';
1037 my $connectors = '';
1038 my @widths;
1039 for my $i (0 .. $n - 1) {
1040 my $count = $bars->[$i]->{count} || 0;
1041 my $pct = $entered ? (100.0 * $count / $entered) : 0;
1042 my $vw = ($pct < $min_pct) ? $min_pct : $pct;
1043 my $bw = int($W * $vw / 100);
1044 push @widths, $bw;
1045 }
1046
1047 for my $i (0 .. $n - 1) {
1048 my $bw = $widths[$i];
1049 my $x = int(($W - $bw) / 2);
1050 my $y = $top_pad + $i * ($H_band + $H_gap);
1051 my $count = fmt_num($bars->[$i]->{count});
1052 my $pct = $bars->[$i]->{pct};
1053 my $label = h_esc($bars->[$i]->{label});
1054 my $drop = $bars->[$i]->{drop_pct} || 0;
1055 my $tooltip = "$label - $count visitors ($pct%)";
1056
1057 # Band: rounded rect.
1058 $bands .= qq~<g class="fnl-band" data-step="$i">
1059 <title>$tooltip</title>
1060 <rect x="$x" y="$y" rx="14" ry="14" width="$bw" height="$H_band" fill="url(#fnlG$i)"/>~;
1061 # Inner highlight stripe.
1062 my $hy = $y + 6;
1063 my $hw = $bw - 24;
1064 my $hx = $x + 12;
1065 $bands .= qq~<rect x="$hx" y="$hy" rx="6" ry="6" width="$hw" height="3" fill="white" opacity="0.18"/>~;
1066
1067 # Number + label on band (if wide enough).
1068 my $cx = int($x + $bw / 2);
1069 my $cy = $y + 38;
1070 if ($bw > 220) {
1071 $bands .= qq~<text x="$cx" y="$cy" text-anchor="middle" font-family="-apple-system,sans-serif" font-size="22" font-weight="800" fill="white">$count</text>~;
1072 my $cy2 = $y + 62;
1073 $bands .= qq~<text x="$cx" y="$cy2" text-anchor="middle" font-family="-apple-system,sans-serif" font-size="12" font-weight="500" fill="white" opacity="0.85">$label &#183; ${pct}%</text>~;
1074 } elsif ($bw > 110) {
1075 $bands .= qq~<text x="$cx" y="$cy" text-anchor="middle" font-family="-apple-system,sans-serif" font-size="16" font-weight="700" fill="white">$count</text>~;
1076 my $cy2 = $y + 58;
1077 $bands .= qq~<text x="$cx" y="$cy2" text-anchor="middle" font-family="-apple-system,sans-serif" font-size="11" fill="white" opacity="0.85">${pct}%</text>~;
1078 } else {
1079 $bands .= qq~<text x="$cx" y="$cy" text-anchor="middle" font-family="-apple-system,sans-serif" font-size="14" font-weight="700" fill="white">$count</text>~;
1080 }
1081
1082 # Side label OUTSIDE the band so very-narrow bands still get text.
1083 my $lbl_x = $x + $bw + 18;
1084 my $lbl_y = $y + 36;
1085 if ($lbl_x + 180 > $W) {
1086 $lbl_x = $x - 18;
1087 my $anc = 'end';
1088 $bands .= qq~<text x="$lbl_x" y="$lbl_y" text-anchor="$anc" font-family="-apple-system,sans-serif" font-size="13" font-weight="600" fill="#cfd6e0">$label</text>~;
1089 my $lbl_y2 = $y + 56;
1090 $bands .= qq~<text x="$lbl_x" y="$lbl_y2" text-anchor="$anc" font-family="-apple-system,sans-serif" font-size="11" fill="#7a8392">${pct}% of entrants</text>~;
1091 } else {
1092 $bands .= qq~<text x="$lbl_x" y="$lbl_y" font-family="-apple-system,sans-serif" font-size="13" font-weight="600" fill="#cfd6e0">$label</text>~;
1093 my $lbl_y2 = $y + 56;
1094 $bands .= qq~<text x="$lbl_x" y="$lbl_y2" font-family="-apple-system,sans-serif" font-size="11" fill="#7a8392">${pct}% of entrants</text>~;
1095 }
1096 $bands .= qq~</g>~;
1097
1098 # Connector polygon between this band and the next.
1099 if ($i < $n - 1) {
1100 my $bw2 = $widths[$i + 1];
1101 my $x2 = int(($W - $bw2) / 2);
1102 my $y2a = $y + $H_band;
1103 my $y2b = $y + $H_band + $H_gap;
1104 my $p1x = $x; my $p1y = $y2a;
1105 my $p2x = $x + $bw; my $p2y = $y2a;
1106 my $p3x = $x2 + $bw2; my $p3y = $y2b;
1107 my $p4x = $x2; my $p4y = $y2b;
1108 my $c1 = step_color($i, $n);
1109 $connectors .= qq~<polygon points="$p1x,$p1y $p2x,$p2y $p3x,$p3y $p4x,$p4y" fill="$c1" opacity="0.18"/>~;
1110
1111 # Drop-off leak: if drop > 0 show a red curve trailing off the right edge.
1112 my $drop_pct = $bars->[$i + 1]->{drop_pct} || 0;
1113 if ($drop_pct > 0.5) {
1114 my $leak_w = int(80 + $drop_pct * 2.4);
1115 my $leak_h = int(8 + $drop_pct * 0.6);
1116 my $lx = $p2x + 8;
1117 my $ly = $p2y - int($H_gap / 2);
1118 my $rx = $lx + $leak_w;
1119 my $ry = $ly + $leak_h;
1120 # Rounded leak strip
1121 $bands .= qq~<rect x="$lx" y="$ly" rx="6" ry="6" width="$leak_w" height="$leak_h" fill="url(#fnlLeak)"/>~;
1122 my $tx = $lx + 6;
1123 my $ty = $ly + $leak_h + 14;
1124 $bands .= qq~<text x="$tx" y="$ty" font-family="-apple-system,sans-serif" font-size="11" font-weight="700" fill="#ff8a8a">-${drop_pct}% leak</text>~;
1125 }
1126 }
1127 }
1128
1129 return qq~
1130 <div class="fnl-svg-stage">
1131 <svg viewBox="0 0 $W $H" class="fnl-svg" preserveAspectRatio="xMidYMid meet">
1132 <defs>$defs</defs>
1133 $connectors
1134 $bands
1135 </svg>
1136 </div>~;
1137}
1138
1139#======================================================================
1140# HELPERS
1141#======================================================================
1142sub step_color {
1143 my ($i, $n) = @_;
1144 # 8 distinct hues (orange to green) covering most funnel lengths.
1145 my @hues = (22, 320, 285, 250, 200, 168, 140, 90);
1146 my $h = $hues[$i % scalar @hues];
1147 return "hsl($h, 92%, 60%)";
1148}
1149sub step_color_dark {
1150 my ($i, $n) = @_;
1151 my @hues = (22, 320, 285, 250, 200, 168, 140, 90);
1152 my $h = $hues[$i % scalar @hues];
1153 return "hsl($h, 80%, 38%)";
1154}
1155
1156sub site_options_html {
1157 my ($sites_aref, $sel_id) = @_;
1158 my $html = '';
1159 foreach my $s (@$sites_aref) {
1160 my $sel = ($s->{id} == $sel_id) ? 'selected' : '';
1161 my $nm = h_esc($s->{display_name} || $s->{site_domain} || ('Site ' . $s->{id}));
1162 $html .= qq~<option value="$s->{id}" $sel>$nm</option>~;
1163 }
1164 $html ||= '<option value="0">No sites yet</option>';
1165 return $html;
1166}
1167
1168sub days_options_html {
1169 my ($sel) = @_;
1170 my $html = '';
1171 foreach my $d (7, 14, 30, 60, 90, 180) {
1172 my $s = ($d == $sel) ? 'selected' : '';
1173 $html .= qq~<option value="$d" $s>Last $d days</option>~;
1174 }
1175 return $html;
1176}
1177
1178sub fmt_num {
1179 my ($n) = @_;
1180 $n = int($n || 0);
1181 1 while $n =~ s/(\d)(\d{3})(?!\d)/$1,$2/;
1182 return $n;
1183}
1184
1185sub fmt_duration {
1186 my ($s) = @_;
1187 $s = int($s || 0);
1188 return '&mdash;' if $s <= 0;
1189 if ($s < 60) { return "${s}s"; }
1190 if ($s < 3600) { my $m = int($s / 60); my $r = $s % 60; return "${m}m ${r}s"; }
1191 if ($s < 86400) { my $h = int($s / 3600); my $m = int(($s % 3600) / 60); return "${h}h ${m}m"; }
1192 my $d = int($s / 86400); my $h = int(($s % 86400) / 3600); return "${d}d ${h}h";
1193}
1194
1195sub h_esc {
1196 my ($s) = @_;
1197 return '' unless defined $s;
1198 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g; $s =~ s/"/&quot;/g;
1199 return $s;
1200}
1201
1202sub render_notfound {
1203 my $css = funnel_css();
1204 return "$css<div class='fnl-empty'><h2>Funnel not found</h2><p>It may have been deleted, or you might not have access.</p><a class='fnl-cta-big' href='/funnels.cgi'>Back to funnels</a></div>";
1205}
1206
1207#======================================================================
1208# HERO BANNER
1209#======================================================================
1210sub funnel_hero_banner {
1211 my ($title, $sub) = @_;
1212 my $t = h_esc($title);
1213 return qq~
1214 <div class="fnl-hero">
1215 <div class="fnl-hero-orb orb-a"></div>
1216 <div class="fnl-hero-orb orb-b"></div>
1217 <div class="fnl-hero-orb orb-c"></div>
1218 <div class="fnl-hero-grid"></div>
1219 <div class="fnl-hero-content">
1220 <div class="fnl-hero-pill">
1221 <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>
1222 <span>Traffic Funnels</span>
1223 </div>
1224 <h1 class="fnl-hero-title">$t</h1>
1225 <p class="fnl-hero-sub">$sub</p>
1226 </div>
1227 </div>~;
1228}
1229
1230#======================================================================
1231# CSS - inline so the page is self-contained
1232#======================================================================
1233sub funnel_css {
1234 return <<'CSS';
1235<style>
1236/* =========================================================
1237 ABForge Funnels - all styles scoped to .fnl-*
1238 ========================================================= */
1239.fnl-hero.fnl-hero {
1240 position: relative;
1241 margin: -8px -8px 24px;
1242 padding: 68px 140px 72px 40px !important;
1243 border-radius: 22px;
1244 background:
1245 radial-gradient(900px 280px at 14% 0%, rgba(249,115,22,0.40), transparent 70%),
1246 radial-gradient(700px 240px at 88% 90%, rgba(168,85,247,0.44), transparent 70%),
1247 linear-gradient(180deg, #0d1219 0%, #060912 100%);
1248 overflow: hidden;
1249 border: 1px solid rgba(255,255,255,0.06);
1250 box-shadow: 0 28px 60px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.04);
1251}
1252.fnl-hero-grid {
1253 position: absolute; inset: 0;
1254 background-image:
1255 linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px),
1256 linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px);
1257 background-size: 36px 36px;
1258 mask-image: radial-gradient(ellipse at 50% 30%, black 0%, transparent 70%);
1259 pointer-events: none;
1260}
1261.fnl-hero-orb {
1262 position: absolute;
1263 border-radius: 50%;
1264 filter: blur(60px);
1265 opacity: 0.85;
1266 pointer-events: none;
1267 animation: fnlOrb 12s ease-in-out infinite;
1268}
1269.fnl-hero-orb.orb-a {
1270 width: 320px; height: 320px; left: -60px; top: -80px;
1271 background: radial-gradient(circle, #f97316 0%, transparent 70%);
1272}
1273.fnl-hero-orb.orb-b {
1274 width: 380px; height: 380px; right: -100px; top: -40px;
1275 background: radial-gradient(circle, #a855f7 0%, transparent 70%);
1276 animation-delay: -3s;
1277}
1278.fnl-hero-orb.orb-c {
1279 width: 260px; height: 260px; left: 40%; bottom: -120px;
1280 background: radial-gradient(circle, #22d3ee 0%, transparent 70%);
1281 animation-delay: -7s;
1282}
1283@keyframes fnlOrb {
1284 0%, 100% { transform: translate(0, 0) scale(1); }
1285 50% { transform: translate(20px, -16px) scale(1.05); }
1286}
1287.fnl-hero-content { position: relative; z-index: 1; max-width: 760px; }
1288.fnl-hero-pill {
1289 display: inline-flex; align-items: center; gap: 8px;
1290 padding: 6px 12px;
1291 background: rgba(249,115,22,0.16);
1292 border: 1px solid rgba(249,115,22,0.42);
1293 border-radius: 999px;
1294 color: #ffae6e;
1295 font-size: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
1296 margin-bottom: 24px;
1297}
1298.fnl-hero-title {
1299 margin: 0 0 20px;
1300 font-size: 48px; line-height: 1.05; font-weight: 800;
1301 color: #fff;
1302 letter-spacing: -0.02em;
1303 text-shadow: 0 4px 24px rgba(0,0,0,0.5);
1304}
1305.fnl-hero-sub {
1306 margin: 0;
1307 color: rgba(207,214,224,0.85);
1308 font-size: 18px; line-height: 1.55;
1309}
1310.fnl-hero-sub strong { color: #fff; }
1311@media (max-width: 700px) {
1312 .fnl-hero-title { font-size: 32px; }
1313 .fnl-hero { padding: 36px 22px 44px; }
1314}
1315
1316/* Toolbar */
1317.fnl-toolbar {
1318 display: flex; align-items: center; justify-content: space-between;
1319 margin: 0 0 22px;
1320}
1321.fnl-toolbar-form { display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap; }
1322.fnl-field { display: inline-flex; flex-direction: column; gap: 4px; }
1323.fnl-field-lbl { font-size: 11px; color: #6f7787; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
1324.fnl-field select, .fnl-field input {
1325 background: #161c25; border: 1px solid rgba(255,255,255,0.08);
1326 color: #e8edf2; padding: 0 14px; border-radius: 10px;
1327 font-size: 14px; min-width: 160px;
1328 height: 42px; box-sizing: border-box;
1329}
1330.fnl-toolbar-btn, .fnl-cta-pill {
1331 display: inline-flex; align-items: center; gap: 8px;
1332 padding: 0 16px; height: 42px; box-sizing: border-box;
1333 border-radius: 10px; font-size: 14px; font-weight: 600;
1334 text-decoration: none; transition: all 0.18s ease;
1335 background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
1336 color: #fff;
1337 border: none;
1338 box-shadow: 0 6px 18px rgba(249,115,22,0.32);
1339}
1340.fnl-toolbar-btn:hover, .fnl-cta-pill:hover { transform: translateY(-1px); box-shadow: 0 10px 24px rgba(249,115,22,0.46); }
1341.fnl-toolbar-btn.ghost {
1342 background: rgba(255,255,255,0.04);
1343 border: 1px solid rgba(255,255,255,0.08);
1344 color: #cfd6e0;
1345 box-shadow: none;
1346}
1347.fnl-toolbar-btn.ghost:hover { background: rgba(255,255,255,0.07); }
1348
1349/* List grid */
1350.fnl-grid {
1351 display: grid; gap: 18px;
1352 grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
1353}
1354.fnl-card {
1355 display: block; text-decoration: none; color: inherit;
1356 background: linear-gradient(160deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0.015) 100%);
1357 border: 1px solid rgba(255,255,255,0.06);
1358 border-radius: 16px;
1359 padding: 20px 22px;
1360 transition: all 0.2s ease;
1361 position: relative;
1362 overflow: hidden;
1363}
1364.fnl-card::before {
1365 content: ""; position: absolute; inset: 0;
1366 background: linear-gradient(135deg, rgba(249,115,22,0.16), transparent 60%);
1367 opacity: 0; transition: opacity 0.2s;
1368}
1369.fnl-card:hover { transform: translateY(-2px); border-color: rgba(249,115,22,0.4); box-shadow: 0 14px 32px rgba(0,0,0,0.5); }
1370.fnl-card:hover::before { opacity: 1; }
1371.fnl-card > * { position: relative; }
1372.fnl-card-head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px; }
1373.fnl-card-name { font-size: 17px; font-weight: 700; color: #fff; }
1374.fnl-card-steps { font-size: 11px; color: #7a8392; background: rgba(255,255,255,0.05); padding: 3px 9px; border-radius: 999px; font-weight: 600; }
1375.fnl-card-path { display: flex; align-items: center; gap: 8px; color: #9aa3b5; font-size: 12px; margin-bottom: 16px; }
1376.fnl-spark { display: flex; align-items: flex-end; gap: 6px; height: 50px; margin-bottom: 14px; padding: 0 2px; border-bottom: 1px dashed rgba(255,255,255,0.06); }
1377.fnl-spark-bar {
1378 height: 100%;
1379 background: linear-gradient(180deg, rgba(249,115,22,0.85), rgba(249,115,22,0.25));
1380 border-radius: 4px 4px 0 0;
1381 min-width: 6px;
1382 position: relative;
1383}
1384.fnl-card-stats { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; }
1385.fnl-card-stats > div .lbl { font-size: 10px; color: #6f7787; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; margin-bottom: 3px; }
1386.fnl-card-stats > div .val { font-size: 17px; font-weight: 800; color: #fff; }
1387.fnl-card-stats > div .val.grad { background: linear-gradient(90deg, #f97316, #a855f7, #22d3ee); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
1388
1389/* Empty state */
1390.fnl-empty {
1391 text-align: center; padding: 80px 24px;
1392 background: linear-gradient(160deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01));
1393 border: 1px dashed rgba(255,255,255,0.08);
1394 border-radius: 16px;
1395 position: relative;
1396}
1397.fnl-empty-orb {
1398 position: absolute; left: 50%; top: 30px; transform: translateX(-50%);
1399 width: 140px; height: 140px; border-radius: 50%; filter: blur(30px);
1400 background: radial-gradient(circle, #f97316 0%, transparent 70%);
1401 opacity: 0.5; animation: fnlOrb 8s ease-in-out infinite;
1402}
1403.fnl-empty h2 { color: #fff; font-size: 24px; margin: 80px 0 8px; }
1404.fnl-empty p { color: #9aa3b5; max-width: 480px; margin: 0 auto 24px; line-height: 1.6; }
1405.fnl-cta-big {
1406 display: inline-flex; align-items: center; gap: 12px;
1407 padding: 14px 26px; border-radius: 12px;
1408 background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
1409 color: #fff; font-weight: 700; text-decoration: none;
1410 box-shadow: 0 10px 28px rgba(249,115,22,0.42);
1411 transition: all 0.18s ease;
1412}
1413.fnl-cta-big:hover { transform: translateY(-2px); box-shadow: 0 14px 36px rgba(249,115,22,0.56); }
1414
1415/* Stats grid (report view) */
1416/* =========================================================
1417 Top Acquisition Paths panel
1418 ========================================================= */
1419.fnl-paths-wrap {
1420 position: relative;
1421 padding: 22px 24px 24px;
1422 background: linear-gradient(160deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01));
1423 border: 1px solid rgba(255,255,255,0.06);
1424 border-radius: 16px;
1425 margin: 0 0 22px;
1426 overflow: hidden;
1427}
1428.fnl-paths-wrap::before {
1429 content: ""; position: absolute; inset: 0;
1430 background:
1431 radial-gradient(500px 200px at 0% 0%, rgba(249,115,22,0.10), transparent 60%),
1432 radial-gradient(500px 200px at 100% 100%, rgba(168,85,247,0.10), transparent 60%);
1433 pointer-events: none;
1434}
1435.fnl-paths-head { position: relative; margin-bottom: 24px; max-width: 720px; }
1436.fnl-paths-title { font-size: 18px; font-weight: 700; color: #fff; }
1437.fnl-paths-sub { font-size: 13px; color: #8893a4; margin-top: 4px; line-height: 1.55; }
1438.fnl-paths-sub strong { color: #fff; font-weight: 600; }
1439.fnl-paths-empty {
1440 padding: 28px 22px; text-align: center; color: #7a8392;
1441 background: linear-gradient(160deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01));
1442 border: 1px dashed rgba(255,255,255,0.08);
1443 border-radius: 14px;
1444 margin: 0 0 22px;
1445 font-size: 13px;
1446}
1447.fnl-path-active { position: relative; margin: 0 0 16px; }
1448.fnl-path-active-pill {
1449 display: inline-flex; align-items: center; gap: 10px;
1450 padding: 8px 12px 8px 14px;
1451 background: linear-gradient(135deg, rgba(249,115,22,0.18), rgba(168,85,247,0.18));
1452 border: 1px solid rgba(249,115,22,0.40);
1453 border-radius: 999px;
1454 font-size: 12px; color: #cfd6e0;
1455}
1456.fnl-path-active-pill .lbl { color: #ffae6e; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; font-size: 10px; }
1457.fnl-path-active-pill strong { color: #fff; font-weight: 700; }
1458.fnl-path-active-pill .val { color: #fff; background: rgba(255,255,255,0.08); padding: 3px 9px; border-radius: 999px; font-family: 'SF Mono', Menlo, monospace; font-size: 11px; }
1459.fnl-path-clear { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; background: rgba(239,68,68,0.16); border: 1px solid rgba(239,68,68,0.40); border-radius: 999px; color: #ff8a8a; text-decoration: none; transition: all 0.18s; }
1460.fnl-path-clear:hover { background: rgba(239,68,68,0.28); transform: scale(1.05); }
1461.fnl-paths-grid {
1462 position: relative;
1463 display: grid;
1464 grid-template-columns: repeat(4, 1fr);
1465 gap: 14px;
1466}
1467@media (max-width: 1100px) { .fnl-paths-grid { grid-template-columns: repeat(2, 1fr); } }
1468@media (max-width: 600px) { .fnl-paths-grid { grid-template-columns: 1fr; } }
1469.fnl-path-col {
1470 background: rgba(0,0,0,0.18);
1471 border: 1px solid rgba(255,255,255,0.05);
1472 border-top: 2px solid hsl(var(--col-hue), 92%, 60%);
1473 border-radius: 12px;
1474 padding: 14px 14px 10px;
1475 box-shadow: 0 -10px 24px -16px hsl(var(--col-hue), 92%, 60%) inset;
1476}
1477.fnl-path-col-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 10px; }
1478.fnl-path-col-label { font-size: 12px; font-weight: 700; color: #fff; letter-spacing: 0.04em; }
1479.fnl-path-col-sub { font-size: 10px; color: #6f7787; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
1480.fnl-path-list { display: flex; flex-direction: column; gap: 4px; }
1481.fnl-path-row {
1482 position: relative; display: grid;
1483 grid-template-columns: 18px 1fr auto;
1484 align-items: center; gap: 8px;
1485 padding: 8px 10px;
1486 background: rgba(255,255,255,0.02);
1487 border: 1px solid transparent;
1488 border-radius: 8px;
1489 color: inherit; text-decoration: none;
1490 font-size: 12px;
1491 transition: all 0.18s;
1492 overflow: hidden;
1493}
1494.fnl-path-row::before {
1495 content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: var(--pct, 0%);
1496 background: linear-gradient(90deg, hsla(var(--col-hue), 92%, 60%, 0.18) 0%, hsla(var(--col-hue), 92%, 60%, 0.04) 100%);
1497 border-radius: 8px 0 0 8px;
1498 z-index: 0;
1499 transition: width 0.45s cubic-bezier(.4,0,.2,1);
1500}
1501.fnl-path-row > * { position: relative; z-index: 1; }
1502.fnl-path-row:hover {
1503 background: rgba(255,255,255,0.05);
1504 border-color: hsla(var(--col-hue), 92%, 60%, 0.45);
1505 transform: translateX(2px);
1506}
1507.fnl-path-row.active {
1508 background: hsla(var(--col-hue), 92%, 60%, 0.10);
1509 border-color: hsl(var(--col-hue), 92%, 60%);
1510 box-shadow: 0 0 18px -4px hsla(var(--col-hue), 92%, 60%, 0.5);
1511}
1512.fnl-path-row.active::before { width: 100%; }
1513.fnl-path-rank { color: #6f7787; font-weight: 700; text-align: center; font-size: 10px; }
1514.fnl-path-bar { display: none; } /* legacy slot, kept in markup */
1515.fnl-path-val {
1516 color: #cfd6e0; font-family: 'SF Mono', Menlo, monospace;
1517 font-size: 11px;
1518 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1519}
1520.fnl-path-row:hover .fnl-path-val, .fnl-path-row.active .fnl-path-val { color: #fff; }
1521.fnl-path-cnt { color: #fff; font-weight: 700; font-size: 12px; padding-left: 4px; }
1522
1523.fnl-stats-grid {
1524 display: grid; gap: 16px; margin: 0 0 28px;
1525 grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
1526}
1527.fnl-stat {
1528 position: relative; overflow: hidden;
1529 padding: 22px 22px 24px;
1530 background: linear-gradient(160deg, rgba(255,255,255,0.04), rgba(255,255,255,0.01));
1531 border: 1px solid rgba(255,255,255,0.06);
1532 border-radius: 16px;
1533 opacity: 0; transform: translateY(10px); transition: all 0.5s cubic-bezier(.4,0,.2,1);
1534}
1535.fnl-stat.lit { opacity: 1; transform: none; }
1536.fnl-stat::after {
1537 content: ""; position: absolute;
1538 width: 160px; height: 160px; border-radius: 50%; filter: blur(40px);
1539 right: -40px; top: -40px; opacity: 0.5;
1540 pointer-events: none;
1541}
1542.fnl-stat.orb-orange::after { background: #f97316; }
1543.fnl-stat.orb-pink::after { background: #ec4899; }
1544.fnl-stat.orb-violet::after { background: #a855f7; }
1545.fnl-stat.orb-cyan::after { background: #22d3ee; }
1546.fnl-stat .lbl { font-size: 11px; color: #8893a4; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
1547.fnl-stat .val { font-size: 34px; font-weight: 800; color: #fff; margin: 8px 0 6px; line-height: 1; position: relative; }
1548.fnl-stat .val.grad-text { background: linear-gradient(90deg, #f97316, #ec4899, #a855f7); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
1549.fnl-stat .sub { font-size: 12px; color: #7a8392; position: relative; }
1550
1551/* Visualization wrap */
1552.fnl-viz-wrap {
1553 position: relative;
1554 padding: 28px 26px 22px;
1555 background: linear-gradient(180deg, #0f1520 0%, #0a0f17 100%);
1556 border: 1px solid rgba(255,255,255,0.06);
1557 border-radius: 18px;
1558 overflow: hidden;
1559 margin: 0 0 28px;
1560}
1561.fnl-viz-glow {
1562 position: absolute; inset: 0;
1563 background:
1564 radial-gradient(700px 200px at 20% 0%, rgba(249,115,22,0.16), transparent 70%),
1565 radial-gradient(600px 200px at 80% 100%, rgba(168,85,247,0.16), transparent 70%);
1566 pointer-events: none;
1567}
1568.fnl-viz-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; position: relative; margin-bottom: 16px; }
1569.fnl-viz-title { font-size: 18px; font-weight: 700; color: #fff; }
1570.fnl-viz-sub { font-size: 12px; color: #8893a4; margin-top: 4px; }
1571.fnl-biggest-drop {
1572 text-align: right; padding: 10px 16px;
1573 background: rgba(239,68,68,0.10);
1574 border: 1px solid rgba(239,68,68,0.35);
1575 border-radius: 12px;
1576}
1577.fnl-biggest-drop .lbl { font-size: 10px; color: #ff8a8a; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
1578.fnl-biggest-drop .val { font-size: 26px; font-weight: 800; color: #ff5b5b; margin: 2px 0 2px; line-height: 1; }
1579.fnl-biggest-drop .sub { font-size: 11px; color: #cfd6e0; }
1580.fnl-svg-stage { position: relative; padding: 8px 0 0; }
1581.fnl-svg { width: 100%; height: auto; max-height: 720px; display: block; }
1582.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; }
1583.fnl-band.lit { opacity: 1; transform: scaleX(1); }
1584.fnl-band:hover { filter: brightness(1.12) drop-shadow(0 0 14px rgba(249,115,22,0.5)); }
1585
1586/* Step cards (under the viz) */
1587.fnl-step-grid { display: grid; gap: 12px; }
1588.fnl-step {
1589 padding: 18px 22px;
1590 background: linear-gradient(160deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01));
1591 border: 1px solid rgba(255,255,255,0.06);
1592 border-left: 3px solid var(--step-color, #f97316);
1593 border-radius: 12px;
1594 opacity: 0; transform: translateX(-10px); transition: all 0.5s cubic-bezier(.4,0,.2,1);
1595}
1596.fnl-step.lit { opacity: 1; transform: none; }
1597.fnl-step-head { display: flex; gap: 16px; align-items: center; }
1598.fnl-step-num {
1599 width: 38px; height: 38px; border-radius: 10px;
1600 background: var(--step-color, #f97316);
1601 color: #0a0f17; font-weight: 800; font-size: 18px;
1602 display: flex; align-items: center; justify-content: center;
1603 box-shadow: 0 6px 16px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.3);
1604}
1605.fnl-step-info { flex: 1; }
1606.fnl-step-label { font-size: 15px; font-weight: 700; color: #fff; }
1607.fnl-step-meta { font-size: 12px; color: #8893a4; margin-top: 2px; }
1608.fnl-step-drop { text-align: right; padding: 6px 12px; border-radius: 8px; min-width: 88px; }
1609.fnl-step-drop .lbl { font-size: 9px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.8; }
1610.fnl-step-drop .val { font-size: 18px; font-weight: 800; line-height: 1; margin-top: 2px; }
1611.fnl-step-drop.high { background: rgba(239,68,68,0.14); color: #ff8a8a; }
1612.fnl-step-drop.mid { background: rgba(234,179,8,0.12); color: #fbbf24; }
1613.fnl-step-drop.low { background: rgba(34,197,94,0.10); color: #4ade80; }
1614.fnl-step-exits-head { font-size: 11px; color: #6f7787; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; margin: 14px 0 6px; }
1615.fnl-exits { list-style: none; padding: 0; margin: 0; }
1616.fnl-exits li { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; border-radius: 8px; background: rgba(255,255,255,0.02); margin-bottom: 4px; font-size: 13px; }
1617.fnl-exits li .pth { color: #cfd6e0; font-family: 'SF Mono', Menlo, monospace; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 70%; }
1618.fnl-exits li .cnt { color: #8893a4; font-weight: 600; font-size: 12px; }
1619.fnl-exits-empty { font-size: 12px; color: #6f7787; padding: 10px 12px; background: rgba(255,255,255,0.02); border-radius: 8px; }
1620
1621/* Builder */
1622.fnl-edit-form { display: block; }
1623.fnl-edit-head { display: flex; gap: 14px; flex-wrap: wrap; margin-bottom: 24px; }
1624.fnl-edit-field { display: flex; flex-direction: column; gap: 5px; min-width: 200px; }
1625.fnl-edit-field.grow { flex: 1; min-width: 260px; }
1626.fnl-edit-field > span { font-size: 11px; color: #8893a4; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
1627.fnl-edit-field input, .fnl-edit-field select {
1628 background: #161c25; border: 1px solid rgba(255,255,255,0.08);
1629 color: #e8edf2; padding: 11px 14px; border-radius: 10px; font-size: 14px;
1630 font-family: inherit;
1631}
1632.fnl-edit-field input:focus, .fnl-edit-field select:focus { outline: none; border-color: #f97316; box-shadow: 0 0 0 3px rgba(249,115,22,0.2); }
1633.fnl-edit-steps { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; }
1634.fnl-edit-step {
1635 display: flex; gap: 14px; align-items: flex-start;
1636 padding: 14px 16px;
1637 background: linear-gradient(160deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01));
1638 border: 1px solid rgba(255,255,255,0.06);
1639 border-left: 3px solid var(--step-color, #f97316);
1640 border-radius: 12px;
1641}
1642.fnl-edit-step-num {
1643 width: 32px; height: 32px; border-radius: 8px;
1644 background: var(--step-color, #f97316);
1645 color: #0a0f17; font-weight: 800; font-size: 15px;
1646 display: flex; align-items: center; justify-content: center;
1647 flex-shrink: 0; margin-top: 16px;
1648 box-shadow: 0 4px 12px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.3);
1649}
1650.fnl-edit-step-fields { flex: 1; display: flex; gap: 10px; flex-wrap: wrap; }
1651.fnl-edit-step-rm {
1652 background: rgba(239,68,68,0.10); border: 1px solid rgba(239,68,68,0.25);
1653 color: #ff8a8a; cursor: pointer; padding: 8px;
1654 border-radius: 8px; align-self: flex-start; margin-top: 16px;
1655 display: flex; align-items: center; justify-content: center;
1656}
1657.fnl-edit-step-rm:hover { background: rgba(239,68,68,0.18); }
1658.fnl-edit-add {
1659 display: inline-flex; align-items: center; gap: 8px;
1660 padding: 10px 18px; background: rgba(249,115,22,0.10);
1661 border: 1px dashed rgba(249,115,22,0.4); color: #ffae6e;
1662 border-radius: 10px; font-weight: 600; cursor: pointer; font-family: inherit; font-size: 14px;
1663}
1664.fnl-edit-add:hover { background: rgba(249,115,22,0.16); }
1665.fnl-edit-bottom { display: flex; align-items: center; gap: 12px; margin-top: 24px; padding-top: 22px; border-top: 1px solid rgba(255,255,255,0.06); }
1666.fnl-edit-del { color: #ff8a8a; text-decoration: none; font-size: 13px; }
1667.fnl-edit-del:hover { text-decoration: underline; }
1668.fnl-edit-cancel { margin-left: auto; color: #8893a4; text-decoration: none; padding: 11px 18px; }
1669.fnl-edit-save {
1670 display: inline-flex; align-items: center; gap: 10px;
1671 padding: 12px 22px;
1672 background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
1673 color: #fff; font-weight: 700; border: none; border-radius: 10px;
1674 cursor: pointer; font-family: inherit; font-size: 14px;
1675 box-shadow: 0 8px 22px rgba(249,115,22,0.40);
1676 transition: all 0.18s ease;
1677}
1678.fnl-edit-save:hover { transform: translateY(-1px); box-shadow: 0 12px 28px rgba(249,115,22,0.54); }
1679</style>
1680CSS
1681}
1682
1683#======================================================================
1684# funnel_action entry: save/delete POST handler.
1685# Bounces back to /funnels.cgi?id=N on success, /funnels.cgi on delete.
1686#======================================================================
1687sub _handle_action {
1688 my ($q) = @_;
1689
1690 my $fid = int($q->param('id') || 0);
1691 my $new = $q->param('new') ? 1 : 0;
1692 my $del = $q->param('delete') ? 1 : 0;
1693 my $hide = $q->param('hide') ? 1 : 0;
1694 my $unhide= $q->param('unhide') ? 1 : 0;
1695 my $site_id = int($q->param('site_id') || 0);
1696
1697 my $dbh = $db->db_connect();
1698 my @sites_list = $sites->list_for_user($dbh, $DB, $userinfo->{user_id});
1699 my %owned = map { $_->{id} => 1 } @sites_list;
1700
1701 #----------------------------------------------------------------------
1702 # Hide / Unhide -- soft toggle, keeps the row so the discovery worker
1703 # doesn't re-create a hidden auto funnel on its next run.
1704 #----------------------------------------------------------------------
1705 if ($fid && ($hide || $unhide)) {
1706 my $f = $db->db_readwrite($dbh, qq~
1707 SELECT site_id FROM `${DB}`.funnels WHERE id='$fid' LIMIT 1
1708 ~, $ENV{SCRIPT_NAME}, __LINE__);
1709 if ($f && $f->{site_id} && $owned{ $f->{site_id} }) {
1710 my $v = $hide ? 1 : 0;
1711 $db->db_readwrite($dbh, qq~
1712 UPDATE `${DB}`.funnels SET is_hidden='$v' WHERE id='$fid'
1713 ~, $ENV{SCRIPT_NAME}, __LINE__);
1714 }
1715 $db->db_disconnect($dbh);
1716 my $back = "/funnels.cgi?site=" . ($f && $f->{site_id} ? int($f->{site_id}) : 0);
1717 $back .= "&show_hidden=1" if $unhide;
1718 print "Status: 302 Found\nLocation: $back\n\n";
1719 return;
1720 }
1721
1722 #----------------------------------------------------------------------
1723 # Delete
1724 #----------------------------------------------------------------------
1725 if ($fid && $del) {
1726 my $f = $db->db_readwrite($dbh, qq~
1727 SELECT site_id FROM `${DB}`.funnels WHERE id='$fid' LIMIT 1
1728 ~, $ENV{SCRIPT_NAME}, __LINE__);
1729 if ($f && $f->{site_id} && $owned{ $f->{site_id} }) {
1730 $db->db_readwrite($dbh, qq~
1731 UPDATE `${DB}`.funnels SET is_active=0 WHERE id='$fid'
1732 ~, $ENV{SCRIPT_NAME}, __LINE__);
1733 }
1734 $db->db_disconnect($dbh);
1735 print "Status: 302 Found\nLocation: /funnels.cgi\n\n";
1736 return;
1737 }
1738
1739 #----------------------------------------------------------------------
1740 # Save (new or update)
1741 #----------------------------------------------------------------------
1742 unless ($owned{$site_id}) {
1743 # Fall back to first owned site if the form sent a stale id.
1744 $site_id = $sites_list[0] ? $sites_list[0]->{id} : 0;
1745 }
1746
1747 my $name = $q->param('name') || 'Untitled funnel';
1748 $name = substr($name, 0, 160);
1749 $name = _act_sql_clean($name);
1750
1751 my @labels = $q->multi_param('label');
1752 my @kinds = $q->multi_param('kind');
1753 my @matches = $q->multi_param('match');
1754
1755 my @steps;
1756 for my $i (0 .. $#labels) {
1757 my $k = $kinds[$i] || 'pageview';
1758 my $m = $matches[$i] || '';
1759 my $l = $labels[$i] || ("Step " . ($i + 1));
1760 $k = ($k eq 'event') ? 'event' : 'pageview';
1761 next unless length $m; # skip blank rows
1762 $l = substr($l, 0, 80);
1763 $m = substr($m, 0, 200);
1764 push @steps, { kind => $k, match => $m, label => $l };
1765 last if scalar(@steps) >= 8; # cap
1766 }
1767
1768 # Need at least 2 valid steps.
1769 if (scalar(@steps) < 2) {
1770 $db->db_disconnect($dbh);
1771 my $back = $fid ? "/funnels.cgi?id=$fid&edit=1" : "/funnels.cgi?new=1";
1772 print "Status: 302 Found\nLocation: $back\n\n";
1773 return;
1774 }
1775
1776 my $json = _act_steps_to_json(\@steps);
1777 my $json_safe = _act_sql_clean($json);
1778
1779 if ($fid) {
1780 # Ownership check: confirm fid belongs to a site the user owns.
1781 my $f = $db->db_readwrite($dbh, qq~
1782 SELECT site_id FROM `${DB}`.funnels WHERE id='$fid' LIMIT 1
1783 ~, $ENV{SCRIPT_NAME}, __LINE__);
1784 unless ($f && $f->{site_id} && $owned{ $f->{site_id} }) {
1785 $db->db_disconnect($dbh);
1786 print "Status: 302 Found\nLocation: /funnels.cgi\n\n";
1787 return;
1788 }
1789 $db->db_readwrite($dbh, qq~
1790 UPDATE `${DB}`.funnels
1791 SET name='$name', steps='$json_safe', site_id='$site_id', is_active=1
1792 WHERE id='$fid'
1793 ~, $ENV{SCRIPT_NAME}, __LINE__);
1794 } else {
1795 $db->db_readwrite($dbh, qq~
1796 INSERT INTO `${DB}`.funnels (site_id, name, steps, is_active)
1797 VALUES ('$site_id', '$name', '$json_safe', 1)
1798 ~, $ENV{SCRIPT_NAME}, __LINE__);
1799 my $r = $db->db_readwrite($dbh, "SELECT LAST_INSERT_ID() AS id", $ENV{SCRIPT_NAME}, __LINE__);
1800 $fid = $r->{id} if $r && $r->{id};
1801 }
1802
1803 $db->db_disconnect($dbh);
1804
1805 my $back = $fid ? "/funnels.cgi?id=$fid" : "/funnels.cgi";
1806 print "Status: 302 Found\nLocation: $back\n\n";
1807 return;
1808}
1809
1810#----------------------------------------------------------------------
1811sub _act_sql_clean {
1812 my ($s) = @_;
1813 return '' unless defined $s;
1814 $s =~ s/[\\']/\\$&/g;
1815 return $s;
1816}
1817
1818sub _act_steps_to_json {
1819 my ($aref) = @_;
1820 my @parts;
1821 foreach my $s (@$aref) {
1822 my $k = $s->{kind}; $k =~ s/[\\"]/\\$&/g;
1823 my $m = $s->{match}; $m =~ s/\\/\\\\/g; $m =~ s/"/\\"/g;
1824 my $l = $s->{label}; $l =~ s/\\/\\\\/g; $l =~ s/"/\\"/g;
1825 push @parts, qq~{"kind":"$k","match":"$m","label":"$l"}~;
1826 }
1827 return '[' . join(',', @parts) . ']';
1828}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help