-
-
Save taniorr/dae2180e051d6bcf472435004e5b66a7 to your computer and use it in GitHub Desktop.
Cookie Consent Dashboard
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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