Files
taller-wox/app/templates/admin_leads.html

252 lines
8.1 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Admin · Leads — taller-wox{% endblock %}
{% block content %}
<section class="admin-section">
<div class="admin-header">
<div>
<p class="eyebrow-light">ADMIN · EN VIVO</p>
<h1>Leads registrados</h1>
<p class="admin-sub">
<span id="total-leads"></span> registros ·
<span id="total-downloads"></span> descargas totales
</p>
</div>
<div class="admin-actions">
<div class="live-status">
<span class="dot dot-live" id="live-dot"></span>
<span class="live-label" id="live-label">en vivo · 10s</span>
</div>
<button type="button" class="btn btn-secondary btn-icon" id="pause-btn" title="Pausar / reanudar auto-refresh"></button>
<button type="button" class="btn btn-secondary btn-icon" id="refresh-btn" title="Actualizar ahora"></button>
<a class="btn btn-secondary" href="/admin/leads.csv">Exportar CSV</a>
</div>
</div>
<div class="admin-filters">
<input type="text" id="filter" placeholder="Filtrar por nombre, email o empresa...">
<span class="last-updated" id="last-updated"></span>
</div>
<div class="admin-table-wrap">
<table class="admin-table" id="leads-table">
<thead>
<tr>
<th data-sort="id" class="sortable num">#<span class="sort-ind"></span></th>
<th data-sort="nombre" class="sortable">Nombre<span class="sort-ind"></span></th>
<th data-sort="email" class="sortable">Email<span class="sort-ind"></span></th>
<th data-sort="empresa" class="sortable">Empresa<span class="sort-ind"></span></th>
<th data-sort="times_registered" class="sortable num">Reg.<span class="sort-ind"></span></th>
<th data-sort="created_at" class="sortable">Creado<span class="sort-ind"></span></th>
<th data-sort="last_seen" class="sortable">Último<span class="sort-ind"></span></th>
<th class="hide-sm">IP</th>
<th class="hide-sm">Consent</th>
</tr>
</thead>
<tbody id="leads-tbody">
<tr><td colspan="9" class="empty">Cargando…</td></tr>
</tbody>
</table>
</div>
<div class="admin-stats-grid">
<div class="admin-stats">
<h2 class="admin-h2">Top empresas</h2>
<ul class="empresa-list" id="empresas-list">
<li class="empty">Cargando…</li>
</ul>
</div>
<div class="admin-stats">
<h2 class="admin-h2">Descargas por archivo</h2>
<ul class="empresa-list" id="downloads-list">
<li class="empty">Cargando…</li>
</ul>
</div>
</div>
</section>
<script>
const REFRESH_MS = 10000;
let paused = false;
let timer = null;
let lastFetch = null;
let sortKey = 'created_at';
let sortDir = -1; // -1 desc, 1 asc
let currentLeads = [];
let currentStats = null;
let filterText = '';
const $ = id => document.getElementById(id);
function fmtDate(s) {
if (!s) return '—';
return s.replace('T', ' ').slice(0, 19);
}
function escape(s) {
if (s == null) return '';
return String(s).replace(/[&<>"']/g, c => ({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
}[c]));
}
function renderLeads() {
const tbody = $('leads-tbody');
let rows = currentLeads.slice();
// sort
rows.sort((a, b) => {
const va = a[sortKey], vb = b[sortKey];
if (va == null) return 1;
if (vb == null) return -1;
if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * sortDir;
return String(va).localeCompare(String(vb)) * sortDir;
});
// filter
if (filterText) {
const q = filterText.toLowerCase();
rows = rows.filter(l =>
(l.nombre || '').toLowerCase().includes(q) ||
(l.email || '').toLowerCase().includes(q) ||
(l.empresa || '').toLowerCase().includes(q)
);
}
if (rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="empty">Sin resultados.</td></tr>';
return;
}
tbody.innerHTML = rows.map(l => `
<tr>
<td class="muted">${l.id}</td>
<td><strong>${escape(l.nombre)}</strong></td>
<td><a href="mailto:${escape(l.email)}">${escape(l.email)}</a></td>
<td>${escape(l.empresa)}</td>
<td class="num">${l.times_registered}</td>
<td class="muted">${fmtDate(l.created_at)}</td>
<td class="muted">${fmtDate(l.last_seen)}</td>
<td class="muted mono hide-sm">${escape(l.ip) || '—'}</td>
<td class="hide-sm">${l.consent ? '<span class="pill pill-ok">✓</span>' : '<span class="pill pill-no">×</span>'}</td>
</tr>
`).join('');
}
function renderStats() {
if (!currentStats) return;
$('total-leads').textContent = currentStats.total_leads;
$('total-downloads').textContent = currentStats.total_downloads;
const empList = $('empresas-list');
if (currentStats.top_5_empresas && currentStats.top_5_empresas.length) {
empList.innerHTML = currentStats.top_5_empresas.map(e =>
`<li><span class="empresa-name">${escape(e.empresa)}</span><span class="empresa-count">${e.count}</span></li>`
).join('');
} else {
empList.innerHTML = '<li class="empty">Sin datos</li>';
}
const dlList = $('downloads-list');
const entries = Object.entries(currentStats.downloads_por_archivo || {});
if (entries.length) {
dlList.innerHTML = entries.map(([k, v]) =>
`<li><span class="empresa-name mono">${escape(k)}</span><span class="empresa-count">${v}</span></li>`
).join('');
} else {
dlList.innerHTML = '<li class="empty">Sin descargas</li>';
}
}
function renderSortIndicators() {
document.querySelectorAll('th.sortable').forEach(th => {
const ind = th.querySelector('.sort-ind');
if (th.dataset.sort === sortKey) {
ind.textContent = sortDir === -1 ? ' ↓' : ' ↑';
th.classList.add('active');
} else {
ind.textContent = '';
th.classList.remove('active');
}
});
}
async function refresh() {
try {
const [leadsResp, statsResp] = await Promise.all([
fetch('/admin/leads.json?limit=10000', { credentials: 'include' }),
fetch('/admin/stats', { credentials: 'include' }),
]);
if (!leadsResp.ok || !statsResp.ok) throw new Error('HTTP ' + leadsResp.status);
const newLeads = await leadsResp.json();
currentStats = await statsResp.json();
// Detect new leads since last refresh
if (lastFetch !== null && newLeads.length > currentLeads.length) {
flashNewBadge(newLeads.length - currentLeads.length);
}
currentLeads = newLeads;
lastFetch = new Date();
renderLeads();
renderStats();
$('last-updated').textContent = 'Actualizado: ' + lastFetch.toLocaleTimeString('es', {hour12: false});
$('live-dot').classList.remove('dot-error');
$('live-dot').classList.add('dot-live');
} catch (err) {
$('live-dot').classList.remove('dot-live');
$('live-dot').classList.add('dot-error');
$('live-label').textContent = 'error de conexión';
console.error(err);
}
}
function flashNewBadge(count) {
const label = $('live-label');
const original = paused ? 'pausado' : 'en vivo · 10s';
label.textContent = `+${count} nuevo${count > 1 ? 's' : ''}`;
label.classList.add('flash');
setTimeout(() => {
label.textContent = original;
label.classList.remove('flash');
}, 4000);
}
function startTimer() {
stopTimer();
if (!paused) timer = setInterval(refresh, REFRESH_MS);
}
function stopTimer() {
if (timer) clearInterval(timer);
timer = null;
}
document.addEventListener('DOMContentLoaded', () => {
$('filter').addEventListener('input', e => {
filterText = e.target.value;
renderLeads();
});
document.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => {
const key = th.dataset.sort;
if (sortKey === key) sortDir = -sortDir;
else { sortKey = key; sortDir = -1; }
renderSortIndicators();
renderLeads();
});
});
$('pause-btn').addEventListener('click', () => {
paused = !paused;
$('pause-btn').textContent = paused ? '▶' : '⏸';
$('live-label').textContent = paused ? 'pausado' : 'en vivo · 10s';
if (paused) stopTimer(); else { refresh(); startTimer(); }
});
$('refresh-btn').addEventListener('click', refresh);
renderSortIndicators();
refresh();
startTimer();
});
</script>
{% endblock %}