- Dark theme en index (class=page-dark): fondo #1a1714, títulos ácido, botones nav crema - Hero-landing con gradiente animado ácido→caoba aplicado a los 7 htmls - Slider 3 slides (crossfade CSS + JS): crowd-phones / surveillance / legal-action - Imágenes descargadas localmente (CSP img-src 'self') - Botón RESETEA hero: verde ácido, fuente Recion, tipografía grande centrada - Cards de estudios con enlaces a fuente original - CSP ampliada: img-src añade cdn.simpleicons.org para chips de redes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
372 lines
14 KiB
HTML
372 lines
14 KiB
HTML
<!doctype html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>RESETEA.NET · Estadísticas</title>
|
|
<meta name="description" content="Estadísticas anónimas de solicitudes GDPR enviadas a través de resetea.net. Sin datos personales.">
|
|
<link rel="stylesheet" href="index.css">
|
|
<style>
|
|
|
|
/* ── Cabecera ── */
|
|
.stats-hero {
|
|
background: var(--caoba);
|
|
color: #fff;
|
|
padding: 2.8rem 1.5rem 2.2rem;
|
|
text-align: center;
|
|
}
|
|
.stats-hero h1 {
|
|
font-family: 'Recion', 'Italiana', serif;
|
|
font-size: clamp(2.2rem, 6vw, 3.6rem);
|
|
letter-spacing: 0.06em;
|
|
color: #fff;
|
|
margin-bottom: 0.35rem;
|
|
}
|
|
.stats-hero p {
|
|
color: rgba(255,255,255,0.78);
|
|
font-size: .95rem;
|
|
max-width: 520px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
/* ── Contenedor ── */
|
|
.stats-wrap { max-width:860px; margin:0 auto; padding:2.5rem 1.2rem 4rem; }
|
|
|
|
/* ── KPIs ── */
|
|
.kpi-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(185px, 1fr));
|
|
gap: 1.1rem;
|
|
margin-bottom: 2.8rem;
|
|
}
|
|
.kpi-card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 16px;
|
|
padding: 1.4rem 1.2rem 1.2rem;
|
|
box-shadow: var(--shadow-md);
|
|
text-align: center;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
.kpi-card::before {
|
|
content:''; position:absolute; top:0; left:0; right:0; height:3px;
|
|
}
|
|
.kpi-card.sent::before { background: var(--sage); }
|
|
.kpi-card.redirect::before { background: var(--caoba-mid); }
|
|
.kpi-card.search::before { background: var(--lime); }
|
|
.kpi-card.total::before { background: var(--acid-dark); }
|
|
|
|
.kpi-num {
|
|
font-family: 'Recion', serif;
|
|
font-size: 3rem;
|
|
line-height: 1;
|
|
margin-bottom: 0.3rem;
|
|
transition: all 400ms ease-out;
|
|
}
|
|
.kpi-card.sent .kpi-num { color: var(--sage); }
|
|
.kpi-card.redirect .kpi-num { color: var(--caoba-mid); }
|
|
.kpi-card.search .kpi-num { color: var(--acid-dark); }
|
|
.kpi-card.total .kpi-num { color: var(--caoba); }
|
|
|
|
.kpi-label { font-size:.78rem; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); }
|
|
.kpi-sub { font-size:.72rem; color:var(--subtle); margin-top:.25rem; }
|
|
|
|
/* ── Proveedores ── */
|
|
.section-title {
|
|
font-family: 'Recion', serif;
|
|
font-size: 1.3rem;
|
|
color: var(--caoba);
|
|
letter-spacing: .04em;
|
|
margin-bottom: 1.2rem;
|
|
padding-bottom: .5rem;
|
|
border-bottom: 2px solid var(--border);
|
|
}
|
|
.provider-list { display:flex; flex-direction:column; gap:.75rem; margin-bottom:2.8rem; }
|
|
.provider-row { display:grid; grid-template-columns:130px 1fr 55px; align-items:center; gap:.8rem; }
|
|
.provider-name { font-size:.85rem; font-weight:600; color:var(--text); text-align:right; }
|
|
.bar-track { background:var(--surface2); border-radius:6px; height:14px; overflow:hidden; }
|
|
.bar-fill {
|
|
height:100%; border-radius:6px;
|
|
background: linear-gradient(90deg, var(--sage) 0%, var(--lime) 100%);
|
|
width:0%; transition: width 700ms cubic-bezier(.4,0,.2,1);
|
|
}
|
|
.bar-fill.redirect { background: linear-gradient(90deg, var(--caoba) 0%, var(--caoba-mid) 100%); }
|
|
.provider-count { font-size:.82rem; font-weight:700; color:var(--muted); }
|
|
|
|
.legend { display:flex; gap:1.4rem; margin-bottom:1.5rem; flex-wrap:wrap; }
|
|
.legend-item { display:flex; align-items:center; gap:.4rem; font-size:.78rem; color:var(--muted); }
|
|
.legend-dot { width:10px; height:10px; border-radius:3px; flex-shrink:0; }
|
|
.legend-dot.sent { background:var(--sage); }
|
|
.legend-dot.redirect { background:var(--caoba); }
|
|
|
|
/* ── Privacidad (expandible) ── */
|
|
.privacy-block {
|
|
background: var(--sage-lt);
|
|
border: 1px solid #a8d4bc;
|
|
border-radius: 14px;
|
|
overflow: hidden;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
.privacy-summary {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: .7rem;
|
|
padding: 1rem 1.3rem;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
list-style: none;
|
|
font-size: .85rem;
|
|
font-weight: 700;
|
|
color: var(--sage);
|
|
}
|
|
.privacy-summary::-webkit-details-marker { display:none; }
|
|
.privacy-summary .arrow { font-size:.7rem; transition:transform 200ms; }
|
|
details[open] .privacy-summary .arrow { transform:rotate(90deg); }
|
|
.privacy-body {
|
|
padding: 0 1.3rem 1.2rem;
|
|
font-size: .82rem;
|
|
color: var(--text);
|
|
line-height: 1.7;
|
|
}
|
|
.privacy-body h3 { font-size:.8rem; text-transform:uppercase; letter-spacing:.06em; color:var(--sage); margin:1rem 0 .3rem; }
|
|
.privacy-body ul { padding-left:1.2rem; }
|
|
.privacy-body ul li { margin-bottom:.25rem; }
|
|
.privacy-body code { background:var(--surface2); padding:.1rem .35rem; border-radius:4px; font-size:.78rem; }
|
|
|
|
/* ── Estado vacío / error ── */
|
|
.empty-state { text-align:center; padding:3rem 1rem; color:var(--subtle); font-size:.9rem; }
|
|
.empty-state .big { font-size:3rem; margin-bottom:.5rem; }
|
|
|
|
/* ── Fecha ── */
|
|
.updated-note { text-align:center; margin-top:1.2rem; font-size:.75rem; color:var(--subtle); }
|
|
|
|
/* ── Spinner ── */
|
|
.loading { text-align:center; padding:3rem; color:var(--subtle); }
|
|
.spinner { display:inline-block; width:28px; height:28px; border:3px solid var(--border); border-top-color:var(--caoba); border-radius:50%; animation:spin .8s linear infinite; margin-bottom:.6rem; }
|
|
@keyframes spin { to { transform:rotate(360deg); } }
|
|
|
|
@media (max-width:500px) {
|
|
.provider-row { grid-template-columns:95px 1fr 40px; gap:.45rem; }
|
|
.kpi-num { font-size:2.4rem; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<section class="hero-landing">
|
|
<div class="container hero-landing-inner">
|
|
<h1 class="main-title">RESETEA</h1>
|
|
<div class="landing-nav">
|
|
<a class="landing-nav-btn" href="tipos.html">Tipos de información</a>
|
|
<a class="landing-nav-btn" href="concienciacion.html">Concienciación</a>
|
|
<a class="landing-nav-btn" href="egosurfing.html">Egosurfing</a>
|
|
<a class="landing-nav-btn" href="plantillas.html">Resetea</a>
|
|
<a class="landing-nav-btn landing-nav-btn--highlight" href="stats.html">Estadísticas</a>
|
|
<a class="landing-nav-btn" href="salud-digital.html">🧠 Salud digital</a>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ── Hero ── -->
|
|
<div class="stats-hero">
|
|
<h1>ESTADÍSTICAS</h1>
|
|
<p>Actividad anónima de resetea.net — contadores agregados, sin ningún dato personal.</p>
|
|
</div>
|
|
|
|
<div class="stats-wrap">
|
|
<div id="content">
|
|
<div class="loading">
|
|
<div class="spinner"></div><br>Cargando datos...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
|
|
/* ── Escape HTML — toda cadena del servidor pasa por aquí antes de innerHTML ── */
|
|
function esc(str) {
|
|
return String(str ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
/* Los números vienen del servidor sanitizados, pero validamos aquí también */
|
|
function safeNum(v) {
|
|
const n = Math.floor(Number(v));
|
|
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
}
|
|
|
|
/* Iconos por clave — si la clave no está mapeada, se usa un emoji neutro */
|
|
const ICONS = {
|
|
instagram:'📸', facebook:'👤', twitter_x:'🐦', linkedin:'💼',
|
|
tiktok:'🎵', snapchat:'👻', microsoft:'🪟', apple:'🍎',
|
|
google:'🔍', amazon:'📦', reddit:'🤖', discord:'💬',
|
|
};
|
|
|
|
function animateCount(el, target, ms) {
|
|
if (!el) return;
|
|
if (target === 0) { el.textContent = '0'; return; }
|
|
const start = performance.now();
|
|
const tick = (now) => {
|
|
const p = Math.min((now - start) / ms, 1);
|
|
const ease = 1 - Math.pow(1 - p, 3);
|
|
el.textContent = Math.round(ease * target).toLocaleString('es-ES');
|
|
if (p < 1) requestAnimationFrame(tick);
|
|
};
|
|
requestAnimationFrame(tick);
|
|
}
|
|
|
|
function renderProviders(providers, maxTotal) {
|
|
if (!providers.length) return '';
|
|
|
|
const rows = providers.map(p => {
|
|
const total = safeNum(p.total);
|
|
const sent = safeNum(p.sent);
|
|
const redir = safeNum(p.redirected);
|
|
const onlyRedir = redir > 0 && sent === 0;
|
|
const pct = maxTotal > 0 ? Math.max(2, Math.round((total / maxTotal) * 100)) : 0;
|
|
|
|
/* icon_key viene del servidor whitelistado (a-z_), pero igual lo escapamos */
|
|
const icon = ICONS[esc(p.icon_key)] || '🔹';
|
|
|
|
/* p.name viene de la whitelist del servidor, pero escapeamos de todas formas */
|
|
return `<div class="provider-row">
|
|
<span class="provider-name">${icon} ${esc(p.name)}</span>
|
|
<div class="bar-track" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100">
|
|
<div class="bar-fill${onlyRedir ? ' redirect' : ''}" data-pct="${pct}"></div>
|
|
</div>
|
|
<span class="provider-count">${total.toLocaleString('es-ES')}</span>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
return `<h2 class="section-title">Por plataforma</h2>
|
|
<div class="legend">
|
|
<div class="legend-item"><div class="legend-dot sent"></div> Enviado por email</div>
|
|
<div class="legend-item"><div class="legend-dot redirect"></div> Formulario oficial</div>
|
|
</div>
|
|
<div class="provider-list">${rows}</div>`;
|
|
}
|
|
|
|
function renderPrivacy() {
|
|
return `<details class="privacy-block">
|
|
<summary class="privacy-summary">
|
|
<span>🔒</span>
|
|
<span>Política de privacidad de estas estadísticas</span>
|
|
<span class="arrow">▶</span>
|
|
</summary>
|
|
<div class="privacy-body">
|
|
<h3>Qué se cuenta</h3>
|
|
<ul>
|
|
<li>Número de correos GDPR enviados correctamente.</li>
|
|
<li>Número de redirecciones a formularios oficiales (Google, Amazon…).</li>
|
|
<li>Número de búsquedas OSINT realizadas en el egosurfing.</li>
|
|
<li>Desglose por plataforma: cuántas solicitudes recibió cada servicio.</li>
|
|
</ul>
|
|
<h3>Qué NO se registra nunca</h3>
|
|
<ul>
|
|
<li><strong>Ninguna dirección de email.</strong> Los emails se usan para enviar la carta y se descartan.</li>
|
|
<li><strong>Ninguna dirección IP.</strong> Ni del que solicita ni de nadie más.</li>
|
|
<li><strong>Ningún nombre, alias, teléfono ni dirección postal.</strong></li>
|
|
<li><strong>Ninguna marca de tiempo individual.</strong> Solo la fecha del último envío (YYYY-MM-DD) como indicador de actividad.</li>
|
|
<li><strong>Sin cookies, sin fingerprinting, sin analytics de terceros.</strong></li>
|
|
</ul>
|
|
<h3>Cómo funciona técnicamente</h3>
|
|
<ul>
|
|
<li>El servidor mantiene un fichero <code>stats.json</code> con contadores enteros.</li>
|
|
<li>Cada solicitud suma 1 al contador correspondiente. Nada más.</li>
|
|
<li>No existe ninguna tabla de eventos, logs de acceso ni base de datos.</li>
|
|
<li>La única referencia trazable es un hash SHA-256 truncado (12 chars) generado con una sal privada — sirve para que el usuario confirme su solicitud, pero es irreversible.</li>
|
|
<li>Estos datos agregados son públicos precisamente porque no contienen información personal.</li>
|
|
</ul>
|
|
<h3>Base legal</h3>
|
|
<p style="margin-top:.4rem">
|
|
Al no procesarse ningún dato de carácter personal, estas estadísticas no están sujetas al RGPD (Reglamento UE 2016/679) ni a la LOPDGDD (LO 3/2018). Son datos estadísticos anónimos en el sentido del Considerando 26 del RGPD.
|
|
</p>
|
|
</div>
|
|
</details>`;
|
|
}
|
|
|
|
function render(data) {
|
|
const sent = safeNum(data.total_sent);
|
|
const redir = safeNum(data.total_redirected);
|
|
const search = safeNum(data.total_searches);
|
|
const total = sent + redir;
|
|
const maxProv = data.providers?.length ? safeNum(data.providers[0].total) : 0;
|
|
|
|
/* updated: solo mostramos si es fecha válida (ya validada en servidor) */
|
|
const updatedStr = data.updated
|
|
? `Última actividad registrada: ${esc(data.updated)}`
|
|
: 'Sin actividad registrada aún';
|
|
|
|
const kpis = `<div class="kpi-grid">
|
|
<div class="kpi-card sent">
|
|
<div class="kpi-num" id="k-sent">0</div>
|
|
<div class="kpi-label">Emails enviados</div>
|
|
<div class="kpi-sub">Solicitudes Art. 17 GDPR</div>
|
|
</div>
|
|
<div class="kpi-card redirect">
|
|
<div class="kpi-num" id="k-redir">0</div>
|
|
<div class="kpi-label">Formularios web</div>
|
|
<div class="kpi-sub">Google, Amazon y similares</div>
|
|
</div>
|
|
<div class="kpi-card search">
|
|
<div class="kpi-num" id="k-search">0</div>
|
|
<div class="kpi-label">Búsquedas OSINT</div>
|
|
<div class="kpi-sub">Egosurfings realizados</div>
|
|
</div>
|
|
<div class="kpi-card total">
|
|
<div class="kpi-num" id="k-total">0</div>
|
|
<div class="kpi-label">Total solicitudes</div>
|
|
<div class="kpi-sub">Emails + formularios</div>
|
|
</div>
|
|
</div>`;
|
|
|
|
const providersHtml = total > 0
|
|
? renderProviders(data.providers || [], maxProv)
|
|
: `<div class="empty-state"><div class="big">📬</div>
|
|
El desglose por plataforma aparecerá cuando se envíen las primeras solicitudes.</div>`;
|
|
|
|
document.getElementById('content').innerHTML =
|
|
kpis + providersHtml + renderPrivacy() +
|
|
`<div class="updated-note">${updatedStr}</div>`;
|
|
|
|
animateCount(document.getElementById('k-sent'), sent, 900);
|
|
animateCount(document.getElementById('k-redir'), redir, 900);
|
|
animateCount(document.getElementById('k-search'), search, 900);
|
|
animateCount(document.getElementById('k-total'), total, 900);
|
|
|
|
setTimeout(() => {
|
|
document.querySelectorAll('.bar-fill').forEach(el => {
|
|
el.style.width = el.dataset.pct + '%';
|
|
});
|
|
}, 120);
|
|
}
|
|
|
|
function renderError(msg) {
|
|
document.getElementById('content').innerHTML =
|
|
`<div class="empty-state">
|
|
<div class="big">⚠️</div>
|
|
${esc(msg) || 'No se pudieron cargar las estadísticas.'}<br>
|
|
<small style="color:var(--subtle)">El backend puede estar arrancando. Inténtalo en unos segundos.</small>
|
|
</div>`;
|
|
}
|
|
|
|
/* La función fetch no envía cookies ni credenciales — same-origin, solo lectura */
|
|
fetch('/api/stats', { credentials: 'omit' })
|
|
.then(r => {
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
return r.json();
|
|
})
|
|
.then(render)
|
|
.catch(e => renderError(e.message));
|
|
|
|
}());
|
|
</script>
|
|
</body>
|
|
</html>
|