Add usage analytics: event tracking, API, and dashboard
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>
This commit is contained in:
parent
a1abbbc2a9
commit
21ec93f966
7 changed files with 657 additions and 4 deletions
458
analytics.html
Normal file
458
analytics.html
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
<!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>
|
||||
30
auth.js
30
auth.js
|
|
@ -39,6 +39,12 @@ function showAuthView(id) {
|
|||
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function initAuth() {
|
||||
// LOCAL DEV: skip auth on localhost / 127.0.0.1
|
||||
if (['localhost', '127.0.0.1'].includes(location.hostname)) {
|
||||
onAuthSuccess({ email: 'dev@localhost', displayName: 'Local Dev' }, 'dev');
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Handle ?verify_token= in URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const verifyTok = urlParams.get('verify_token');
|
||||
|
|
@ -85,7 +91,7 @@ async function tryRefresh() {
|
|||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
currentAccessToken = data.accessToken;
|
||||
onAuthSuccess(data.user);
|
||||
onAuthSuccess(data.user, 'refresh');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
|
@ -129,7 +135,7 @@ async function exchangeMsalToken(idToken) {
|
|||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'SSO failed');
|
||||
currentAccessToken = data.accessToken;
|
||||
onAuthSuccess(data.user);
|
||||
onAuthSuccess(data.user, 'sso');
|
||||
} catch (err) {
|
||||
console.error('SSO exchange error:', err);
|
||||
showAuthView('choice');
|
||||
|
|
@ -159,7 +165,7 @@ async function authEmailLogin(event) {
|
|||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Login failed');
|
||||
currentAccessToken = data.accessToken;
|
||||
onAuthSuccess(data.user);
|
||||
onAuthSuccess(data.user, 'email');
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.classList.remove('hidden');
|
||||
|
|
@ -330,7 +336,7 @@ async function authResendVerification() {
|
|||
|
||||
// ── Success / sign-out ────────────────────────────────────────────────────────
|
||||
|
||||
function onAuthSuccess(user) {
|
||||
function onAuthSuccess(user, authMethod) {
|
||||
const overlay = document.getElementById('authOverlay');
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
|
||||
|
|
@ -342,6 +348,22 @@ function onAuthSuccess(user) {
|
|||
|
||||
const userInfo = document.getElementById('userInfo');
|
||||
if (userInfo) userInfo.classList.remove('hidden');
|
||||
|
||||
// Track login event (skip on localhost dev bypass)
|
||||
if (authMethod && authMethod !== 'dev') {
|
||||
const email = user.email || '';
|
||||
const domain = email.split('@')[1] || '';
|
||||
const isLoreal = /loreal|lorealusa/i.test(domain);
|
||||
fetch(`${API_BASE.replace('/auth', '')}/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
event: 'login',
|
||||
page: 'calculator',
|
||||
metadata: { authMethod, emailDomain: domain, isLoreal },
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
|
|
|
|||
|
|
@ -8,12 +8,45 @@
|
|||
let CONFIG = null;
|
||||
let lastResultData = null;
|
||||
|
||||
// ---- Usage Tracking ----
|
||||
const TRACK_API = (() => {
|
||||
// In production behind reverse proxy, use relative path
|
||||
if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') return null;
|
||||
const base = location.pathname.replace(/\/[^/]*$/, '');
|
||||
return `${base}/api/events`;
|
||||
})();
|
||||
|
||||
async function getVisitorId() {
|
||||
// Stable anonymous fingerprint — no PII, no cookies
|
||||
const raw = [
|
||||
navigator.language,
|
||||
screen.width + 'x' + screen.height,
|
||||
screen.colorDepth,
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
navigator.hardwareConcurrency || '',
|
||||
].join('|');
|
||||
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw));
|
||||
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function trackEvent(event, metadata = {}) {
|
||||
if (!TRACK_API) return; // skip on localhost
|
||||
getVisitorId().then(visitor_id => {
|
||||
fetch(TRACK_API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ event, page: 'market', visitor_id, metadata }),
|
||||
}).catch(() => {}); // fire-and-forget, never block UI
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Bootstrap ----
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await loadConfig();
|
||||
initDarkMode();
|
||||
initDatePicker();
|
||||
bindEvents();
|
||||
trackEvent('page_view');
|
||||
});
|
||||
|
||||
async function loadConfig() {
|
||||
|
|
@ -88,6 +121,13 @@ function validateForm() {
|
|||
function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
calculateAndRender();
|
||||
// Track after render so we can capture the resolved brief type
|
||||
if (lastResultData) {
|
||||
trackEvent('show_results', {
|
||||
briefType: lastResultData.briefType?.label || '',
|
||||
contentType: document.getElementById('contentType').value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
|
|
@ -476,6 +516,10 @@ function renderResults(data) {
|
|||
// ---- Copy for Email (Rich HTML) ----
|
||||
function copyForEmail() {
|
||||
if (!lastResultData) return;
|
||||
trackEvent('copy_email', {
|
||||
briefType: lastResultData.briefType?.label || '',
|
||||
contentType: document.getElementById('contentType').value,
|
||||
});
|
||||
const data = lastResultData;
|
||||
|
||||
const tbl = 'border-collapse:collapse;width:100%;font-family:Calibri,Arial,sans-serif;font-size:13px;';
|
||||
|
|
|
|||
14
server/db/migrations/004_create_usage_events.sql
Normal file
14
server/db/migrations/004_create_usage_events.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
CREATE TABLE IF NOT EXISTS usage_events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
event VARCHAR(50) NOT NULL, -- page_view, show_results, copy_email
|
||||
page VARCHAR(100) NOT NULL, -- market, calculator
|
||||
visitor_id VARCHAR(64), -- SHA-256 fingerprint (no PII)
|
||||
metadata JSONB DEFAULT '{}', -- brief_type, content_type, etc.
|
||||
ip INET,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_usage_events_event ON usage_events (event);
|
||||
CREATE INDEX idx_usage_events_created_at ON usage_events (created_at);
|
||||
CREATE INDEX idx_usage_events_visitor ON usage_events (visitor_id);
|
||||
77
server/db/queries/events.js
Normal file
77
server/db/queries/events.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
'use strict';
|
||||
const pool = require('../index');
|
||||
|
||||
async function insertEvent({ event, page, visitor_id, metadata, ip, user_agent }) {
|
||||
await pool.query(
|
||||
`INSERT INTO usage_events (event, page, visitor_id, metadata, ip, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[event, page, visitor_id || null, metadata || {}, ip || null, user_agent || null]
|
||||
);
|
||||
}
|
||||
|
||||
async function getStats({ page, days = 30 }) {
|
||||
const since = new Date();
|
||||
since.setDate(since.getDate() - days);
|
||||
|
||||
const pageFilter = page === 'all' ? '' : 'AND page = $2';
|
||||
const params = page === 'all' ? [since] : [since, page];
|
||||
const ph = page === 'all' ? '$1' : '$2'; // page placeholder
|
||||
const sinceIdx = '$1';
|
||||
|
||||
const [totals, uniques, byEvent, byDay, byAuth, byDomain] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT event, COUNT(*)::int AS count FROM usage_events
|
||||
WHERE created_at >= ${sinceIdx} ${pageFilter}
|
||||
GROUP BY event ORDER BY count DESC`,
|
||||
params
|
||||
),
|
||||
pool.query(
|
||||
`SELECT COUNT(DISTINCT visitor_id)::int AS unique_visitors FROM usage_events
|
||||
WHERE created_at >= ${sinceIdx} ${pageFilter} AND visitor_id IS NOT NULL`,
|
||||
params
|
||||
),
|
||||
pool.query(
|
||||
`SELECT event, metadata->>'briefType' AS brief_type, COUNT(*)::int AS count
|
||||
FROM usage_events
|
||||
WHERE created_at >= ${sinceIdx} ${pageFilter}
|
||||
AND event IN ('show_results', 'copy_email')
|
||||
GROUP BY event, metadata->>'briefType' ORDER BY count DESC`,
|
||||
params
|
||||
),
|
||||
pool.query(
|
||||
`SELECT created_at::date AS day, event, COUNT(*)::int AS count
|
||||
FROM usage_events
|
||||
WHERE created_at >= ${sinceIdx} ${pageFilter}
|
||||
GROUP BY day, event ORDER BY day`,
|
||||
params
|
||||
),
|
||||
pool.query(
|
||||
`SELECT metadata->>'authMethod' AS auth_method, COUNT(*)::int AS count
|
||||
FROM usage_events
|
||||
WHERE created_at >= ${sinceIdx} ${pageFilter} AND event = 'login'
|
||||
GROUP BY metadata->>'authMethod' ORDER BY count DESC`,
|
||||
params
|
||||
),
|
||||
pool.query(
|
||||
`SELECT metadata->>'emailDomain' AS domain,
|
||||
(metadata->>'isLoreal')::boolean AS is_loreal,
|
||||
COUNT(*)::int AS count
|
||||
FROM usage_events
|
||||
WHERE created_at >= ${sinceIdx} ${pageFilter} AND event = 'login'
|
||||
GROUP BY metadata->>'emailDomain', (metadata->>'isLoreal')::boolean
|
||||
ORDER BY count DESC LIMIT 20`,
|
||||
params
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
totals: totals.rows,
|
||||
uniqueVisitors: uniques.rows[0]?.unique_visitors || 0,
|
||||
byBriefType: byEvent.rows,
|
||||
byDay: byDay.rows,
|
||||
byAuthMethod: byAuth.rows,
|
||||
byDomain: byDomain.rows,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { insertEvent, getStats };
|
||||
|
|
@ -6,6 +6,7 @@ const cookieParser = require('cookie-parser');
|
|||
const path = require('path');
|
||||
|
||||
const authRoutes = require('./routes/auth');
|
||||
const eventRoutes = require('./routes/events');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
|
@ -19,6 +20,7 @@ app.set('trust proxy', 1);
|
|||
|
||||
// ── API routes ────────────────────────────────────────────────────────────────
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/events', eventRoutes);
|
||||
|
||||
// ── Health check ──────────────────────────────────────────────────────────────
|
||||
app.get('/api/health', (_req, res) => res.json({ status: 'ok' }));
|
||||
|
|
|
|||
36
server/routes/events.js
Normal file
36
server/routes/events.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
'use strict';
|
||||
const router = require('express').Router();
|
||||
const { insertEvent, getStats } = require('../db/queries/events');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
|
||||
// Public — called by the frontend tracker (no auth needed)
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { event, page, visitor_id, metadata } = req.body;
|
||||
if (!event || !page) return res.status(400).json({ error: 'event and page required' });
|
||||
|
||||
const ip = req.ip;
|
||||
const user_agent = req.headers['user-agent'] || null;
|
||||
|
||||
await insertEvent({ event, page, visitor_id, metadata, ip, user_agent });
|
||||
res.status(201).json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('Event insert error:', err);
|
||||
res.status(500).json({ error: 'Failed to log event' });
|
||||
}
|
||||
});
|
||||
|
||||
// Protected — admin only
|
||||
router.get('/stats', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const page = req.query.page || 'market';
|
||||
const days = parseInt(req.query.days, 10) || 30;
|
||||
const stats = await getStats({ page, days });
|
||||
res.json(stats);
|
||||
} catch (err) {
|
||||
console.error('Stats query error:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch stats' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Loading…
Add table
Reference in a new issue