resetea.net/public/stats.html
hacklab 3ebcc3e405 feat: comunidad.html, slider 5 diapositivas, botón Derechos y fondos en study cards
- Nuevo comunidad.html con sección de participación en el proyecto y 14 alternativas de software libre organizadas por categoría (navegadores, buscadores, correo, mensajería, almacenamiento, redes sociales, VPN/DNS)
- Slider principal ampliado de 3 a 5 diapositivas: slides 4 (jardín japonés) y 5 (bosque aéreo) con layout split texto+botón; slide 2 reemplazada con foto Times Square aérea
- CTAs ácido-verde en slides 1-3 enlazando a salud-digital#ciencia, egosurfing y salud-digital#derechos
- Sección confirm-section sustituida por botón grande "Derechos" que despliega 8 botones verbales (Conocer/Corregir/Borrar/Limitar/Exportar/Oponerse/Decidir/Reclamar) con right-cards RGPD expandibles
- Fondos fotográficos con overlay oscuro en las 4 study cards (estudio-brain, atención, sueño, datos)
- 7 fotos nuevas descargadas en img/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 23:51:48 +02:00

370 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="egosurfing.html">Egosurfing</a>
<a class="landing-nav-btn" href="plantillas.html">Resetea</a>
<a class="landing-nav-btn landing-nav-btn--highlight" href="comunidad.html">Comunidad</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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
/* 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:'IG', facebook:'FB', twitter_x:'X', linkedin:'LI',
tiktok:'TK', snapchat:'SC', microsoft:'MS', apple:'AP',
google:'GG', amazon:'AM', reddit:'RE', discord:'DC',
};
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>