chore: baseline of the live help4bis.com docroot as of 2026-10-23 (cockpit #168)

Excludes analytics/data, vendor, support, .env, ACME tokens.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 06:45:34 +10:00
co-authored by Claude Sonnet 5.5
parent 84c1a01338
commit 9e6a41fc5d
86 changed files with 20852 additions and 0 deletions
+473
View File
@@ -0,0 +1,473 @@
<?php
/* Version: 1.0.0 | Created: 2026-04-08 | Updated: 2026-04-08 */
/* HELP4BIS Analytics Dashboard — Self-hosted admin panel */
session_start();
// ── Authentication ──────────────────────────────────────────────────────────
$ADMIN_USER = 'admin';
$ADMIN_HASH = '$2y$10$PLACEHOLDER'; // Set on first run
$configFile = __DIR__ . '/data/.auth_config';
// First-run setup: create password hash
if (!file_exists($configFile)) {
if (isset($_POST['setup_password'])) {
$pass = $_POST['setup_password'];
if (strlen($pass) < 8) {
$setupError = 'Password must be at least 8 characters.';
} else {
$hash = password_hash($pass, PASSWORD_BCRYPT);
$dataDir = __DIR__ . '/data';
if (!is_dir($dataDir)) mkdir($dataDir, 0750, true);
file_put_contents($configFile, json_encode([
'user' => $ADMIN_USER,
'hash' => $hash,
'created' => date('Y-m-d H:i:s')
]));
chmod($configFile, 0600);
$_SESSION['analytics_auth'] = true;
$_SESSION['analytics_user'] = $ADMIN_USER;
header('Location: /analytics/');
exit;
}
}
// Show setup form
?><!DOCTYPE html>
<html lang="en-AU"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>HELP4BIS Analytics — Setup</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;display:flex;align-items:center;justify-content:center;min-height:100vh}
.setup{background:#1e293b;padding:2.5rem;border-radius:12px;width:100%;max-width:400px;box-shadow:0 25px 50px -12px rgba(0,0,0,.5)}
h1{font-size:1.25rem;margin-bottom:.5rem;color:#f8fafc}
p{font-size:.875rem;color:#94a3b8;margin-bottom:1.5rem}
label{display:block;font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:#64748b;margin-bottom:.5rem}
input{width:100%;padding:.75rem 1rem;border:1px solid #334155;border-radius:8px;background:#0f172a;color:#f8fafc;font-size:1rem;margin-bottom:1rem}
input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.2)}
button{width:100%;padding:.75rem;background:#3b82f6;color:#fff;border:none;border-radius:8px;font-size:1rem;font-weight:600;cursor:pointer;transition:background .2s}
button:hover{background:#2563eb}
.error{background:#7f1d1d;color:#fca5a5;padding:.75rem;border-radius:8px;margin-bottom:1rem;font-size:.875rem}
.brand{color:#3b82f6;font-weight:700}
</style></head><body>
<div class="setup">
<h1><span class="brand">HELP4BIS</span> Analytics</h1>
<p>First-time setup. Create your admin password.</p>
<?php if (isset($setupError)): ?><div class="error"><?= htmlspecialchars($setupError) ?></div><?php endif; ?>
<form method="post">
<label>Admin Username</label>
<input type="text" value="admin" disabled>
<label>Password</label>
<input type="password" name="setup_password" required minlength="8" autofocus placeholder="Minimum 8 characters">
<button type="submit">Create Account</button>
</form></div></body></html><?php
exit;
}
// Load auth config
$auth = json_decode(file_get_contents($configFile), true);
// Login
if (!isset($_SESSION['analytics_auth'])) {
if (isset($_POST['password'])) {
if (password_verify($_POST['password'], $auth['hash'])) {
$_SESSION['analytics_auth'] = true;
$_SESSION['analytics_user'] = $auth['user'];
header('Location: /analytics/');
exit;
}
$loginError = 'Incorrect password.';
}
?><!DOCTYPE html>
<html lang="en-AU"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>HELP4BIS Analytics — Login</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;display:flex;align-items:center;justify-content:center;min-height:100vh}
.login{background:#1e293b;padding:2.5rem;border-radius:12px;width:100%;max-width:400px;box-shadow:0 25px 50px -12px rgba(0,0,0,.5)}
h1{font-size:1.25rem;margin-bottom:1.5rem;color:#f8fafc}
label{display:block;font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:#64748b;margin-bottom:.5rem}
input{width:100%;padding:.75rem 1rem;border:1px solid #334155;border-radius:8px;background:#0f172a;color:#f8fafc;font-size:1rem;margin-bottom:1rem}
input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.2)}
button{width:100%;padding:.75rem;background:#3b82f6;color:#fff;border:none;border-radius:8px;font-size:1rem;font-weight:600;cursor:pointer;transition:background .2s}
button:hover{background:#2563eb}
.error{background:#7f1d1d;color:#fca5a5;padding:.75rem;border-radius:8px;margin-bottom:1rem;font-size:.875rem}
.brand{color:#3b82f6;font-weight:700}
</style></head><body>
<div class="login">
<h1><span class="brand">HELP4BIS</span> Analytics</h1>
<?php if (isset($loginError)): ?><div class="error"><?= htmlspecialchars($loginError) ?></div><?php endif; ?>
<form method="post">
<label>Password</label>
<input type="password" name="password" required autofocus placeholder="Enter admin password">
<button type="submit">Log In</button>
</form></div></body></html><?php
exit;
}
// Logout
if (isset($_GET['logout'])) {
session_destroy();
header('Location: /analytics/');
exit;
}
// ── Dashboard ───────────────────────────────────────────────────────────────
$dbPath = __DIR__ . '/data/analytics.db';
$hasData = file_exists($dbPath);
$db = null;
if ($hasData) {
$db = new SQLite3($dbPath);
$db->busyTimeout(5000);
}
// Date range
$range = $_GET['range'] ?? '7d';
$rangeMap = ['24h' => 1, '7d' => 7, '30d' => 30, '90d' => 90];
$days = $rangeMap[$range] ?? 7;
$since = date('Y-m-d', strtotime("-{$days} days"));
// Fetch metrics
$metrics = ['pageviews' => 0, 'unique_visitors' => 0, 'avg_duration' => 0, 'cta_clicks' => 0, 'form_submits' => 0, 'bounce_rate' => 0];
$topPages = [];
$topReferrers = [];
$topCTAs = [];
$dailyChart = [];
$scrollData = [];
if ($db) {
// Total pageviews
$r = $db->querySingle("SELECT COUNT(*) FROM events WHERE event='pageview' AND created_at >= '$since'");
$metrics['pageviews'] = (int)$r;
// Unique visitors
$r = $db->querySingle("SELECT COUNT(DISTINCT session_id) FROM events WHERE event='pageview' AND created_at >= '$since'");
$metrics['unique_visitors'] = (int)$r;
// Avg duration
$r = $db->querySingle("SELECT AVG(CAST(json_extract(extra,'$.seconds') AS REAL)) FROM events WHERE event='duration' AND created_at >= '$since'");
$metrics['avg_duration'] = round((float)$r, 1);
// CTA clicks
$r = $db->querySingle("SELECT COUNT(*) FROM events WHERE event='cta_click' AND created_at >= '$since'");
$metrics['cta_clicks'] = (int)$r;
// Form submits
$r = $db->querySingle("SELECT COUNT(*) FROM events WHERE event='form_submit' AND created_at >= '$since'");
$metrics['form_submits'] = (int)$r;
// Bounce rate (sessions with only 1 pageview)
$totalSessions = $db->querySingle("SELECT COUNT(DISTINCT session_id) FROM events WHERE event='pageview' AND created_at >= '$since'");
$singlePageSessions = $db->querySingle("SELECT COUNT(*) FROM (SELECT session_id, COUNT(*) as cnt FROM events WHERE event='pageview' AND created_at >= '$since' GROUP BY session_id HAVING cnt = 1)");
$metrics['bounce_rate'] = $totalSessions > 0 ? round(($singlePageSessions / $totalSessions) * 100, 1) : 0;
// Top pages
$result = $db->query("SELECT page, COUNT(*) as views, COUNT(DISTINCT session_id) as visitors
FROM events WHERE event='pageview' AND created_at >= '$since'
GROUP BY page ORDER BY views DESC LIMIT 15");
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$topPages[] = $row;
}
// Top referrers
$result = $db->query("SELECT referrer, COUNT(*) as hits
FROM events WHERE event='pageview' AND referrer != '' AND created_at >= '$since'
GROUP BY referrer ORDER BY hits DESC LIMIT 10");
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$topReferrers[] = $row;
}
// Top CTA clicks
$result = $db->query("SELECT json_extract(extra,'$.text') as label, json_extract(extra,'$.href') as href, COUNT(*) as clicks
FROM events WHERE event='cta_click' AND created_at >= '$since'
GROUP BY label, href ORDER BY clicks DESC LIMIT 10");
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$topCTAs[] = $row;
}
// Daily chart data
$result = $db->query("SELECT date(created_at) as day, COUNT(*) as views, COUNT(DISTINCT session_id) as visitors
FROM events WHERE event='pageview' AND created_at >= '$since'
GROUP BY day ORDER BY day ASC");
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$dailyChart[] = $row;
}
// Scroll depth
$result = $db->query("SELECT json_extract(extra,'$.depth') as depth, COUNT(*) as cnt
FROM events WHERE event='scroll' AND created_at >= '$since'
GROUP BY depth ORDER BY depth ASC");
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$scrollData[] = $row;
}
$db->close();
}
$chartLabels = json_encode(array_column($dailyChart, 'day'));
$chartViews = json_encode(array_map('intval', array_column($dailyChart, 'views')));
$chartVisitors = json_encode(array_map('intval', array_column($dailyChart, 'visitors')));
?>
<!DOCTYPE html>
<html lang="en-AU">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>HELP4BIS Analytics Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0f172a;--surface:#1e293b;--border:#334155;--text:#e2e8f0;--muted:#94a3b8;--accent:#3b82f6;--green:#22c55e;--red:#ef4444;--amber:#f59e0b}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);line-height:1.5}
a{color:var(--accent);text-decoration:none}
a:hover{text-decoration:underline}
/* Layout */
.header{background:var(--surface);border-bottom:1px solid var(--border);padding:1rem 2rem;display:flex;justify-content:space-between;align-items:center}
.header h1{font-size:1.125rem;font-weight:700}
.header h1 span{color:var(--accent)}
.header nav{display:flex;gap:1rem;align-items:center}
.header nav a{font-size:.875rem;color:var(--muted)}
.container{max-width:1280px;margin:0 auto;padding:1.5rem}
/* Range tabs */
.range-tabs{display:flex;gap:.5rem;margin-bottom:1.5rem}
.range-tabs a{padding:.5rem 1rem;border-radius:8px;font-size:.875rem;font-weight:500;color:var(--muted);background:var(--surface);border:1px solid var(--border);transition:all .2s}
.range-tabs a.active,.range-tabs a:hover{color:#fff;background:var(--accent);border-color:var(--accent)}
/* Metric cards */
.metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem;margin-bottom:1.5rem}
.metric{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:1.25rem}
.metric .label{font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin-bottom:.25rem}
.metric .value{font-size:1.75rem;font-weight:700;color:#f8fafc}
.metric .sub{font-size:.75rem;color:var(--muted);margin-top:.25rem}
/* Chart */
.chart-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:1.25rem;margin-bottom:1.5rem}
.chart-card h2{font-size:.875rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:1rem}
/* Tables */
.grid-2{display:grid;grid-template-columns:repeat(auto-fit,minmax(400px,1fr));gap:1.5rem;margin-bottom:1.5rem}
.table-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:1.25rem}
.table-card h2{font-size:.875rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:1rem}
table{width:100%;border-collapse:collapse}
th{text-align:left;font-size:.75rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;padding:.5rem .75rem;border-bottom:1px solid var(--border)}
td{padding:.625rem .75rem;font-size:.875rem;border-bottom:1px solid var(--border)}
tr:last-child td{border-bottom:none}
.num{text-align:right;font-variant-numeric:tabular-nums;font-weight:600}
/* Scroll bars */
.scroll-bars{display:flex;gap:1rem;align-items:flex-end;height:100px;margin-top:1rem}
.scroll-bar{flex:1;display:flex;flex-direction:column;align-items:center;gap:.25rem}
.scroll-bar .bar{width:100%;border-radius:4px 4px 0 0;background:var(--accent);min-height:4px;transition:height .3s}
.scroll-bar .lbl{font-size:.75rem;color:var(--muted)}
.scroll-bar .val{font-size:.75rem;font-weight:600;color:#f8fafc}
/* Empty state */
.empty{text-align:center;padding:4rem 2rem}
.empty h2{font-size:1.25rem;margin-bottom:.5rem;color:#f8fafc}
.empty p{color:var(--muted);max-width:500px;margin:0 auto}
.empty code{background:var(--surface);padding:.125rem .5rem;border-radius:4px;font-size:.875rem}
@media(max-width:768px){
.header{flex-direction:column;gap:.75rem;text-align:center}
.container{padding:1rem}
.grid-2{grid-template-columns:1fr}
.metrics{grid-template-columns:repeat(2,1fr)}
}
</style>
</head>
<body>
<div class="header">
<h1><span>HELP4BIS</span> Analytics</h1>
<nav>
<a href="/">View Site</a>
<a href="?logout">Log Out</a>
</nav>
</div>
<div class="container">
<!-- Range tabs -->
<div class="range-tabs">
<?php foreach (['24h'=>'24 Hours','7d'=>'7 Days','30d'=>'30 Days','90d'=>'90 Days'] as $k=>$v): ?>
<a href="?range=<?= $k ?>" class="<?= $range===$k?'active':'' ?>"><?= $v ?></a>
<?php endforeach; ?>
</div>
<?php if (!$hasData || $metrics['pageviews'] === 0): ?>
<!-- Empty state -->
<div class="empty">
<h2>No data yet</h2>
<p>Analytics tracking is active. Data will appear here once visitors start arriving.
The tracker is loaded on all pages via <code>&lt;script src="/analytics/tracker.js"&gt;</code></p>
</div>
<?php else: ?>
<!-- Metric cards -->
<div class="metrics">
<div class="metric">
<div class="label">Pageviews</div>
<div class="value"><?= number_format($metrics['pageviews']) ?></div>
<div class="sub">Last <?= $days ?> day<?= $days>1?'s':'' ?></div>
</div>
<div class="metric">
<div class="label">Unique Visitors</div>
<div class="value"><?= number_format($metrics['unique_visitors']) ?></div>
<div class="sub">By session hash</div>
</div>
<div class="metric">
<div class="label">Avg. Duration</div>
<div class="value"><?= $metrics['avg_duration'] ?>s</div>
<div class="sub">Time on page</div>
</div>
<div class="metric">
<div class="label">Bounce Rate</div>
<div class="value"><?= $metrics['bounce_rate'] ?>%</div>
<div class="sub">Single-page sessions</div>
</div>
<div class="metric">
<div class="label">CTA Clicks</div>
<div class="value"><?= number_format($metrics['cta_clicks']) ?></div>
<div class="sub">Buttons &amp; links</div>
</div>
<div class="metric">
<div class="label">Form Submissions</div>
<div class="value"><?= number_format($metrics['form_submits']) ?></div>
<div class="sub">Contact / brief forms</div>
</div>
</div>
<!-- Chart -->
<div class="chart-card">
<h2>Traffic Over Time</h2>
<canvas id="trafficChart" height="80"></canvas>
</div>
<!-- Tables row -->
<div class="grid-2">
<!-- Top pages -->
<div class="table-card">
<h2>Top Pages</h2>
<table>
<tr><th>Page</th><th class="num">Views</th><th class="num">Visitors</th></tr>
<?php foreach ($topPages as $p): ?>
<tr>
<td><?= htmlspecialchars($p['page'] === '/' ? 'Homepage' : ltrim($p['page'], '/')) ?></td>
<td class="num"><?= number_format($p['views']) ?></td>
<td class="num"><?= number_format($p['visitors']) ?></td>
</tr>
<?php endforeach; ?>
</table>
</div>
<!-- Top referrers -->
<div class="table-card">
<h2>Top Referrers</h2>
<?php if (empty($topReferrers)): ?>
<p style="color:var(--muted);font-size:.875rem;padding:.5rem 0">No external referrers yet.</p>
<?php else: ?>
<table>
<tr><th>Source</th><th class="num">Visits</th></tr>
<?php foreach ($topReferrers as $r): ?>
<tr>
<td><?= htmlspecialchars(parse_url($r['referrer'], PHP_URL_HOST) ?: $r['referrer']) ?></td>
<td class="num"><?= number_format($r['hits']) ?></td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
</div>
</div>
<div class="grid-2">
<!-- CTA clicks -->
<div class="table-card">
<h2>CTA Clicks</h2>
<?php if (empty($topCTAs)): ?>
<p style="color:var(--muted);font-size:.875rem;padding:.5rem 0">No CTA clicks recorded yet.</p>
<?php else: ?>
<table>
<tr><th>Button</th><th>Destination</th><th class="num">Clicks</th></tr>
<?php foreach ($topCTAs as $c): ?>
<tr>
<td><?= htmlspecialchars(substr($c['label'] ?? '—', 0, 40)) ?></td>
<td><?= htmlspecialchars(substr($c['href'] ?? '', 0, 40)) ?></td>
<td class="num"><?= number_format($c['clicks']) ?></td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
</div>
<!-- Scroll depth -->
<div class="table-card">
<h2>Scroll Depth</h2>
<?php if (empty($scrollData)): ?>
<p style="color:var(--muted);font-size:.875rem;padding:.5rem 0">No scroll data yet.</p>
<?php else: ?>
<?php
$maxScroll = max(array_column($scrollData, 'cnt'));
?>
<div class="scroll-bars">
<?php foreach ($scrollData as $s): ?>
<div class="scroll-bar">
<div class="val"><?= number_format($s['cnt']) ?></div>
<div class="bar" style="height:<?= $maxScroll > 0 ? round(($s['cnt']/$maxScroll)*80) : 4 ?>px"></div>
<div class="lbl"><?= htmlspecialchars($s['depth']) ?>%</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
<script>
var ctx = document.getElementById('trafficChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: <?= $chartLabels ?>,
datasets: [{
label: 'Pageviews',
data: <?= $chartViews ?>,
borderColor: '#3b82f6',
backgroundColor: 'rgba(59,130,246,.1)',
fill: true,
tension: .3,
pointRadius: 3,
pointHoverRadius: 5
}, {
label: 'Visitors',
data: <?= $chartVisitors ?>,
borderColor: '#22c55e',
backgroundColor: 'rgba(34,197,94,.1)',
fill: true,
tension: .3,
pointRadius: 3,
pointHoverRadius: 5
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { labels: { color: '#94a3b8', font: { size: 12 } } }
},
scales: {
x: { grid: { color: '#1e293b' }, ticks: { color: '#64748b', font: { size: 11 } } },
y: { beginAtZero: true, grid: { color: '#1e293b' }, ticks: { color: '#64748b', font: { size: 11 }, precision: 0 } }
}
}
});
</script>
<?php endif; ?>
</div>
</body>
</html>