loreal-sla-calculator/analytics.html
Vadym Samoilenko 96134f3c13 Fix MSAL CDN in analytics.html — use jsdelivr to match index.html
alcdn.msauth.net was failing to expose the msal global, causing
"msal is not defined" on auth init.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 16:17:13 +00:00

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://cdn.jsdelivr.net/npm/@azure/msal-browser@2.38.3/lib/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>