Track page views, results clicks, copy-to-email on market page. Track login events with auth method (SSO/email) and L'Oréal domain detection. New analytics.html dashboard with charts (Chart.js), period/page filters. Localhost auth bypass for local dev testing. Post-deploy: run npm run migrate to create usage_events table. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
458 lines
23 KiB
HTML
458 lines
23 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en" class="scroll-smooth">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Analytics — SLA Calculator</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script>
|
|
tailwind.config = {
|
|
darkMode: 'class',
|
|
theme: {
|
|
extend: {
|
|
colors: {
|
|
brand: { 50:'#fef3f2',100:'#fee4e2',200:'#fecdd3',300:'#fda4af',400:'#fb7185',500:'#f43f5e',600:'#e11d48',700:'#be123c',800:'#9f1239',900:'#881337' }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
|
<script src="https://alcdn.msauth.net/browser/2.38.3/js/msal-browser.min.js"></script>
|
|
<style>
|
|
.card { @apply bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5 }
|
|
.stat-value { @apply text-3xl font-bold }
|
|
.stat-label { @apply text-xs text-gray-500 dark:text-gray-400 mt-1 uppercase tracking-wide }
|
|
.pill { @apply px-3 py-1.5 rounded-lg text-sm font-medium cursor-pointer transition-colors }
|
|
.pill-active { @apply bg-brand-600 text-white }
|
|
.pill-inactive { @apply bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 }
|
|
</style>
|
|
</head>
|
|
<body class="bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-100 min-h-screen transition-colors">
|
|
|
|
<!-- Auth overlay (same as main app) -->
|
|
<div id="authOverlay" class="fixed inset-0 z-[100] bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
|
<div class="w-full max-w-sm mx-auto px-6">
|
|
<div class="text-center mb-8">
|
|
<div class="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-brand-600 text-white font-bold text-lg mb-4">SLA</div>
|
|
<h1 class="text-xl font-bold">Analytics Dashboard</h1>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">Sign in to view usage data</p>
|
|
</div>
|
|
<div id="authView-loading" class="auth-view">
|
|
<p class="text-center text-gray-400 text-sm">Checking session…</p>
|
|
</div>
|
|
<div id="authView-choice" class="auth-view hidden">
|
|
<button onclick="authSsoLogin()" class="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors mb-3 font-medium text-sm">
|
|
<svg width="18" height="18" viewBox="0 0 21 21"><rect x="1" y="1" width="9" height="9" fill="#f25022"/><rect x="11" y="1" width="9" height="9" fill="#7fba00"/><rect x="1" y="11" width="9" height="9" fill="#00a4ef"/><rect x="11" y="11" width="9" height="9" fill="#ffb900"/></svg>
|
|
Sign in with Microsoft SSO
|
|
</button>
|
|
<button onclick="showAuthView('login')" class="w-full px-4 py-3 bg-brand-600 hover:bg-brand-700 text-white rounded-lg transition-colors font-medium text-sm">Sign in with email</button>
|
|
</div>
|
|
<div id="authView-login" class="auth-view hidden">
|
|
<div id="authLoginError" class="hidden mb-4 p-3 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm rounded-lg"></div>
|
|
<form id="loginForm" onsubmit="authEmailLogin(event)">
|
|
<div class="space-y-3">
|
|
<input id="loginEmail" type="email" autocomplete="email" required placeholder="you@loreal.com"
|
|
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm focus:ring-2 focus:ring-brand-500">
|
|
<input id="loginPassword" type="password" autocomplete="current-password" required placeholder="••••••••"
|
|
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm focus:ring-2 focus:ring-brand-500">
|
|
</div>
|
|
<button type="submit" id="loginSubmitBtn" class="w-full mt-4 px-4 py-3 bg-brand-600 hover:bg-brand-700 text-white rounded-lg transition-colors font-medium text-sm">Sign in</button>
|
|
<p class="text-center text-xs text-gray-400 mt-3">
|
|
<button type="button" onclick="showAuthView('choice')" class="hover:underline">← Back</button>
|
|
</p>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- App container (hidden until auth) -->
|
|
<div id="appContainer" style="display:none" class="max-w-6xl mx-auto px-4 py-6">
|
|
|
|
<!-- Header -->
|
|
<header class="flex items-center justify-between mb-6">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-10 h-10 rounded-lg bg-brand-600 text-white flex items-center justify-center font-bold text-sm">SLA</div>
|
|
<div>
|
|
<h1 class="text-xl font-bold">Analytics Dashboard</h1>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">SLA Calculator & Market Brief Advisor</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<span id="userDisplay" class="text-sm text-gray-500 dark:text-gray-400"></span>
|
|
<a href="index.html" class="text-xs text-brand-500 hover:underline">← Calculator</a>
|
|
<button id="darkToggle" class="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
|
|
<svg id="sunIcon" class="w-5 h-5 hidden" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
|
|
<svg id="moonIcon" class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Filters -->
|
|
<div class="flex flex-wrap items-center gap-3 mb-6">
|
|
<span class="text-sm font-medium text-gray-500 dark:text-gray-400">Period:</span>
|
|
<div id="periodPills" class="flex gap-2">
|
|
<button class="pill pill-inactive" data-days="7">7d</button>
|
|
<button class="pill pill-active" data-days="30">30d</button>
|
|
<button class="pill pill-inactive" data-days="90">90d</button>
|
|
</div>
|
|
<span class="text-sm font-medium text-gray-500 dark:text-gray-400 ml-4">Page:</span>
|
|
<div id="pagePills" class="flex gap-2">
|
|
<button class="pill pill-active" data-page="market">Market Advisor</button>
|
|
<button class="pill pill-inactive" data-page="calculator">Calculator</button>
|
|
<button class="pill pill-inactive" data-page="all">All</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading spinner -->
|
|
<div id="loading" class="text-center py-20">
|
|
<div class="inline-block w-8 h-8 border-4 border-brand-200 border-t-brand-600 rounded-full animate-spin"></div>
|
|
<p class="text-sm text-gray-400 mt-3">Loading analytics…</p>
|
|
</div>
|
|
|
|
<!-- Dashboard content (hidden until data loads) -->
|
|
<div id="dashboard" class="hidden space-y-6">
|
|
|
|
<!-- Summary cards -->
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div class="card">
|
|
<div id="statPageViews" class="stat-value text-blue-600 dark:text-blue-400">—</div>
|
|
<div class="stat-label">Page Views</div>
|
|
</div>
|
|
<div class="card">
|
|
<div id="statUniqueVisitors" class="stat-value text-purple-600 dark:text-purple-400">—</div>
|
|
<div class="stat-label">Unique Visitors</div>
|
|
</div>
|
|
<div class="card">
|
|
<div id="statResults" class="stat-value text-emerald-600 dark:text-emerald-400">—</div>
|
|
<div class="stat-label">Results Shown</div>
|
|
</div>
|
|
<div class="card">
|
|
<div id="statCopyEmail" class="stat-value text-amber-600 dark:text-amber-400">—</div>
|
|
<div class="stat-label">Copy to Email</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Daily activity -->
|
|
<div class="card">
|
|
<h3 class="text-sm font-semibold mb-3">Daily Activity</h3>
|
|
<canvas id="chartDaily" height="180"></canvas>
|
|
</div>
|
|
|
|
<!-- Brief types + Domains -->
|
|
<div class="grid md:grid-cols-2 gap-4">
|
|
<div class="card">
|
|
<h3 class="text-sm font-semibold mb-3">Brief Types — Results</h3>
|
|
<canvas id="chartBriefResults" height="200"></canvas>
|
|
</div>
|
|
<div class="card">
|
|
<h3 class="text-sm font-semibold mb-3">Email Domains</h3>
|
|
<div id="domainList" class="space-y-2 max-h-[220px] overflow-y-auto"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Logins — compact inline row -->
|
|
<div class="card">
|
|
<div class="flex flex-wrap items-center gap-x-8 gap-y-3">
|
|
<h3 class="text-sm font-semibold">Logins</h3>
|
|
<div class="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm">
|
|
<span><span id="statLogins" class="font-bold">—</span> total</span>
|
|
<span><span id="statSSO" class="font-bold text-blue-500">—</span> SSO</span>
|
|
<span><span id="statEmailLogin" class="font-bold text-amber-500">—</span> Email</span>
|
|
<span><span id="statLoreal" class="font-bold text-green-500">—</span> L'Oréal</span>
|
|
</div>
|
|
<canvas id="chartAuth" class="ml-auto" width="120" height="120" style="max-width:120px;max-height:120px;"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Empty state -->
|
|
<div id="emptyState" class="hidden text-center py-12">
|
|
<p class="text-gray-400">No data for this period yet. Events will appear here once users start interacting.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="auth.js"></script>
|
|
<script>
|
|
// ── Config ─────────────────────────────────────────────────────────────
|
|
const API_BASE_ANALYTICS = (() => {
|
|
if (['localhost','127.0.0.1'].includes(location.hostname)) return '/api';
|
|
return location.pathname.replace(/\/[^/]*$/, '') + '/api';
|
|
})();
|
|
|
|
let currentDays = 30;
|
|
let currentPage = 'market';
|
|
let chartDaily = null;
|
|
let chartAuth = null;
|
|
let chartBriefResults = null;
|
|
|
|
// ── Dark mode ──────────────────────────────────────────────────────────
|
|
(function initDarkMode() {
|
|
const saved = localStorage.getItem('sla-dark-mode');
|
|
if (saved === 'true' || (!saved && matchMedia('(prefers-color-scheme:dark)').matches)) {
|
|
document.documentElement.classList.add('dark');
|
|
}
|
|
updateDarkIcons();
|
|
document.getElementById('darkToggle').addEventListener('click', () => {
|
|
document.documentElement.classList.toggle('dark');
|
|
localStorage.setItem('sla-dark-mode', document.documentElement.classList.contains('dark'));
|
|
updateDarkIcons();
|
|
// Redraw charts with new colors
|
|
if (chartDaily) loadDashboard();
|
|
});
|
|
})();
|
|
|
|
function updateDarkIcons() {
|
|
const isDark = document.documentElement.classList.contains('dark');
|
|
document.getElementById('sunIcon').classList.toggle('hidden', !isDark);
|
|
document.getElementById('moonIcon').classList.toggle('hidden', isDark);
|
|
}
|
|
|
|
// ── Pill selectors ─────────────────────────────────────────────────────
|
|
document.getElementById('periodPills').addEventListener('click', e => {
|
|
const btn = e.target.closest('[data-days]');
|
|
if (!btn) return;
|
|
currentDays = parseInt(btn.dataset.days, 10);
|
|
document.querySelectorAll('#periodPills .pill').forEach(p => p.className = 'pill pill-inactive');
|
|
btn.className = 'pill pill-active';
|
|
loadDashboard();
|
|
});
|
|
|
|
document.getElementById('pagePills').addEventListener('click', e => {
|
|
const btn = e.target.closest('[data-page]');
|
|
if (!btn) return;
|
|
currentPage = btn.dataset.page;
|
|
document.querySelectorAll('#pagePills .pill').forEach(p => p.className = 'pill pill-inactive');
|
|
btn.className = 'pill pill-active';
|
|
loadDashboard();
|
|
});
|
|
|
|
// ── Demo data for localhost (no DB available) ──────────────────────────
|
|
function generateDemoData() {
|
|
const days = [];
|
|
const now = new Date();
|
|
for (let i = currentDays - 1; i >= 0; i--) {
|
|
const d = new Date(now); d.setDate(d.getDate() - i);
|
|
const ds = d.toISOString().split('T')[0];
|
|
const base = Math.floor(Math.random() * 8) + 2;
|
|
days.push({ day: ds, event: 'page_view', count: base });
|
|
if (Math.random() > 0.3) days.push({ day: ds, event: 'show_results', count: Math.floor(base * 0.6) });
|
|
if (Math.random() > 0.5) days.push({ day: ds, event: 'copy_email', count: Math.floor(base * 0.25) });
|
|
if (Math.random() > 0.4) days.push({ day: ds, event: 'login', count: Math.floor(base * 0.4) });
|
|
}
|
|
const totals = ['page_view','show_results','copy_email','login'].map(evt => ({
|
|
event: evt, count: days.filter(d => d.event === evt).reduce((s,r) => s + r.count, 0)
|
|
}));
|
|
return {
|
|
totals,
|
|
uniqueVisitors: Math.floor(totals[0].count * 0.65),
|
|
byDay: days,
|
|
byAuthMethod: [
|
|
{ auth_method: 'sso', count: 28 },
|
|
{ auth_method: 'email', count: 12 },
|
|
{ auth_method: 'refresh', count: 45 },
|
|
],
|
|
byBriefType: [
|
|
{ event: 'show_results', brief_type: 'Country Pull - Simple', count: 18 },
|
|
{ event: 'show_results', brief_type: 'Global Push - PDP', count: 14 },
|
|
{ event: 'show_results', brief_type: 'Country Retailer Request', count: 9 },
|
|
{ event: 'show_results', brief_type: 'Local Push - Eventing', count: 6 },
|
|
{ event: 'show_results', brief_type: 'Urgent Brief', count: 3 },
|
|
],
|
|
byDomain: [
|
|
{ domain: 'oliver.agency', is_loreal: false, count: 34 },
|
|
{ domain: 'loreal.com', is_loreal: true, count: 22 },
|
|
{ domain: 'brandtech.plus', is_loreal: false, count: 8 },
|
|
{ domain: 'insideideas.agency', is_loreal: false, count: 5 },
|
|
{ domain: 'lorealusa.com', is_loreal: true, count: 3 },
|
|
],
|
|
};
|
|
}
|
|
|
|
// ── Fetch & render ─────────────────────────────────────────────────────
|
|
async function loadDashboard() {
|
|
try {
|
|
const isLocal = ['localhost','127.0.0.1'].includes(location.hostname);
|
|
let data;
|
|
|
|
if (isLocal) {
|
|
data = generateDemoData();
|
|
} else {
|
|
const headers = {};
|
|
if (typeof currentAccessToken !== 'undefined' && currentAccessToken) {
|
|
headers['Authorization'] = `Bearer ${currentAccessToken}`;
|
|
}
|
|
const res = await fetch(`${API_BASE_ANALYTICS}/events/stats?page=${currentPage}&days=${currentDays}`, { headers });
|
|
if (!res.ok) throw new Error('Failed to fetch');
|
|
data = await res.json();
|
|
}
|
|
|
|
document.getElementById('loading').classList.add('hidden');
|
|
document.getElementById('dashboard').classList.remove('hidden');
|
|
|
|
const totalEvents = data.totals.reduce((s, r) => s + r.count, 0);
|
|
if (totalEvents === 0) {
|
|
document.getElementById('emptyState').classList.remove('hidden');
|
|
} else {
|
|
document.getElementById('emptyState').classList.add('hidden');
|
|
}
|
|
|
|
renderCards(data);
|
|
renderDailyChart(data.byDay);
|
|
renderAuthChart(data.byAuthMethod);
|
|
renderBriefChart(data.byBriefType);
|
|
renderDomainList(data.byDomain);
|
|
} catch (err) {
|
|
console.error('Dashboard load error:', err);
|
|
document.getElementById('loading').innerHTML = `<p class="text-red-400 text-sm">Failed to load analytics. Make sure you're signed in.</p>`;
|
|
}
|
|
}
|
|
|
|
function getCount(totals, event) {
|
|
const row = totals.find(r => r.event === event);
|
|
return row ? row.count : 0;
|
|
}
|
|
|
|
function renderCards(data) {
|
|
document.getElementById('statPageViews').textContent = getCount(data.totals, 'page_view').toLocaleString();
|
|
document.getElementById('statUniqueVisitors').textContent = data.uniqueVisitors.toLocaleString();
|
|
document.getElementById('statResults').textContent = getCount(data.totals, 'show_results').toLocaleString();
|
|
document.getElementById('statCopyEmail').textContent = getCount(data.totals, 'copy_email').toLocaleString();
|
|
|
|
const logins = getCount(data.totals, 'login');
|
|
document.getElementById('statLogins').textContent = logins.toLocaleString();
|
|
|
|
const ssoCount = data.byAuthMethod.find(r => r.auth_method === 'sso')?.count || 0;
|
|
const emailCount = data.byAuthMethod.find(r => r.auth_method === 'email')?.count || 0;
|
|
const lorealCount = data.byDomain.filter(r => r.is_loreal).reduce((s, r) => s + r.count, 0);
|
|
|
|
document.getElementById('statSSO').textContent = ssoCount.toLocaleString();
|
|
document.getElementById('statEmailLogin').textContent = emailCount.toLocaleString();
|
|
document.getElementById('statLoreal').textContent = lorealCount.toLocaleString();
|
|
}
|
|
|
|
// ── Daily activity chart ───────────────────────────────────────────────
|
|
function renderDailyChart(byDay) {
|
|
const isDark = document.documentElement.classList.contains('dark');
|
|
const days = [...new Set(byDay.map(r => r.day))].sort();
|
|
const events = ['page_view', 'show_results', 'copy_email', 'login'];
|
|
const colors = { page_view: '#3b82f6', show_results: '#10b981', copy_email: '#f59e0b', login: '#8b5cf6' };
|
|
const labels = { page_view: 'Page Views', show_results: 'Results', copy_email: 'Copy Email', login: 'Logins' };
|
|
|
|
const datasets = events.map(evt => ({
|
|
label: labels[evt],
|
|
data: days.map(d => {
|
|
const row = byDay.find(r => r.day === d && r.event === evt);
|
|
return row ? row.count : 0;
|
|
}),
|
|
backgroundColor: colors[evt],
|
|
borderRadius: 4,
|
|
}));
|
|
|
|
if (chartDaily) chartDaily.destroy();
|
|
chartDaily = new Chart(document.getElementById('chartDaily'), {
|
|
type: 'bar',
|
|
data: {
|
|
labels: days.map(d => new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })),
|
|
datasets,
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, padding: 15, color: isDark ? '#9ca3af' : '#6b7280' } } },
|
|
scales: {
|
|
x: { stacked: true, grid: { display: false }, ticks: { color: isDark ? '#9ca3af' : '#6b7280' } },
|
|
y: { stacked: true, beginAtZero: true, grid: { color: isDark ? '#374151' : '#e5e7eb' }, ticks: { color: isDark ? '#9ca3af' : '#6b7280', stepSize: 1 } },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
// ── Auth method mini bar ────────────────────────────────────────────────
|
|
function renderAuthChart(byAuthMethod) {
|
|
const isDark = document.documentElement.classList.contains('dark');
|
|
const methodLabels = { sso: 'SSO', email: 'Email', refresh: 'Return' };
|
|
const methodColors = { sso: '#3b82f6', email: '#f59e0b', refresh: '#8b5cf6' };
|
|
|
|
const filtered = byAuthMethod.filter(r => r.auth_method && r.auth_method !== 'dev');
|
|
if (chartAuth) chartAuth.destroy();
|
|
chartAuth = new Chart(document.getElementById('chartAuth'), {
|
|
type: 'bar',
|
|
data: {
|
|
labels: filtered.map(r => methodLabels[r.auth_method] || r.auth_method),
|
|
datasets: [{
|
|
data: filtered.map(r => r.count),
|
|
backgroundColor: filtered.map(r => methodColors[r.auth_method] || '#6b7280'),
|
|
borderRadius: 3,
|
|
}],
|
|
},
|
|
options: {
|
|
responsive: false,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
x: { grid: { display: false }, ticks: { color: isDark ? '#9ca3af' : '#6b7280', font: { size: 10 } } },
|
|
y: { display: false, beginAtZero: true },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
// ── Brief type bar chart ───────────────────────────────────────────────
|
|
function renderBriefChart(byBriefType) {
|
|
const isDark = document.documentElement.classList.contains('dark');
|
|
const results = byBriefType.filter(r => r.event === 'show_results' && r.brief_type);
|
|
const labels = results.map(r => r.brief_type);
|
|
const counts = results.map(r => r.count);
|
|
|
|
if (chartBriefResults) chartBriefResults.destroy();
|
|
chartBriefResults = new Chart(document.getElementById('chartBriefResults'), {
|
|
type: 'bar',
|
|
data: {
|
|
labels,
|
|
datasets: [{
|
|
label: 'Results Shown',
|
|
data: counts,
|
|
backgroundColor: '#10b981',
|
|
borderRadius: 4,
|
|
}],
|
|
},
|
|
options: {
|
|
indexAxis: 'y',
|
|
responsive: true,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
x: { beginAtZero: true, grid: { color: isDark ? '#374151' : '#e5e7eb' }, ticks: { color: isDark ? '#9ca3af' : '#6b7280', stepSize: 1 } },
|
|
y: { grid: { display: false }, ticks: { color: isDark ? '#9ca3af' : '#6b7280' } },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
// ── Domain list ────────────────────────────────────────────────────────
|
|
function renderDomainList(byDomain) {
|
|
const el = document.getElementById('domainList');
|
|
if (!byDomain.length) {
|
|
el.innerHTML = '<p class="text-sm text-gray-400">No login data yet</p>';
|
|
return;
|
|
}
|
|
el.innerHTML = byDomain.map(r => `
|
|
<div class="flex items-center justify-between text-sm">
|
|
<div class="flex items-center gap-2">
|
|
<span class="w-2 h-2 rounded-full ${r.is_loreal ? 'bg-green-500' : 'bg-gray-400'}"></span>
|
|
<span class="font-mono text-xs">${r.domain || '(unknown)'}</span>
|
|
</div>
|
|
<span class="font-semibold">${r.count}</span>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
// ── Init ───────────────────────────────────────────────────────────────
|
|
const _origOnAuthSuccess = onAuthSuccess;
|
|
onAuthSuccess = function(user, method) {
|
|
_origOnAuthSuccess(user, method);
|
|
loadDashboard();
|
|
};
|
|
|
|
initAuth();
|
|
</script>
|
|
</body>
|
|
</html>
|