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

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

added on local at 2026-07-01 16:00:54

Added
+258
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 443214c3c1a4
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 - Admin Heatmap
4#
5# Admin-only. Two modes:
6# 1. List mode (default): top pages ranked by click activity. Each
7# row links into detail mode.
8# 2. Detail mode (?page=/some/path): renders the page in an iframe
9# with click positions overlaid as glowing dots, plus a scroll-
10# depth histogram showing how far visitors typically read.
11#
12# Data comes from tracking_events: 'click'/'heatmap' rows carry
13# x_pct/y_pct of the cursor at click time, 'scroll' rows carry the
14# max scroll_pct per visit.
15#======================================================================
16use strict;
17use warnings;
18
19use lib '/var/www/vhosts/abforge.com/httpdocs';
20use CGI;
21use MODS::Template;
22use MODS::DBConnect;
23use MODS::Login;
24use MODS::ABForge::Config;
25use MODS::ABForge::Wrapper;
26use MODS::ABForge::Admin;
27use MODS::ABForge::Tracking;
28
29my $q = CGI->new;
30my $form = $q->Vars;
31my $auth = MODS::Login->new;
32my $wrap = MODS::ABForge::Wrapper->new;
33my $tfile = MODS::Template->new;
34my $db = MODS::DBConnect->new;
35my $cfg = MODS::ABForge::Config->new;
36my $admin = MODS::ABForge::Admin->new;
37my $tr = MODS::ABForge::Tracking->new;
38my $DB = $cfg->settings('database_name');
39
40$|=1;
41my $userinfo = $auth->login_verify();
42if (!$userinfo) {
43 print "Status: 302 Found\nLocation: /login.cgi\n\n";
44 exit;
45}
46$admin->require_admin($userinfo);
47require MODS::ABForge::Permissions;
48MODS::ABForge::Permissions->new->require_feature($userinfo, 'admin_view_traffic');
49
50my $dbh = $db->db_connect();
51my $schema_ready = $tr->schema_ready($db, $dbh, $DB);
52
53sub _h { my $s = shift; $s = '' unless defined $s; $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g; $s =~ s/"/&quot;/g; return $s; }
54sub _u {
55 my $s = shift; $s = '' unless defined $s;
56 $s =~ s/([^A-Za-z0-9\-_.~\/?=&])/sprintf('%%%02X', ord($1))/eg;
57 return $s;
58}
59
60# ---- Destructive: PLATFORM-WIDE wipe of heatmap data ----------------
61# Triggered by the type-to-confirm modal in the template. Requires
62# POST + action=wipe_heatmap + confirm=WIPE (verbatim). Drops every
63# click / heatmap / scroll row across every storefront -- way more
64# severe than the seller's per-storefront wipe, which is why the
65# admin modal demands a different phrase (WIPE PLATFORM).
66if (($ENV{REQUEST_METHOD} || '') eq 'POST'
67 && ($form->{action} || '') eq 'wipe_heatmap'
68 && uc($form->{confirm} || '') eq 'WIPE PLATFORM'
69 && $schema_ready) {
70 my $c = $db->db_readwrite($dbh, qq~
71 SELECT COUNT(*) AS n FROM `${DB}`.tracking_events
72 WHERE event_type IN ('click','heatmap','scroll')
73 ~, $ENV{SCRIPT_NAME}, __LINE__);
74 my $deleted = ($c && $c->{n}) ? $c->{n} : 0;
75
76 $db->db_readwrite($dbh, qq~
77 DELETE FROM `${DB}`.tracking_events
78 WHERE event_type IN ('click','heatmap','scroll')
79 ~, $ENV{SCRIPT_NAME}, __LINE__);
80
81 $db->db_disconnect($dbh);
82 print "Status: 302 Found\nLocation: /admin_heatmap.cgi?wiped=$deleted\n\n";
83 exit;
84}
85
86# ---- Detail vs list mode --------------------------------------------
87my $page_path = defined $form->{page} ? $form->{page} : '';
88$page_path =~ s/[\r\n]//g;
89$page_path = substr($page_path, 0, 500);
90my $is_detail = (length $page_path) ? 1 : 0;
91
92my (@top_pages, $summary, @dots, @scroll_hist);
93my $scroll_max_count = 0;
94
95if ($schema_ready && !$is_detail) {
96 @top_pages = $tr->heatmap_top_pages($db, $dbh, $DB, 30, 50);
97}
98my $typical_vh = 900;
99if ($schema_ready && $is_detail) {
100 $summary = $tr->heatmap_page_summary($db, $dbh, $DB, $page_path, 30);
101 @dots = $tr->heatmap_dots($db, $dbh, $DB, $page_path, 800);
102 @scroll_hist = $tr->scroll_depth_distribution($db, $dbh, $DB, $page_path, 30);
103 foreach my $b (@scroll_hist) {
104 $scroll_max_count = $b->{count} if $b->{count} > $scroll_max_count;
105 }
106 # Median visitor viewport for this page (platform-wide) -- used by
107 # the scroll-depth pin overlay to position lines at "where they
108 # actually saw" rather than just where they scrolled.
109 $typical_vh = $tr->typical_viewport_height($db, $dbh, $DB, undef, $page_path);
110}
111
112$db->db_disconnect($dbh);
113
114# ---- Build template vars --------------------------------------------
115my @top_rows;
116foreach my $r (@top_pages) {
117 my $path = _h($r->{page_path} || '/');
118 push @top_rows, {
119 path => $path,
120 path_raw => $r->{page_path} || '/',
121 clicks => $r->{clicks} || 0,
122 sessions => $r->{sessions} || 0,
123 view_href => '/admin_heatmap.cgi?page=' . _u($r->{page_path} || '/'),
124 };
125}
126
127# Pack dots for the JS overlay. Each row carries:
128# x, y -- viewport-relative percent (last-resort fallback)
129# sel -- CSS path/selector for the clicked element
130# ox, oy -- element-relative offset 0..1 (defaults to 0.5/0.5 == element center)
131# The renderer prefers (sel + ox/oy) so the dot stays glued to the DOM
132# element through any reflow. When offsets are absent but a selector
133# exists, we still anchor to the element -- centered -- which is far
134# better than viewport-relative drift. Final fallback to (x, y) only
135# kicks in when the selector can't be resolved (page edited, etc.).
136my @dot_rows;
137my $rows_with_selector = 0;
138my $rows_with_offsets = 0;
139foreach my $d (@dots) {
140 next unless defined $d->{x_pct} && defined $d->{y_pct};
141 my $sel = defined $d->{element_selector} ? $d->{element_selector} : '';
142 my $has_off = (defined $d->{offset_x_pct} && defined $d->{offset_y_pct}) ? 1 : 0;
143 $rows_with_selector++ if length $sel;
144 $rows_with_offsets++ if length $sel && $has_off;
145 push @dot_rows, {
146 x_pct => sprintf('%.2f', $d->{x_pct}),
147 y_pct => sprintf('%.2f', $d->{y_pct}),
148 sel => $sel,
149 ox => $has_off ? sprintf('%.4f', $d->{offset_x_pct}) : '',
150 oy => $has_off ? sprintf('%.4f', $d->{offset_y_pct}) : '',
151 };
152}
153
154# Tiny JSON-string escaper for embedding the selector path. Selectors
155# contain '>' and ':' but no quotes -- still, belt-and-suspenders.
156sub _js_str {
157 my $s = shift; $s = '' unless defined $s;
158 $s =~ s/\\/\\\\/g;
159 $s =~ s/"/\\"/g;
160 $s =~ s/[\r\n\t]/ /g;
161 return $s;
162}
163
164my @hist_rows;
165foreach my $b (@scroll_hist) {
166 my $pct = $scroll_max_count ? sprintf('%.1f', ($b->{count} / $scroll_max_count) * 100) : '0.0';
167 push @hist_rows, {
168 range => $b->{range},
169 lo => $b->{lo},
170 hi => $b->{hi},
171 count => $b->{count},
172 pct => $pct,
173 };
174}
175
176# Encode dot data as compact JSON for the JS overlay. sel/ox/oy are
177# omitted when missing to keep the payload small for popular pages.
178my $dots_json = '[' . join(',', map {
179 my $r = $_;
180 my @kv = ('"x":' . $r->{x_pct}, '"y":' . $r->{y_pct});
181 if (length $r->{sel} && length $r->{ox} && length $r->{oy}) {
182 push @kv, '"s":"' . _js_str($r->{sel}) . '"';
183 push @kv, '"ox":' . $r->{ox};
184 push @kv, '"oy":' . $r->{oy};
185 }
186 '{' . join(',', @kv) . '}'
187} @dot_rows) . ']';
188
189# Heuristic: iframe gets the page path including any querystring,
190# parented to the host's root. We strip anything that doesn't look
191# like a safe URL fragment.
192my $iframe_src = $page_path;
193$iframe_src =~ s/['"<>]//g;
194$iframe_src = '/' . $iframe_src if $iframe_src !~ m{^/};
195
196# Append heatmap_view=1 so the wrapper renders the PUBLIC marketing
197# template instead of wrapping the page in the admin sidebar. Critical
198# for selector resolution -- the stored selectors are paths against
199# the public DOM; if the iframe rendered with the admin shell, every
200# `body > section:nth-of-type(1)` etc. would resolve to a different
201# element and the heatmap would be totally wrong.
202$iframe_src .= ($iframe_src =~ /\?/) ? '&heatmap_view=1' : '?heatmap_view=1';
203
204# Success banner after a successful wipe-redirect (?wiped=N).
205my $wiped_count = 0;
206if (defined $form->{wiped}) {
207 my $w = $form->{wiped}; $w =~ s/[^0-9]//g;
208 $wiped_count = $w if length $w;
209}
210
211my $tvars = {
212 user_id => $userinfo->{user_id},
213 schema_ready => $schema_ready ? 1 : 0,
214 schema_missing => $schema_ready ? 0 : 1,
215
216 just_wiped => $wiped_count > 0 ? 1 : 0,
217 wiped_count => $wiped_count,
218
219 typical_viewport_h => $typical_vh,
220
221 is_list_view => $is_detail ? 0 : 1,
222 is_detail_view => $is_detail ? 1 : 0,
223
224 top_pages => \@top_rows,
225 has_top_pages => scalar(@top_rows) ? 1 : 0,
226 top_page_count => scalar(@top_rows),
227
228 detail_path => _h($page_path),
229 detail_path_raw => $page_path,
230 iframe_src => _h($iframe_src),
231 dots_json => $dots_json,
232 dot_count => scalar(@dot_rows),
233 dots_with_selector => $rows_with_selector,
234 dots_with_offsets => $rows_with_offsets,
235 dots_legacy => scalar(@dot_rows) - $rows_with_selector,
236 anchor_pct => scalar(@dot_rows) ? int(($rows_with_offsets / scalar(@dot_rows)) * 100) : 0,
237 has_any_anchored => $rows_with_offsets ? 1 : 0,
238 all_legacy => (scalar(@dot_rows) && $rows_with_selector == 0) ? 1 : 0,
239 summary_clicks => ($summary && $summary->{clicks}) || 0,
240 summary_sessions => ($summary && $summary->{sessions}) || 0,
241 summary_page_views => ($summary && $summary->{page_views}) || 0,
242 summary_avg_scroll => ($summary && $summary->{avg_scroll}) || 0,
243 summary_max_scroll => ($summary && $summary->{max_scroll}) || 0,
244
245 scroll_hist => \@hist_rows,
246 has_scroll_hist => (scalar(@hist_rows) && $scroll_max_count > 0) ? 1 : 0,
247};
248
249print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n";
250
251my $body = join('', $tfile->template('abforge_admin_heatmap.html', $tvars, $userinfo));
252
253$wrap->render({
254 userinfo => $userinfo,
255 page_key => 'admin_visitors', # Heatmaps lives under Traffic Statistics sidebar entry
256 title => 'Admin &middot; Heatmaps',
257 body => $body,
258});
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help