Diff -- /var/www/vhosts/webstls.com/httpdocs/MODS/AnonPaths.pm
Diff

/var/www/vhosts/webstls.com/httpdocs/MODS/AnonPaths.pm

added on WebSTLs (webstls.com) at 2026-07-01 22:26:36

Added
+381
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 8fe34da15c3f
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1package MODS::AnonPaths;
2#======================================================================
3# Anonymous visitor path rendering -- shared across all 7 sites.
4#
5# Public entry: MODS::AnonPaths::render_for_admin($db, $dbh, $days,
6# $selected_path, $entered_signups)
7# Returns one HTML string containing:
8# 1. <style> block with all the path-panel CSS (idempotent — caller can
9# include it once per page, repeated includes are harmless)
10# 2. "Top Acquisition Paths" panel with up-to-10 most-common sequences
11# 3. (Optional) per-step funnel drill-in when $selected_path is set
12#
13# Assumes the dbh is already bound to the site's schema and that
14# anon_sessions + anon_pageviews tables exist (see _anon_path_tracking.sql).
15#======================================================================
16use strict;
17use warnings;
18use Time::Local ();
19
20my $ARROW = chr(0xE2) . chr(0x86) . chr(0x92); # → as UTF-8 bytes
21
22sub render_for_admin {
23 my ($db, $dbh, $days, $selected_path, $entered_signups) = @_;
24 $days = ($days && $days =~ /^\d+$/) ? $days + 0 : 30;
25 $days = 365 if $days > 365;
26 $days = 1 if $days < 1;
27 $selected_path = '' unless defined $selected_path;
28 $selected_path =~ s/[\x00-\x1f]//g;
29 $selected_path = substr($selected_path, 0, 1000);
30 $entered_signups = 0 unless $entered_signups;
31
32 my @top_paths = _query_top_paths($db, $dbh, $days);
33 my @drill_steps = ();
34 my ($sel_total, $sel_signups, $sel_abandons) = (0, 0, 0);
35 if (length $selected_path) {
36 (@drill_steps) = _build_drilldown($db, $dbh, $days, $selected_path);
37 if (@drill_steps) {
38 $sel_total = $drill_steps[0]->{reached};
39 $sel_signups += $_->{signup} for @drill_steps;
40 $sel_abandons += $_->{abandon} for @drill_steps;
41 }
42 }
43
44 my $css = _css();
45 my $panel = _render_panel(\@top_paths, $entered_signups, $selected_path, $days);
46 my $drill = (@drill_steps && length $selected_path)
47 ? _render_drilldown(\@drill_steps, $selected_path, $sel_total, $sel_signups, $sel_abandons)
48 : '';
49 return $css . $panel . $drill;
50}
51
52sub _query_top_paths {
53 my ($db, $dbh, $days) = @_;
54 my @rows;
55 local *OLDOUT;
56 open(OLDOUT, '>&', \*STDOUT) or do {};
57 open(STDOUT, '>', '/dev/null') or do {};
58 @rows = eval { $db->db_readwrite_multiple($dbh, qq~
59 SELECT path_sequence, COUNT(*) AS n,
60 SUM(CASE WHEN end_reason='signed_up' THEN 1 ELSE 0 END) AS signups,
61 SUM(CASE
62 WHEN end_reason IN ('abandoned','bounced') THEN 1
63 WHEN end_reason='active' AND last_seen_at < DATE_SUB(NOW(), INTERVAL 30 MINUTE) THEN 1
64 ELSE 0
65 END) AS abandons
66 FROM (
67 SELECT s.id, s.end_reason, s.last_seen_at,
68 GROUP_CONCAT(p.page_path ORDER BY p.sequence SEPARATOR ' $ARROW ') AS path_sequence
69 FROM anon_sessions s
70 JOIN anon_pageviews p ON p.session_id=s.id
71 WHERE s.first_seen_at >= DATE_SUB(NOW(), INTERVAL $days DAY)
72 GROUP BY s.id
73 ) AS sess
74 WHERE path_sequence IS NOT NULL AND path_sequence != ''
75 GROUP BY path_sequence
76 ORDER BY n DESC
77 LIMIT 10
78 ~, $ENV{SCRIPT_NAME} || 'anonpaths', __LINE__) };
79 @rows = () unless @rows;
80 open(STDOUT, '>&', \*OLDOUT) or do {};
81 return @rows;
82}
83
84sub _build_drilldown {
85 my ($db, $dbh, $days, $selected_path) = @_;
86 my @steps = split /\s+\Q$ARROW\E\s+/, $selected_path;
87 return () unless @steps;
88 my @all;
89 {
90 local *OLDOUT;
91 open(OLDOUT, '>&', \*STDOUT) or do {};
92 open(STDOUT, '>', '/dev/null') or do {};
93 @all = eval { $db->db_readwrite_multiple($dbh, qq~
94 SELECT s.id, s.end_reason, s.last_seen_at,
95 GROUP_CONCAT(p.page_path ORDER BY p.sequence SEPARATOR ' $ARROW ') AS path_sequence
96 FROM anon_sessions s
97 JOIN anon_pageviews p ON p.session_id=s.id
98 WHERE s.first_seen_at >= DATE_SUB(NOW(), INTERVAL $days DAY)
99 GROUP BY s.id
100 ~, $ENV{SCRIPT_NAME} || 'anonpaths', __LINE__) };
101 @all = () unless @all;
102 open(STDOUT, '>&', \*OLDOUT) or do {};
103 }
104 my @out;
105 for (my $i = 0; $i < @steps; $i++) {
106 my $prefix = join(" $ARROW ", @steps[0..$i]);
107 my ($reached, $abandon_here, $signup_here) = (0, 0, 0);
108 foreach my $s (@all) {
109 my $seq = $s->{path_sequence} // '';
110 next unless index($seq, $prefix) == 0;
111 $reached++;
112 my $is_full = ($seq eq $prefix);
113 my $is_abandon =
114 $s->{end_reason} eq 'abandoned' || $s->{end_reason} eq 'bounced' ||
115 ($s->{end_reason} eq 'active' && _stale($s->{last_seen_at}));
116 $abandon_here++ if $is_full && $is_abandon;
117 $signup_here++ if $is_full && $s->{end_reason} eq 'signed_up';
118 }
119 push @out, { idx => $i, step => $steps[$i], reached => $reached, abandon => $abandon_here, signup => $signup_here };
120 }
121 return @out;
122}
123
124sub _stale {
125 my ($ts) = @_;
126 return 1 unless $ts;
127 if ($ts =~ /^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})/) {
128 my $ep = eval { Time::Local::timelocal($6,$5,$4,$3,$2-1,$1-1900) } || 0;
129 return ($ep > 0 && (time - $ep) > 1800) ? 1 : 0;
130 }
131 return 0;
132}
133
134sub _h {
135 my $s = shift // '';
136 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g; $s =~ s/"/&quot;/g;
137 return $s;
138}
139
140sub _fmt_n {
141 my $n = int(shift // 0);
142 1 while $n =~ s/(\d)(\d{3})(?!\d)/$1,$2/;
143 return $n;
144}
145
146sub _render_panel {
147 my ($rows, $entered, $selected, $days) = @_;
148 if (!scalar @$rows) {
149 return qq~
150 <div class="anp-wrap">
151 <div class="anp-head">
152 <div class="anp-title">Top Acquisition Paths</div>
153 <div class="anp-sub">Page sequences anonymous visitors take before they sign up or abandon. Click a row to drill into its per-step funnel.</div>
154 </div>
155 <div class="anp-empty">No anonymous-visitor data yet for the last $days days. Once visitors land on a marketing page, their paths will appear here.</div>
156 </div>~;
157 }
158
159 my $max = 1;
160 foreach my $r (@$rows) { $max = $r->{n} if $r->{n} > $max; }
161 my $cells = '';
162 my $rank = 0;
163 foreach my $r (@$rows) {
164 $rank++;
165 my $path_disp = _h($r->{path_sequence});
166 my $cnt = _fmt_n($r->{n});
167 my $sn = _fmt_n($r->{signups} || 0);
168 my $an = _fmt_n($r->{abandons} || 0);
169 my $bar_pct = int(100.0 * $r->{n} / $max + 0.5);
170 my $is_sel = (defined($selected) && length($selected) && $selected eq $r->{path_sequence}) ? 'is-selected' : '';
171 my $share_pct = $entered ? sprintf('%.1f', 100.0 * $r->{n} / $entered) : '0.0';
172 my $enc = $r->{path_sequence}; $enc =~ s/([^A-Za-z0-9])/sprintf("%%%02X", ord($1))/eg;
173 $cells .= qq~
174 <a href="?days=$days&amp;path=$enc" class="anp-row $is_sel" style="--pct:${bar_pct}%">
175 <div class="anp-rank">$rank</div>
176 <div class="anp-name" title="$path_disp">$path_disp</div>
177 <div class="anp-stat ok"><span class="dot"></span>$sn</div>
178 <div class="anp-stat bad"><span class="dot"></span>$an</div>
179 <div class="anp-share">${share_pct}%</div>
180 <div class="anp-cnt">$cnt</div>
181 </a>~;
182 }
183 return qq~
184 <div class="anp-wrap">
185 <div class="anp-head">
186 <div>
187 <div class="anp-title">Top Acquisition Paths</div>
188 <div class="anp-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>
189 </div>
190 <div class="anp-legend">
191 <span><span class="dot ok"></span> signed up</span>
192 <span><span class="dot bad"></span> abandoned</span>
193 </div>
194 </div>
195 <div class="anp-list">$cells</div>
196 </div>~;
197}
198
199sub _render_drilldown {
200 my ($steps, $path_raw, $total, $signups, $abandons) = @_;
201 return '' unless ref($steps) eq 'ARRAY' && scalar @$steps;
202 return '' unless length($path_raw // '');
203
204 my $path_disp = _h($path_raw);
205 my $total_fmt = _fmt_n($total);
206 my $signup_fmt = _fmt_n($signups);
207 my $aban_fmt = _fmt_n($abandons);
208 my $continued = $total - $signups - $abandons; $continued = 0 if $continued < 0;
209 my $cont_fmt = _fmt_n($continued);
210 my $entered = $total || 1;
211
212 my $bars = '';
213 my $idx = 0;
214 foreach my $s (@$steps) {
215 $idx++;
216 my $step_path = _h($s->{step});
217 my $r = $s->{reached}; my $sn = $s->{signup}; my $an = $s->{abandon};
218 my $reached_pct = $entered ? sprintf('%.1f', 100 * $r / $entered) : '0.0';
219 my $bar_w = $entered ? int(100 * $r / $entered) : 0;
220 $bar_w = 4 if $bar_w < 4 && $r > 0;
221 my $sn_n = _fmt_n($sn); my $an_n = _fmt_n($an); my $reached_n = _fmt_n($r);
222 my $sn_block = $sn ? qq~<span class="anp-marker ok">&#10003; $sn_n</span>~ : '';
223 my $an_block = $an ? qq~<span class="anp-marker bad">&#10005; $an_n</span>~ : '';
224 $bars .= qq~
225 <div class="anp-step">
226 <div class="anp-step-head">
227 <div class="anp-step-num">$idx</div>
228 <div class="anp-step-name" title="$step_path">$step_path</div>
229 <div class="anp-step-meta">$reached_n &middot; ${reached_pct}%</div>
230 </div>
231 <div class="anp-step-bar"><div class="anp-step-fill" style="width:${bar_w}%"></div></div>
232 <div class="anp-step-markers">$sn_block $an_block</div>
233 </div>~;
234 }
235 return qq~
236 <div class="anp-drill">
237 <div class="anp-drill-head">
238 <div class="lbl">Path drill-in</div>
239 <div class="path-string">$path_disp</div>
240 <div class="anp-drill-stats">
241 <span>Sessions <strong>$total_fmt</strong></span>
242 <span class="ok">Signed up <strong>$signup_fmt</strong></span>
243 <span class="bad">Abandoned <strong>$aban_fmt</strong></span>
244 <span class="dim">Still active <strong>$cont_fmt</strong></span>
245 </div>
246 </div>
247 <div class="anp-drill-steps">$bars</div>
248 </div>~;
249}
250
251sub _css {
252 return <<'CSS';
253<style>
254.anp-wrap { position: relative; padding: 22px 24px 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; }
255.anp-head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; gap: 12px; flex-wrap: wrap; }
256.anp-title { font-size: 17px; font-weight: 700; color: #fff; }
257.anp-sub { font-size: 12px; color: #8893a4; margin-top: 4px; line-height: 1.55; max-width: 640px; }
258.anp-legend { display: flex; gap: 14px; font-size: 11px; color: #8893a4; }
259.anp-legend .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; vertical-align: 1px; margin-right: 4px; }
260.anp-legend .dot.ok { background: #22c55e; }
261.anp-legend .dot.bad { background: #ef4444; }
262.anp-list { display: flex; flex-direction: column; gap: 4px; }
263.anp-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; }
264.anp-row:hover { background: rgba(255,255,255,0.05); }
265.anp-row.is-selected { background: rgba(168,85,247,0.12); border: 1px solid rgba(168,85,247,0.3); }
266.anp-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; }
267.anp-row > * { position: relative; z-index: 1; }
268.anp-rank { color: #6f7787; font-weight: 700; font-size: 11px; text-align: center; }
269.anp-name { color: #cfd6e0; font-family: 'SF Mono', Menlo, monospace; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
270.anp-stat { font-size: 11px; font-weight: 600; text-align: right; display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px; }
271.anp-stat.ok { color: #4ade80; }
272.anp-stat.bad { color: #f87171; }
273.anp-stat .dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; }
274.anp-stat.ok .dot { background: #22c55e; }
275.anp-stat.bad .dot { background: #ef4444; }
276.anp-share { color: #8893a4; font-size: 11px; text-align: right; font-weight: 600; }
277.anp-cnt { color: #fff; font-weight: 700; font-size: 13px; text-align: right; }
278.anp-empty { text-align: center; padding: 30px 12px; color: #8893a4; font-size: 13px; }
279
280.anp-drill { position: relative; padding: 24px 26px; 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; }
281.anp-drill-head .lbl { font-size: 11px; color: #c4a3f0; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 6px; }
282.anp-drill-head .path-string { color: #fff; font-family: 'SF Mono', Menlo, monospace; font-size: 13px; word-break: break-all; margin-bottom: 14px; }
283.anp-drill-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: 18px; }
284.anp-drill-stats strong { color: #fff; margin-left: 6px; }
285.anp-drill-stats .ok strong { color: #4ade80; }
286.anp-drill-stats .bad strong { color: #f87171; }
287.anp-drill-stats .dim strong { color: #cbd5e1; }
288.anp-drill-steps { display: flex; flex-direction: column; gap: 14px; }
289.anp-step { display: grid; gap: 6px; }
290.anp-step-head { display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 10px; }
291.anp-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; }
292.anp-step-name { color: #cfd6e0; font-family: 'SF Mono', Menlo, monospace; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
293.anp-step-meta { color: #8893a4; font-size: 12px; font-weight: 600; }
294.anp-step-bar { height: 10px; background: rgba(255,255,255,0.04); border-radius: 5px; overflow: hidden; }
295.anp-step-fill { height: 100%; background: linear-gradient(90deg, #a855f7 0%, #22d3ee 100%); border-radius: 5px; }
296.anp-step-markers { display: flex; gap: 8px; margin-left: 38px; }
297.anp-marker { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 12px; font-size: 11px; font-weight: 700; }
298.anp-marker.ok { background: rgba(34,197,94,0.18); color: #4ade80; }
299.anp-marker.bad { background: rgba(239,68,68,0.18); color: #f87171; }
300</style>
301CSS
302}
303
304# ---------------------------------------------------------------------
305# Date-range helpers (ported from TF version of AnonPaths) used by
306# admin_earnings.cgi / admin_funnels.cgi to drive the from/to picker.
307# ---------------------------------------------------------------------
308
309sub render_range_controls {
310 my ($preset, $from, $to) = @_;
311 $preset //= '30'; $from //= ''; $to //= '';
312 my %sel;
313 foreach (qw(7 14 30 90 this_month last_month custom)) { $sel{$_} = ''; }
314 $sel{$preset} = ' selected' if exists $sel{$preset};
315 my $custom_disp = ($preset eq 'custom') ? '' : 'display:none';
316 return qq~
317 <label class="afnl-field">
318 <span class="afnl-field-lbl">Range</span>
319 <select name="preset" onchange="afnlPresetChange(this)">
320 <option value="7"$sel{7}>Last 7 days</option>
321 <option value="14"$sel{14}>Last 14 days</option>
322 <option value="30"$sel{30}>Last 30 days</option>
323 <option value="90"$sel{90}>Last 90 days</option>
324 <option value="this_month"$sel{this_month}>This month</option>
325 <option value="last_month"$sel{last_month}>Last month</option>
326 <option value="custom"$sel{custom}>Custom...</option>
327 </select>
328 </label>
329 <label class="afnl-field afnl-range-custom" style="$custom_disp">
330 <span class="afnl-field-lbl">From</span>
331 <input type="date" name="from" value="$from">
332 </label>
333 <label class="afnl-field afnl-range-custom" style="$custom_disp">
334 <span class="afnl-field-lbl">To</span>
335 <input type="date" name="to" value="$to">
336 </label>
337 <button type="submit" class="afnl-cta-pill afnl-range-custom" style="$custom_disp; padding: 8px 16px;">Apply</button>
338 <script>
339 function afnlPresetChange(sel) {
340 var custom = sel.value === 'custom';
341 var els = document.querySelectorAll('.afnl-range-custom');
342 els.forEach(function(e) { e.style.display = custom ? '' : ''; if (!custom) e.style.display = 'none'; });
343 if (!custom) sel.form.submit();
344 }
345 </script>~;
346}
347
348sub parse_range_params {
349 my ($params) = @_;
350 my $preset = $params->{preset} || '';
351 my $from = $params->{from} || '';
352 my $to = $params->{to} || '';
353 my $days = $params->{days} || '';
354 if ($preset eq 'this_month') {
355 my @t = localtime();
356 my $fy = $t[5] + 1900; my $fm = $t[4] + 1;
357 $from = sprintf('%04d-%02d-01', $fy, $fm);
358 $to = sprintf('%04d-%02d-%02d', $fy, $fm, $t[3]);
359 return ({ from => $from, to => $to }, 'this_month', $from, $to);
360 } elsif ($preset eq 'last_month') {
361 my @t = localtime();
362 my $y = $t[5] + 1900; my $m = $t[4] + 1;
363 $m--; if ($m == 0) { $m = 12; $y--; }
364 $from = sprintf('%04d-%02d-01', $y, $m);
365 my $nm = $m + 1; my $ny = $y; if ($nm > 12) { $nm = 1; $ny++; }
366 require Time::Local;
367 my $ts_first_next = eval { Time::Local::timelocal(0,0,0,1,$nm-1,$ny-1900) } || 0;
368 my @lt = localtime($ts_first_next - 86400);
369 $to = sprintf('%04d-%02d-%02d', $lt[5]+1900, $lt[4]+1, $lt[3]);
370 return ({ from => $from, to => $to }, 'last_month', $from, $to);
371 } elsif ($preset eq 'custom' && $from =~ /^\d{4}-\d{2}-\d{2}$/ && $to =~ /^\d{4}-\d{2}-\d{2}$/) {
372 return ({ from => $from, to => $to }, 'custom', $from, $to);
373 } elsif ($preset =~ /^\d+$/) {
374 return ({ days => $preset + 0 }, $preset, '', '');
375 }
376 my $d = ($days =~ /^\d+$/) ? $days + 0 : 30;
377 return ({ days => $d }, $d, '', '');
378}
379
380
3811;
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help