added on local at 2026-07-01 15:02:18
| 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 | #====================================================================== | |
| 13 | use strict; | |
| 14 | use warnings; | |
| 15 | ||
| 16 | use lib '/var/www/vhosts/3dshawn.com/crm.3dshawn.com'; | |
| 17 | use CGI; | |
| 18 | use MODS::DBConnect; | |
| 19 | use MODS::Login; | |
| 20 | use MODS::ContactForge::Config; | |
| 21 | use MODS::ContactForge::Wrapper; | |
| 22 | use MODS::ContactForge::Stats; | |
| 23 | ||
| 24 | my $q = CGI->new; | |
| 25 | my $form = $q->Vars; | |
| 26 | my $auth = MODS::Login->new; | |
| 27 | my $wrap = MODS::ContactForge::Wrapper->new; | |
| 28 | my $db = MODS::DBConnect->new; | |
| 29 | my $cfg = MODS::ContactForge::Config->new; | |
| 30 | my $stats = MODS::ContactForge::Stats->new; | |
| 31 | my $DB = $cfg->settings('database_name'); | |
| 32 | ||
| 33 | $|=1; | |
| 34 | my $userinfo = $auth->login_verify(); | |
| 35 | unless ($userinfo) { | |
| 36 | print "Status: 302 Found\nLocation: /login.cgi?return=/admin_heatmap.cgi\n\n"; | |
| 37 | exit; | |
| 38 | } | |
| 39 | unless ($userinfo->{is_admin}) { | |
| 40 | print "Status: 403 Forbidden\nContent-Type: text/plain\n\nAdmin access required.\n"; | |
| 41 | exit; | |
| 42 | } | |
| 43 | ||
| 44 | sub _h { | |
| 45 | my $s = shift; $s = '' unless defined $s; | |
| 46 | $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; | |
| 47 | $s =~ s/"/"/g; $s =~ s/'/'/g; | |
| 48 | return $s; | |
| 49 | } | |
| 50 | sub _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 | } | |
| 55 | sub _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 | ||
| 62 | my $dbh = $db->db_connect(); | |
| 63 | ||
| 64 | my $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 | ||
| 74 | my $page_path = defined $form->{page} ? $form->{page} : ''; | |
| 75 | $page_path =~ s/[\r\n]//g; | |
| 76 | $page_path = substr($page_path, 0, 500); | |
| 77 | my $is_detail = (length $page_path) ? 1 : 0; | |
| 78 | ||
| 79 | my (@top, @dots); | |
| 80 | if ($schema_ready && !$is_detail) { | |
| 81 | @top = $stats->heatmap_top_pages($db, $dbh, $DB, 30, 50); | |
| 82 | } | |
| 83 | my ($summary_clicks, $summary_sessions, $summary_pageviews) = (0, 0, 0); | |
| 84 | my ($avg_scroll, $max_scroll) = (0, 0); | |
| 85 | if ($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 | |
| 120 | my $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 · Heatmap</span>}; | |
| 138 | if ($is_detail) { | |
| 139 | $body .= '<h1 class="page-title">Click heatmap · ' . _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 | ||
| 147 | if (!$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 | } | |
| 150 | elsif ($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">← 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 | } | |
| 224 | else { | |
| 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 →</a></td></tr>'; | |
| 238 | } | |
| 239 | $body .= q{</tbody></table></div>}; | |
| 240 | } | |
| 241 | } | |
| 242 | ||
| 243 | print "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 | }); |