Skip to content

Instantly share code, notes, and snippets.

@taniorr
Last active October 6, 2026 07:54
Show Gist options
  • Select an option

  • Save taniorr/dae2180e051d6bcf472435004e5b66a7 to your computer and use it in GitHub Desktop.

Select an option

Save taniorr/dae2180e051d6bcf472435004e5b66a7 to your computer and use it in GitHub Desktop.
Cookie Consent Dashboard
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Consent Analytics Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
</head>
<body class="bg-slate-900 text-slate-100 min-h-screen font-sans antialiased">
<!-- LOGIN MODAL -->
<div id="loginView" class="flex min-h-screen items-center justify-center p-4">
<div class="bg-slate-800 border border-slate-700 w-full max-w-md p-8 rounded-2xl shadow-2xl">
<div class="text-center mb-6">
<div class="inline-flex p-3 bg-indigo-500/10 text-indigo-400 rounded-xl mb-3">
<i data-lucide="shield-check" class="w-8 h-8"></i>
</div>
<h1 class="text-2xl font-bold">Consent Analytics</h1>
<p class="text-slate-400 text-sm mt-1">Sign in to view live statistics</p>
</div>
<form id="loginForm" class="space-y-4">
<div>
<label class="block text-xs uppercase font-semibold text-slate-400 mb-1">Email</label>
<input type="email" id="email" required class="w-full bg-slate-900 border border-slate-700 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-indigo-500">
</div>
<div>
<label class="block text-xs uppercase font-semibold text-slate-400 mb-1">Password</label>
<input type="password" id="password" required class="w-full bg-slate-900 border border-slate-700 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-indigo-500">
</div>
<p id="loginError" class="text-rose-400 text-xs hidden"></p>
<button type="submit" class="w-full bg-indigo-600 hover:bg-indigo-500 py-2.5 rounded-lg font-semibold transition flex justify-center items-center gap-2">
<span>Sign In</span>
<i data-lucide="arrow-right" class="w-4 h-4"></i>
</button>
</form>
</div>
</div>
<!-- DASHBOARD VIEW -->
<div id="dashboardView" class="hidden max-w-7xl mx-auto px-4 py-8">
<!-- Header -->
<header class="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-8 border-b border-slate-800">
<div>
<h1 class="text-3xl font-extrabold tracking-tight">Consent Rate Insights</h1>
<p class="text-slate-400 text-sm mt-1">Real-time performance by shop & cookie configuration</p>
</div>
<div class="flex items-center gap-3">
<!-- FILTER BY COOKIE_NAME -->
<select id="cookieFilter" class="bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-sm text-slate-200 focus:outline-none focus:border-indigo-500">
<option value="ALL">All Shops (Cookies)</option>
</select>
<button id="logoutBtn" class="bg-slate-800 hover:bg-slate-700 text-slate-300 p-2 rounded-lg border border-slate-700">
<i data-lucide="log-out" class="w-5 h-5"></i>
</button>
</div>
</header>
<!-- KPI Metric Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 my-8">
<!-- Consent Rate -->
<div class="bg-gradient-to-br from-indigo-900/40 to-slate-800 border border-indigo-500/30 rounded-2xl p-6">
<span class="text-indigo-400 text-xs uppercase font-bold tracking-wider">Initial Consent Rate</span>
<div class="flex items-baseline gap-2 mt-2">
<span id="kpiRate" class="text-4xl font-black">0%</span>
</div>
<p class="text-slate-400 text-xs mt-2">Accepted / First-time visits</p>
</div>
<!-- Total Decisions -->
<div class="bg-slate-800 border border-slate-700 rounded-2xl p-6">
<span class="text-slate-400 text-xs uppercase font-bold tracking-wider">Total Decisions</span>
<div class="flex items-baseline gap-2 mt-2">
<span id="kpiTotal" class="text-4xl font-black">0</span>
</div>
<p class="text-slate-400 text-xs mt-2">All recorded interactions</p>
</div>
<!-- Total Accepted -->
<div class="bg-slate-800 border border-slate-700 rounded-2xl p-6">
<span class="text-emerald-400 text-xs uppercase font-bold tracking-wider">Total Accepted</span>
<div class="flex items-baseline gap-2 mt-2">
<span id="kpiAccept" class="text-4xl font-black text-emerald-400">0</span>
</div>
<p class="text-slate-400 text-xs mt-2">Full or partial consent</p>
</div>
<!-- Total Rejected -->
<div class="bg-slate-800 border border-slate-700 rounded-2xl p-6">
<span class="text-rose-400 text-xs uppercase font-bold tracking-wider">Total Rejected</span>
<div class="flex items-baseline gap-2 mt-2">
<span id="kpiDeny" class="text-4xl font-black text-rose-400">0</span>
</div>
<p class="text-slate-400 text-xs mt-2">Necessary only</p>
</div>
</div>
<!-- Charts Section -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<!-- Donut Chart -->
<div class="bg-slate-800 border border-slate-700 rounded-2xl p-6 flex flex-col items-center">
<h3 class="w-full text-left font-bold text-lg mb-4">Breakdown by Choice</h3>
<div class="w-full max-w-[260px] my-auto">
<canvas id="donutChart"></canvas>
</div>
</div>
<!-- Shop Bar Chart -->
<div class="bg-slate-800 border border-slate-700 rounded-2xl p-6 lg:col-span-2">
<h3 class="font-bold text-lg mb-4">Consent Rate by Shop (Cookie Name)</h3>
<div class="h-64">
<canvas id="barChart"></canvas>
</div>
</div>
</div>
<!-- Data Table -->
<div class="bg-slate-800 border border-slate-700 rounded-2xl overflow-hidden">
<div class="p-6 border-b border-slate-700 font-bold text-lg">Recent Interactions</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="bg-slate-900/50 text-slate-400 uppercase text-xs">
<tr>
<th class="px-6 py-3">Timestamp</th>
<th class="px-6 py-3">Shop (Cookie Name)</th>
<th class="px-6 py-3">URL Path</th>
<th class="px-6 py-3">Decision</th>
<th class="px-6 py-3">Type</th>
</tr>
</thead>
<tbody id="eventsTable" class="divide-y divide-slate-700 text-slate-300">
<!-- Populated via JS -->
</tbody>
</table>
</div>
</div>
</div>
<script>
lucide.createIcons();
const PB_URL = window.location.origin;
let token = localStorage.getItem('pb_auth_token');
let allRecords = [];
let donutChartInstance = null;
let barChartInstance = null;
if (token) showDashboard();
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
const errorEl = document.getElementById('loginError');
try {
const res = await fetch(`${PB_URL}/api/collections/users/auth-with-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity: email, password })
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || 'Login failed');
token = data.token;
localStorage.setItem('pb_auth_token', token);
showDashboard();
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('hidden');
}
});
document.getElementById('logoutBtn').addEventListener('click', () => {
localStorage.removeItem('pb_auth_token');
location.reload();
});
function showDashboard() {
document.getElementById('loginView').classList.add('hidden');
document.getElementById('dashboardView').classList.remove('hidden');
loadData();
}
async function loadData() {
try {
const res = await fetch(`${PB_URL}/api/collections/consent_events/records?perPage=500&sort=-created`, {
headers: { 'Authorization': token }
});
if (res.status === 401 || res.status === 403) {
localStorage.removeItem('pb_auth_token');
location.reload();
return;
}
const json = await res.json();
allRecords = json.items || [];
populateCookieFilter();
renderStats();
} catch (e) {
console.error('Failed to load records:', e);
}
}
function populateCookieFilter() {
const select = document.getElementById('cookieFilter');
const cookieNames = [...new Set(allRecords.map(r => r.cookie_name).filter(Boolean))];
select.innerHTML = '<option value="ALL">All Shops (Cookies)</option>';
cookieNames.forEach(name => {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
select.appendChild(opt);
});
select.onchange = renderStats;
}
function renderStats() {
const selectedCookie = document.getElementById('cookieFilter').value;
const records = selectedCookie === 'ALL'
? allRecords
: allRecords.filter(r => r.cookie_name === selectedCookie);
const initialRecords = records.filter(r => !r.is_update);
const acceptAllCount = initialRecords.filter(r => r.action === 'accept_all').length;
const acceptSelectedCount = initialRecords.filter(r => r.action === 'accept_selected').length;
const denyCount = initialRecords.filter(r => r.action === 'deny_all').length;
const totalConsented = acceptAllCount + acceptSelectedCount;
const rate = initialRecords.length > 0
? Math.round((totalConsented / initialRecords.length) * 100)
: 0;
document.getElementById('kpiRate').textContent = `${rate}%`;
document.getElementById('kpiTotal').textContent = records.length;
document.getElementById('kpiAccept').textContent = totalConsented;
document.getElementById('kpiDeny').textContent = denyCount;
renderDonutChart(acceptAllCount, acceptSelectedCount, denyCount);
renderBarChart();
renderTable(records);
}
function renderDonutChart(acceptAll, selected, deny) {
const ctx = document.getElementById('donutChart').getContext('2d');
if (donutChartInstance) donutChartInstance.destroy();
donutChartInstance = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Accept All', 'Selected', 'Denied'],
datasets: [{
data: [acceptAll, selected, deny],
backgroundColor: ['#10b981', '#6366f1', '#f43f5e'],
borderWidth: 0
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom', labels: { color: '#94a3b8' } }
}
}
});
}
function renderBarChart() {
const ctx = document.getElementById('barChart').getContext('2d');
if (barChartInstance) barChartInstance.destroy();
// Group and compare by cookie_name
const cookieNames = [...new Set(allRecords.map(r => r.cookie_name).filter(Boolean))];
const rates = cookieNames.map(name => {
const initials = allRecords.filter(r => r.cookie_name === name && !r.is_update);
const accepted = initials.filter(r => r.action === 'accept_all' || r.action === 'accept_selected').length;
return initials.length > 0 ? Math.round((accepted / initials.length) * 100) : 0;
});
barChartInstance = new Chart(ctx, {
type: 'bar',
data: {
labels: cookieNames,
datasets: [{
label: 'Consent Rate (%)',
data: rates,
backgroundColor: '#6366f1',
borderRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: { max: 100, beginAtZero: true, grid: { color: '#334155' }, ticks: { color: '#94a3b8' } },
x: { grid: { display: false }, ticks: { color: '#94a3b8' } }
},
plugins: { legend: { display: false } }
}
});
}
function renderTable(records) {
const tbody = document.getElementById('eventsTable');
tbody.innerHTML = records.slice(0, 15).map(r => {
let cleanPath = '-';
if (r.url) {
try {
const urlObj = new URL(r.url);
cleanPath = urlObj.pathname + urlObj.search;
} catch (e) {
cleanPath = r.url;
}
}
return `
<tr class="hover:bg-slate-700/30">
<td class="px-6 py-4">${new Date(r.created).toLocaleString()}</td>
<td class="px-6 py-4 font-mono font-medium text-xs text-indigo-300">${r.cookie_name || '-'}</td>
<td class="px-6 py-4 font-mono text-xs text-slate-400 max-w-xs truncate" title="${r.url || ''}">${cleanPath}</td>
<td class="px-6 py-4">
<span class="px-2.5 py-1 rounded-full text-xs font-semibold ${
r.action === 'accept_all' ? 'bg-emerald-500/10 text-emerald-400' :
r.action === 'deny_all' ? 'bg-rose-500/10 text-rose-400' :
'bg-indigo-500/10 text-indigo-400'
}">${r.action}</span>
</td>
<td class="px-6 py-4 text-xs text-slate-400">${r.is_update ? 'Updated' : 'Initial'}</td>
</tr>
`;
}).join('');
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment