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

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

added on local at 2026-07-01 15:02:18

Added
+250
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 17e65ab2c186
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# ContactForge -- Admin Heatmap
4#
5# Two modes:
6# 1. List mode (default): page picker -- top pages ranked by click
7# activity. Each row links to detail mode.
8# 2. Detail mode (?page=/some/path): canvas-rendered click density
9# drawn inline (no external libs).
10#
11# Single-tenant. Admin-only. Outputs inline HTML (no template file).
12#======================================================================
13use strict;
14use warnings;
15
16use lib '/var/www/vhosts/3dshawn.com/crm.3dshawn.com';
17use CGI;
18use MODS::DBConnect;
19use MODS::Login;
20use MODS::ContactForge::Config;
21use MODS::ContactForge::Wrapper;
22use MODS::ContactForge::Stats;
23
24my $q = CGI->new;
25my $form = $q->Vars;
26my $auth = MODS::Login->new;
27my $wrap = MODS::ContactForge::Wrapper->new;
28my $db = MODS::DBConnect->new;
29my $cfg = MODS::ContactForge::Config->new;
30my $stats = MODS::ContactForge::Stats->new;
31my $DB = $cfg->settings('database_name');
32
33$|=1;
34my $userinfo = $auth->login_verify();
35unless ($userinfo) {
36 print "Status: 302 Found\nLocation: /login.cgi?return=/admin_heatmap.cgi\n\n";
37 exit;
38}
39unless ($userinfo->{is_admin}) {
40 print "Status: 403 Forbidden\nContent-Type: text/plain\n\nAdmin access required.\n";
41 exit;
42}
43
44sub _h {
45 my $s = shift; $s = '' unless defined $s;
46 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g;
47 $s =~ s/"/&quot;/g; $s =~ s/'/&#39;/g;
48 return $s;
49}
50sub _u {
51 my $s = shift; $s = '' unless defined $s;
52 $s =~ s/([^A-Za-z0-9\-_.~\/?=&])/sprintf('%%%02X', ord($1))/eg;
53 return $s;
54}
55sub _js_str {
56 my $s = shift; $s = '' unless defined $s;
57 $s =~ s/\\/\\\\/g; $s =~ s/"/\\"/g;
58 $s =~ s/[\r\n\t]/ /g;
59 return $s;
60}
61
62my $dbh = $db->db_connect();
63
64my $schema_ready = 0;
65{
66 my $r = $db->db_readwrite($dbh, qq~
67 SELECT COUNT(*) AS n FROM information_schema.tables
68 WHERE table_schema='$DB'
69 AND table_name IN ('heatmap_clicks','heatmap_scrolls','pageviews')
70 ~, $ENV{SCRIPT_NAME}, __LINE__);
71 $schema_ready = ($r && $r->{n} && $r->{n} >= 3) ? 1 : 0;
72}
73
74my $page_path = defined $form->{page} ? $form->{page} : '';
75$page_path =~ s/[\r\n]//g;
76$page_path = substr($page_path, 0, 500);
77my $is_detail = (length $page_path) ? 1 : 0;
78
79my (@top, @dots);
80if ($schema_ready && !$is_detail) {
81 @top = $stats->heatmap_top_pages($db, $dbh, $DB, 30, 50);
82}
83my ($summary_clicks, $summary_sessions, $summary_pageviews) = (0, 0, 0);
84my ($avg_scroll, $max_scroll) = (0, 0);
85if ($schema_ready && $is_detail) {
86 @dots = $stats->heatmap_dots($db, $dbh, $DB, $page_path, 800);
87 my $esc = $page_path; $esc =~ s/'/''/g;
88 my $r1 = $db->db_readwrite($dbh, qq~
89 SELECT COUNT(*) AS clicks, COUNT(DISTINCT session_id) AS sessions
90 FROM `${DB}`.heatmap_clicks
91 WHERE path='$esc'
92 AND occurred_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
93 ~, $ENV{SCRIPT_NAME}, __LINE__);
94 $summary_clicks = ($r1 && $r1->{clicks}) ? $r1->{clicks} : 0;
95 $summary_sessions = ($r1 && $r1->{sessions}) ? $r1->{sessions} : 0;
96 my $r2 = $db->db_readwrite($dbh, qq~
97 SELECT COUNT(*) AS n FROM `${DB}`.pageviews
98 WHERE path='$esc'
99 AND occurred_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
100 ~, $ENV{SCRIPT_NAME}, __LINE__);
101 $summary_pageviews = ($r2 && $r2->{n}) ? $r2->{n} : 0;
102 my $r3 = $db->db_readwrite($dbh, qq~
103 SELECT AVG(m) AS a, MAX(m) AS x FROM (
104 SELECT MAX(max_scroll_pct) AS m
105 FROM `${DB}`.heatmap_scrolls
106 WHERE path='$esc'
107 AND occurred_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
108 GROUP BY session_id
109 ) t
110 ~, $ENV{SCRIPT_NAME}, __LINE__);
111 if ($r3) {
112 $avg_scroll = $r3->{a} ? int($r3->{a} + 0.5) : 0;
113 $max_scroll = $r3->{x} ? int($r3->{x}) : 0;
114 }
115}
116
117$db->db_disconnect($dbh);
118
119# Build inline body
120my $body = '';
121$body .= q{<style>
122 .hm-grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap:12px; margin-bottom:22px; }
123 .hm-tile { background: var(--col-surface-1,#161a23); border:1px solid var(--col-border,#232936); border-radius:12px; padding:14px 16px; }
124 .hm-tile .lbl { font-size:10px; letter-spacing:1.5px; text-transform:uppercase; color:var(--col-text-3,#94a3b8); font-weight:700; }
125 .hm-tile .val { font-size:26px; font-weight:700; color:#fff; font-family:var(--font-mono,monospace); margin-top:4px; }
126 .hm-table { width:100%; border-collapse: separate; border-spacing:0; background: var(--col-surface-1,#161a23); border:1px solid var(--col-border,#232936); border-radius:12px; overflow:hidden; }
127 .hm-table thead th { background: var(--col-surface-2,#1c212c); padding:11px 14px; text-align:left; font-size:10px; letter-spacing:1.5px; text-transform:uppercase; color:var(--col-text-3,#94a3b8); font-weight:700; border-bottom:1px solid var(--col-border,#232936); }
128 .hm-table tbody td { padding:12px 14px; border-bottom:1px solid var(--col-border,#232936); font-size:13px; color: var(--col-text-2,#cbd5e1); vertical-align:middle; }
129 .hm-table tbody tr:last-child td { border-bottom: none; }
130 .hm-table .num { text-align:right; font-family:var(--font-mono,monospace); }
131 .hm-canvas-wrap { position:relative; background:#0b0e16; border:1px solid var(--col-border,#232936); border-radius:12px; padding:14px; }
132 .hm-canvas { display:block; width:100%; height:auto; background:#0b0e16; border-radius:8px; }
133</style>
134};
135
136$body .= q{<div class="page-head"><div>};
137$body .= q{<span class="page-eyebrow"><span class="dot"></span> Admin &middot; Heatmap</span>};
138if ($is_detail) {
139 $body .= '<h1 class="page-title">Click heatmap &middot; ' . _h($page_path) . '</h1>';
140 $body .= '<p class="page-subtitle">Aggregated click density across the last 30 days. Each click contributes a soft glow; overlapping clicks build up into hot spots.</p>';
141} else {
142 $body .= '<h1 class="page-title">Click heatmaps</h1>';
143 $body .= '<p class="page-subtitle">Pick a page to see how visitors are clicking on it. Ranked by total clicks in the last 30 days.</p>';
144}
145$body .= q{</div></div>};
146
147if (!$schema_ready) {
148 $body .= q{<div class="banner warn"><strong>Heatmap tables not installed yet.</strong> Run the schema migration to create heatmap_clicks / heatmap_scrolls.</div>};
149}
150elsif ($is_detail) {
151 # KPI tiles
152 $body .= q{<div class="hm-grid">};
153 $body .= '<div class="hm-tile"><div class="lbl">Clicks</div><div class="val">' . $summary_clicks . '</div></div>';
154 $body .= '<div class="hm-tile"><div class="lbl">Sessions</div><div class="val">' . $summary_sessions . '</div></div>';
155 $body .= '<div class="hm-tile"><div class="lbl">Pageviews</div><div class="val">' . $summary_pageviews . '</div></div>';
156 $body .= '<div class="hm-tile"><div class="lbl">Avg scroll</div><div class="val">' . $avg_scroll . '%</div></div>';
157 $body .= '<div class="hm-tile"><div class="lbl">Max scroll</div><div class="val">' . $max_scroll . '%</div></div>';
158 $body .= q{</div>};
159
160 # Back link
161 $body .= q{<div style="margin-bottom:14px"><a class="btn btn-secondary" href="/admin_heatmap.cgi">&larr; Back to page list</a></div>};
162
163 # Canvas
164 $body .= q{<div class="hm-canvas-wrap">};
165 $body .= q{<canvas id="hm-canvas" class="hm-canvas" width="1200" height="700"></canvas>};
166 $body .= q{</div>};
167
168 # Build JSON dot data: [{x,y},...] x/y are PERCENT (0-100).
169 my $dots_json = '[' . join(',', map {
170 my $r = $_;
171 my $x = sprintf('%.2f', $r->{x_pct} || 0) + 0;
172 my $y = sprintf('%.2f', $r->{y_pct} || 0) + 0;
173 '{"x":' . $x . ',"y":' . $y . '}'
174 } @dots) . ']';
175
176 $body .= '<script>(function(){' .
177 'var dots = ' . $dots_json . ';' .
178 'var c = document.getElementById("hm-canvas"); if (!c) return;' .
179 'var ctx = c.getContext("2d");' .
180 'var W = c.width, H = c.height;' .
181 'ctx.fillStyle = "#0b0e16"; ctx.fillRect(0,0,W,H);' .
182 'if (!dots.length) {' .
183 'ctx.fillStyle = "#475569"; ctx.font = "16px sans-serif"; ctx.textAlign = "center";' .
184 'ctx.fillText("No clicks recorded for this page yet.", W/2, H/2);' .
185 'return;' .
186 '}' .
187 '/* Off-screen accumulation buffer for soft glow blending */' .
188 'var off = document.createElement("canvas"); off.width = W; off.height = H;' .
189 'var octx = off.getContext("2d");' .
190 'var R = 28; /* radius of each glow */' .
191 'for (var i=0; i<dots.length; i++) {' .
192 'var px = (dots[i].x/100) * W;' .
193 'var py = (dots[i].y/100) * H;' .
194 'var g = octx.createRadialGradient(px,py,0, px,py,R);' .
195 'g.addColorStop(0, "rgba(255,255,255,0.22)");' .
196 'g.addColorStop(0.5,"rgba(255,255,255,0.10)");' .
197 'g.addColorStop(1, "rgba(255,255,255,0)");' .
198 'octx.fillStyle = g; octx.beginPath(); octx.arc(px,py,R,0,Math.PI*2); octx.fill();' .
199 '}' .
200 '/* Colourise via alpha lookup: low -> blue, high -> red */' .
201 'var img = octx.getImageData(0,0,W,H);' .
202 'var d = img.data;' .
203 'for (var p=0; p<d.length; p+=4) {' .
204 'var a = d[p+3];' .
205 'if (a < 4) { d[p]=d[p+1]=d[p+2]=d[p+3]=0; continue; }' .
206 'var t = Math.min(1, a/255 * 3);' .
207 'var r,g,b;' .
208 'if (t < 0.25) { r=0; g=Math.round(t*4*200); b=255; }' .
209 'else if (t < 0.5) { r=0; g=200 + Math.round((t-0.25)*4*55); b=Math.round((1 - (t-0.25)*4) * 255); }' .
210 'else if (t < 0.75) { r=Math.round((t-0.5)*4*255); g=255; b=0; }' .
211 'else { r=255; g=Math.round((1 - (t-0.75)*4) * 255); b=0; }' .
212 'd[p]=r; d[p+1]=g; d[p+2]=b; d[p+3]=Math.min(255, a*2);' .
213 '}' .
214 'ctx.putImageData(img,0,0);' .
215 '/* Optional: draw faint dots on top for individual clicks */' .
216 'ctx.fillStyle = "rgba(255,255,255,0.35)";' .
217 'for (var j=0; j<dots.length; j++) {' .
218 'ctx.beginPath();' .
219 'ctx.arc((dots[j].x/100)*W, (dots[j].y/100)*H, 1.5, 0, Math.PI*2);' .
220 'ctx.fill();' .
221 '}' .
222 '})();</script>';
223}
224else {
225 # List mode
226 if (!scalar @top) {
227 $body .= q{<div class="banner info">No clicks recorded yet. Once the t.js snippet starts firing, popular pages will show up here ranked by click count.</div>};
228 } else {
229 $body .= q{<div class="card"><table class="hm-table">};
230 $body .= q{<thead><tr><th>Page</th><th class="num">Clicks</th><th class="num">Sessions</th><th></th></tr></thead><tbody>};
231 foreach my $r (@top) {
232 my $path = $r->{page_path} || '/';
233 my $href = '/admin_heatmap.cgi?page=' . _u($path);
234 $body .= '<tr><td>' . _h($path) . '</td>' .
235 '<td class="num">' . ($r->{clicks} || 0) . '</td>' .
236 '<td class="num">' . ($r->{sessions} || 0) . '</td>' .
237 '<td><a class="btn btn-secondary btn-sm" href="' . $href . '">View heatmap &rarr;</a></td></tr>';
238 }
239 $body .= q{</tbody></table></div>};
240 }
241}
242
243print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n";
244
245$wrap->render({
246 userinfo => $userinfo,
247 page_key => 'admin_visitors',
248 title => 'Click Heatmap',
249 body => $body,
250});
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help