feat: rediseño UI completo + infra email + stats

This commit is contained in:
hacklab 2026-04-20 00:46:00 +02:00
parent 93d75ddafe
commit 24401c0ee5
37 changed files with 2162 additions and 412 deletions

209
CONTEXT.txt Normal file
View file

@ -0,0 +1,209 @@
══════════════════════════════════════════════════════════════════════
RESETEA.NET — CONTEXTO DEL PROYECTO
Actualizado: 2026-04-20
══════════════════════════════════════════════════════════════════════
QUÉ ES
──────
Herramienta web de privacidad digital. Ayuda a los usuarios a reducir
su huella digital: enviar cartas GDPR, eliminar cuentas en redes
sociales, desindexar datos de buscadores y contactar con data brokers.
Principio fundamental: PRIVACY-FIRST.
· No se almacena ningún dato personal (PII).
· Sin cookies, sin tracking, sin analytics.
· La generación de cartas en plantillas.html es 100% local (JS en navegador).
· El único dato que pasa por el servidor es el email para reenvío GDPR,
y solo se guarda un hash SHA-256 truncado (12 chars) como referencia.
ARQUITECTURA GENERAL
────────────────────
Internet
└── Nginx (puerto 80 → redirect HTTPS / puerto 443 SSL)
├── / → archivos estáticos en public/
└── /api/* → proxy → Node.js en 127.0.0.1:8787
└── Nodemailer → sendmail (MTA local)
└── Postfix → relay Brevo SMTP (smtp-relay.brevo.com:587)
└── opendkim (firma DKIM, selector: mail, 2048-bit RSA)
└── Google APIs (opcional: Gmail OAuth, CSE)
└── SearXNG (instancias públicas, fallback egosearch)
INFRAESTRUCTURA DE EMAIL
────────────────────────
Problema: IP dinámica del router → reputación de envío comprometida.
Solución: relay via Brevo (free tier, 300 emails/día).
· Postfix configurado como relay hacia smtp-relay.brevo.com:587
· Credenciales en /etc/postfix/sasl_passwd (script: infra/set-relay-credentials.sh)
· DKIM signing con opendkim — selector "mail", clave en /etc/opendkim/keys/resetea.net/
· DNS gestionado via Gandi LiveDNS API — script: /home/capitansito/HOST/managedns.sh
Registros DNS configurados:
SPF: "v=spf1 include:spf.brevo.com include:_mailcust.gandi.net ~all"
DKIM: mail._domainkey.resetea.net TXT (clave pública 2048-bit RSA)
DMARC: _dmarc.resetea.net TXT "v=DMARC1; p=none; rua=mailto:dmarc@resetea.net"
→ Cuando el envío sea estable varios días, subir p=none a p=quarantine
Scripts de infraestructura:
infra/setup-mail.sh — instala Postfix, opendkim, genera clave DKIM
infra/set-relay-credentials.sh — escribe /etc/postfix/sasl_passwd con login Brevo
HOST/managedns.sh — actualiza IP dinámica en Gandi (NO toca registros mail)
Comando: setup-mail-dns para configurar SPF/DKIM/DMARC
Proceso Node.js:
Servicio: /etc/systemd/system/resetea.service
Node binary: /home/capitansito/.nvm/versions/node/v18.20.8/bin/node
Arranque: sudo systemctl restart resetea
PÁGINAS PÚBLICAS (public/)
───────────────────────────
index.html — Página principal. REDISEÑADA EN ESTA SESIÓN.
Secciones:
1. Hero: título "RESETEA" + lema "La información es poder. Quitémosles poder."
Carrusel de citas (5 frases, rota cada 6s):
- "Matamos gente basándonos en metadatos." (Gen. Hayden, NSA)
- Edward Snowden sobre privacidad
- Parafraseando Orwell, 1984
- "No somos clientes, somos el producto." (Serra & Schoolman, 1973)
- "La vigilancia es el modelo de negocio de internet." (Schneier)
2. Formulario GDPR (DISEÑO DOS COLUMNAS — nuevo):
Columna izquierda (Paso 1): email input + lista de privacidad
Columna derecha (Paso 2): selección de redes
· Grid de chips AUTO-GDPR (instagram, facebook, twitter_x, linkedin,
tiktok, snapchat, discord, reddit, microsoft, apple, google, amazon)
→ seleccionables, al enviar se manda carta GDPR via API o se abre form
· Grid de chips MANUALES (whatsapp, telegram, spotify, youtube,
netflix, twitch, pinterest)
→ ACCIÓN DIRECTA: clic abre el enlace oficial inmediatamente
→ NO son seleccionables, NO cuentan para el botón "Enviar"
→ Borde discontinuo, flash verde al hacer clic
· Botón "Seleccionar todas" (solo auto-chips)
· Contador "X redes seleccionadas"
Botón "Enviar cartas GDPR (X)" — solo activo con email válido + al menos 1 auto-chip
3. Panel de acciones completo (REDISEÑADO — secciones visibles, sin tabs):
Barra de progreso global (todos los checkboxes)
6 secciones siempre visibles apiladas verticalmente, cada una con:
· Badge pill con nombre en fuente Recion (Cuentas base, Redes sociales,
Mensajería, Streaming, Buscadores, Data brokers)
· Botón "Marcar todas" (header) y "✓ Marcar sección completada" (footer)
· Grid de tarjetas con: logo oficial + nombre + botones de acción
· Al marcar checkbox: tarjeta se vuelve verde irlandés (sage)
4. Sección "Cómo funciona" (3 pasos)
5. Sección "Plantillas legales"
6. Aviso legal
Logos: Simple Icons CDN (cdn.simpleicons.org) — pendiente mover a /public/icons/
PENDIENTE: Descargar los SVGs localmente (linkedin, microsoft, amazon,
microsoftbing fallaron — buscar slug correcto en Simple Icons)
stats.html — Dashboard de estadísticas anónimas. NUEVO.
· 4 KPI cards: emails enviados, redireccionados, búsquedas, total
· Gráfico de barras por proveedor
· Explicación detallada de privacidad (sección <details> expandible)
· Fetch a GET /api/stats con credentials:'omit'
· Todo el HTML de servidor pasa por esc() antes de innerHTML (anti-XSS)
plantillas.html — Generador de cartas GDPR (browser-side).
· esc() + safeHref() aplicados a todo HTML dinámico
· Nav: enlace añadido a stats.html
concienciacion.html, tipos.html, egosurfing.html — sin cambios en esta sesión.
SERVICIOS DEL BACKEND (api/)
─────────────────────────────
POST /api/erase
Envía carta GDPR Art. 17 al DPO.
Entrada: { provider, email, [nickname, phone, address, extra] }
Salida: { status: 'ok'|'use_form'|error, reference: hash12chars }
Proveedores API directa (10): instagram, facebook, twitter_x, linkedin,
tiktok, snapchat, microsoft, apple, reddit, discord
Proveedores formulario oficial (2): google, amazon
SEGURIDAD: sin fallback a proveedor desconocido (eliminado open-relay)
GET /api/stats
Devuelve estadísticas anónimas en JSON.
Rate limit: 30 req/min
Datos: total_sent, total_redirected, total_errors, total_searches,
by_provider (solo proveedores whitelisteados)
Almacenamiento: data/stats.json (escritura atómica con .tmp + rename)
SIN PII — solo contadores agregados.
GET /api/egosearch?q=<consulta>
Busca con SearXNG o Google CSE. Límite: 500.000 bytes respuesta.
Registra stats.record({ type: 'search' }) por cada búsqueda.
GET /api/gmail/auth → GET /api/gmail/callback
OAuth2 Google. Estado firmado con HMAC-SHA256 + nonce (anti-forgery).
Token de un solo uso, sin refresh, sin persistencia.
SEGURIDAD IMPLEMENTADA
──────────────────────
FRONTEND:
· esc() en todos los datos del servidor antes de innerHTML
· safeHref() para validar URLs (solo http/https)
· credentials:'omit' en todos los fetch
BACKEND:
· Rate limiting Nginx: 20 req/s, burst 40 para /api/
· Rate limiting Express: general 100/15min, email 10/1h, búsqueda 8/1min, stats 30/1min
· IP real via X-Real-IP desde Nginx ($remote_addr — no spoofeable)
· Helmet: HSTS, X-Frame-Options DENY, CSP estricta, Referrer-Policy, Permissions-Policy
· Anti-CRLF: sanitizeHeader() en campos opcionales del email
· Anti prototype pollution: Object.create(null) en stats.by_provider
· Timeout Slowloris: headersTimeout 10s, requestTimeout 15s en Node
· Timeout Nginx: proxy_read_timeout 15s, connect_timeout 5s
· OAuth state: HMAC-SHA256 con nonce random, base64url, max 4096 bytes
· Mailer: sin fallback a proveedor desconocido
· Stats: safeInt() valida numéricos, VALID_DATE regex valida fecha
DISEÑO Y UI
───────────
Sistema de diseño (index.css):
Variables CSS: --caoba (marrón oscuro), --sage (#1a7a4a, verde irlandés),
--acid (#c8ff00, amarillo ácido), --surface, --bg, --border
Fuentes: Recion/Italiana (serif, para títulos y badges), system-ui (para texto)
Formulario principal:
· Layout dos columnas (grid 340px + 1fr) — sticky email column
· Botones de red: system-ui, 0.97rem, grid auto-fill minmax(130px, 1fr)
· Auto-chips seleccionados: verde irlandés (#1a7a4a)
· Manual chips: borde discontinuo, clic directo
· Mobile: stacks a columna única en ≤720px
Panel de acciones:
· Secciones visibles sin tabs
· Badges: fuente Recion, background caoba, border-radius 20px
· Cards: border 1.5px, border-radius 14px, checked = sage-lt background
· Botones de acción: 0.82rem, padding 0.35rem 0.85rem, border-radius 8px
· Logos: Simple Icons CDN (pendiente mover a /public/icons/ local)
PENDIENTES PARA PRÓXIMA SESIÓN
───────────────────────────────
1. Logos locales: descargar SVGs de Simple Icons con slugs correctos
Fallaron: linkedin, microsoft, amazon, microsoftbing
Intentar desde: https://simpleicons.org/ o el paquete npm simple-icons
Guardar en: public/icons/{slug}.svg
Actualizar src en HTML de /icons/{slug}.svg
2. DNS/mail: revisar logs de envío con journalctl -u postfix
Cuando envío sea estable varios días → subir DMARC a p=quarantine:
Comando: bash /home/capitansito/HOST/managedns.sh setup-mail-dns
(editar el script para cambiar p=none a p=quarantine)
3. Systemd: verificar que resetea.service arranca bien con:
sudo systemctl status resetea
sudo systemctl enable resetea (si no está enabled)
4. Probar flujo completo de email desde la web en producción.
5. Gmail OAuth: requiere credenciales Google Cloud Console en .env
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URI
6. Revisar si el ícono de Snapchat (amarillo) es visible sobre fondo claro —
puede necesitar ajuste de contraste.

248
DOCS.txt Normal file
View file

@ -0,0 +1,248 @@
══════════════════════════════════════════════════════════════════════
RESETEA.NET — DOCUMENTACIÓN TÉCNICA
Actualizado: 2026-04
══════════════════════════════════════════════════════════════════════
PUERTOS
───────
80 Nginx — HTTP (redirige a HTTPS automáticamente)
443 Nginx — HTTPS (SSL/TLS, certificado Let's Encrypt)
8787 Node.js/Express — Solo escucha en 127.0.0.1 (loopback, no expuesto)
Nginx hace de proxy inverso: /api/* → 127.0.0.1:8787
⚠ El puerto 8787 NO debe abrirse en el firewall. Solo Nginx lo usa.
ENDPOINTS DE LA API
────────────────────
Base: https://resetea.net/api/
POST /api/erase
Descripción: Envía carta GDPR Art. 17 al DPO de la plataforma.
Content-Type: application/json
Rate limit: 10 peticiones / hora / IP
Body:
{
"provider": "instagram", ← obligatorio
"email": "tu@email.com",← obligatorio
"nickname": "usuario123", ← opcional
"phone": "+34 600...", ← opcional
"address": "Calle...", ← opcional
"extra": "Texto libre" ← opcional
}
Respuesta OK:
{ "status": "ok", "message": "...", "reference": "abc123def456" }
Respuesta proveedor sin email:
{ "status": "use_form", "formUrl": "https://...", "reference": "..." }
Respuesta error:
{ "error": "descripción del error" }
GET /api/egosearch?q=<consulta>
Descripción: Busca en la web menciones del término indicado.
Rate limit: 8 peticiones / minuto / IP
Parámetros:
q (string, 2-150 chars) — nombre o alias a buscar
Respuesta:
{ "results": [ { "title", "url", "snippet", "domain", "engine" } ], "query", "total" }
Nota: sin operadores especiales, la query se envuelve en comillas automáticamente.
GET /api/gmail/auth?provider=&name=&email=&...
Descripción: Inicia flujo OAuth2 para enviar carta desde Gmail del usuario.
Parámetros: provider, name, email (obligatorios) + nickname, phone, address, extra, requestType (opcionales)
Respuesta: Redirect a Google OAuth2.
Requiere: GOOGLE_CLIENT_ID y GOOGLE_CLIENT_SECRET en .env
GET /api/gmail/callback?code=&state=&error=
Descripción: Callback OAuth2 de Google. Envía el email y descarta el token.
Respuesta: Redirect a /plantillas.html?oauth=ok|cancelled|error|no_email
GET /api/health
Descripción: Health check.
Respuesta: { "status": "ok" }
VARIABLES DE ENTORNO (.env en api/)
─────────────────────────────────────
Obligatorias:
SALT Cadena aleatoria larga para hash de referencia de auditoría.
Ejemplo: openssl rand -hex 32
PORT Puerto del servidor Node.js. Por defecto: 8787
Opcionales — Gmail OAuth2 (sin esto el botón "Enviar desde mi Gmail" no funciona):
GOOGLE_CLIENT_ID ID de cliente OAuth2 de Google Cloud Console
GOOGLE_CLIENT_SECRET Secreto del cliente OAuth2
GOOGLE_REDIRECT_URI URI de callback. Valor: https://resetea.net/api/gmail/callback
Opcionales — Google Custom Search (sin esto egosearch usa SearXNG):
GOOGLE_API_KEY API Key de Google Cloud Console (Custom Search API habilitada)
GOOGLE_CSE_ID ID del motor de búsqueda de Programmable Search Engine
Plantilla del archivo .env:
SALT=<openssl rand -hex 32>
PORT=8787
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=https://resetea.net/api/gmail/callback
GOOGLE_API_KEY=
GOOGLE_CSE_ID=
DEPENDENCIAS DEL BACKEND (npm)
────────────────────────────────
Producción (api/package.json):
express ^4.19.2 Framework HTTP
helmet ^7.1.0 Cabeceras de seguridad HTTP
express-rate-limit ^7.3.1 Rate limiting por IP
nodemailer ^6.9.13 Envío de emails via sendmail local
googleapis ^144.0.0 Gmail API + OAuth2 (para envío desde Gmail del usuario)
dotenv ^16.4.5 Carga de variables de entorno desde .env
Sin dependencias de frontend (HTML/CSS/JS puro, sin frameworks).
SOFTWARE DEL SISTEMA NECESARIO
────────────────────────────────
Obligatorio:
Node.js >= 18 Servidor backend
npm >= 8 Gestor de paquetes Node
Nginx Servidor web / proxy inverso
Certbot (Let's Encrypt) Certificados SSL — certbot --nginx
Para el envío de emails (POST /api/erase):
Un MTA (Mail Transfer Agent) local que provea /usr/sbin/sendmail.
Opciones comunes:
· Postfix: apt install postfix (recomendado, configurar como "Internet Site")
· msmtp: apt install msmtp msmtp-mta (más simple, relay a servidor externo)
· Exim4: apt install exim4
⚠ SIN MTA el endpoint POST /api/erase fallará silenciosamente.
El binario /usr/sbin/sendmail debe existir en el sistema.
Para verificar: which sendmail
Dominio de envío configurado en mailer.js:
From: privacy@resetea.net
El dominio resetea.net debe tener registros SPF/DKIM/DMARC correctos
para que los emails no lleguen a spam.
CONFIGURACIÓN DE NGINX
───────────────────────
Archivo: infra/nginx-resetea.conf
Copiar en: /etc/nginx/sites-available/resetea.net
Activar: ln -s /etc/nginx/sites-available/resetea.net /etc/nginx/sites-enabled/
nginx -t && nginx -s reload
Lo que hace:
· Puerto 80 → redirect 301 a HTTPS.
· Puerto 443 → sirve public/ como estático.
· /api/* → proxy a 127.0.0.1:8787 con X-Real-IP = $remote_addr.
· Rate limit Nginx: 20 req/s por IP, burst 40 en /api/.
· Headers de seguridad: HSTS, X-Frame-Options, CSP, Referrer-Policy,
Permissions-Policy, COOP, X-Content-Type-Options.
· Timeout proxy: read 15s, connect 5s.
· server_tokens off (oculta versión de Nginx).
SSL (Let's Encrypt):
certbot --nginx -d resetea.net
Rutas de certificados:
/etc/letsencrypt/live/resetea.net/fullchain.pem
/etc/letsencrypt/live/resetea.net/privkey.pem
FUENTES TIPOGRÁFICAS (public/fonts/)
──────────────────────────────────────
RECION.otf / RECION.ttf Títulos (h1, h2, h3, chips, logo)
Italiana-Regular.ttf Alternativa serif
CormorantGaramond-Regular.woff2 Cuerpo de texto elegante
CormorantGaramond-Italic.woff2 Cursiva
ARRANCAR EL BACKEND
────────────────────
cd /var/www/resetea.net/api
npm install ← solo la primera vez o tras cambiar package.json
node app.js ← arranque simple
Para producción (sin systemd todavía):
npm install -g pm2
pm2 start app.js --name resetea
pm2 save
pm2 startup ← genera el comando para arranque automático
Para crear un servicio systemd (recomendado):
Crear /etc/systemd/system/resetea.service:
[Unit]
Description=RESETEA.NET backend
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/resetea.net/api
ExecStart=/usr/bin/node app.js
Restart=on-failure
EnvironmentFile=/var/www/resetea.net/api/.env
[Install]
WantedBy=multi-user.target
systemctl enable resetea
systemctl start resetea
APIS EXTERNAS UTILIZADAS
─────────────────────────
SearXNG (instancias públicas — sin key, gratuito):
Se usa como motor de búsqueda para egosurfing cuando no hay Google CSE.
Instancias hardcodeadas en egosearch.js:
https://searx.be
https://priv.au
https://search.mdosch.de
https://searxng.site
Se prueban en orden hasta obtener resultados. Timeout: 7s por instancia.
Google Custom Search API (opcional — mejora calidad de resultados):
https://programmablesearchengine.google.com/
https://console.cloud.google.com/ → Custom Search API
Requiere: GOOGLE_API_KEY + GOOGLE_CSE_ID en .env
Google Gmail API + OAuth2 (opcional — envío desde Gmail del usuario):
https://console.cloud.google.com/ → APIs y servicios → Gmail API
Tipo de credencial: OAuth2, Aplicación web
URI de redirección autorizado: https://resetea.net/api/gmail/callback
Scope usado: https://www.googleapis.com/auth/gmail.send
Requiere: GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET en .env
El token es de un solo uso (access_type: 'online', no refresh token).
Let's Encrypt (Certbot):
Renovación automática via cron o systemd timer.
Verificar: certbot renew --dry-run
ESTRUCTURA DE ARCHIVOS
───────────────────────
/var/www/resetea.net/
public/
index.html Panel principal
concienciacion.html Derechos GDPR
plantillas.html Generador de cartas (JS local)
tipos.html Tipos de información personal
egosurfing.html Guía OSINT + buscador integrado
index.css Design system completo (tema cálido, caoba/salvia)
fonts/ Tipografías locales
api/
app.js Entrada: Express + helmet + rate-limit + rutas
routes/
erase.js POST /api/erase — validación + envío GDPR
egosearch.js GET /api/egosearch — búsqueda SearXNG/Google CSE
gmail_oauth.js GET /api/gmail/auth y /callback — OAuth2 Gmail
services/
mailer.js Datos de 12 DPOs + plantilla carta Art.17 + nodemailer
.env Variables de entorno (NO subir a git)
package.json Dependencias npm
infra/
nginx-resetea.conf Config Nginx lista para copiar
CONTEXT.txt Contexto del proyecto (este documento complementario)
DOCS.txt Este archivo
context_puppeteer.txt Diseño de automatización Puppeteer (pendiente)

View file

@ -9,6 +9,7 @@ const rateLimit = require('express-rate-limit');
const eraseRoute = require('./routes/erase');
const gmailOAuth = require('./routes/gmail_oauth');
const egosearch = require('./routes/egosearch');
const statsRoute = require('./routes/stats');
const app = express();
@ -57,6 +58,15 @@ app.get('/api/egosearch', searchLimit, egosearch);
app.get('/api/gmail/auth', emailLimit, gmailOAuth.authInit);
app.get('/api/gmail/callback', gmailOAuth.authCallback);
// Stats públicas (solo contadores anónimos, sin PII)
const statsLimit = rateLimit({
windowMs: 60 * 1000,
max: 30,
message: { error: 'Demasiadas peticiones.' },
keyGenerator: realIp,
});
app.get('/api/stats', statsLimit, statsRoute);
// Health check
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));

View file

@ -94,7 +94,9 @@ async function searchGoogle(query) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error?.message || `HTTP ${res.status}`);
}
const data = await res.json();
const text = await res.text();
if (text.length > 500_000) throw new Error('Respuesta Google CSE demasiado grande');
const data = JSON.parse(text);
return (data.items || []).map(item => ({
title: String(item.title || '').slice(0, 300),
url: String(item.link || ''),
@ -103,6 +105,8 @@ async function searchGoogle(query) {
}));
}
const stats = require('../services/stats');
/* ── Handler principal ──────────────────────────────────────── */
module.exports = async (req, res) => {
const raw = req.query.q;
@ -150,5 +154,6 @@ module.exports = async (req, res) => {
engine: r.engine,
}));
stats.record({ type: 'search' });
res.json({ results, query, total: raw_results.length });
};

View file

@ -2,6 +2,7 @@
const crypto = require('crypto');
const { sendErasureMail, PROVIDER_DATA } = require('../services/mailer');
const stats = require('../services/stats');
const ALLOWED_PROVIDERS = new Set(Object.keys(PROVIDER_DATA));
@ -50,6 +51,7 @@ module.exports = async (req, res) => {
const result = await sendErasureMail({ provider, email, nickname, phone, address, extra });
if (result.skipped) {
stats.record({ type: 'redirected', provider });
return res.json({
status: 'use_form',
message: 'Este proveedor no acepta solicitudes por email. Usa su formulario oficial.',
@ -58,7 +60,7 @@ module.exports = async (req, res) => {
});
}
// PII fuera de scope aquí — solo el hash queda
stats.record({ type: 'sent', provider });
res.json({
status: 'ok',
message: 'Solicitud enviada. Guarda el código de referencia.',
@ -66,6 +68,7 @@ module.exports = async (req, res) => {
});
} catch (e) {
stats.record({ type: 'error' });
console.error('erase route error:', e.message);
res.status(500).json({ error: 'Error interno. Inténtalo de nuevo o usa el formulario oficial del proveedor.' });
}

View file

@ -1,30 +1,49 @@
'use strict';
/**
* RESETEA.NET OAuth Gmail
*
* Flujo:
* GET /api/gmail/auth redirige a Google para autorización
* GET /api/gmail/callback intercambia code por token de un solo uso
* POST /api/gmail/send envía la carta GDPR desde el Gmail del usuario
* (el token se usa y se descarta nunca se persiste)
*
* PREREQUISITO:
* Crea un proyecto en Google Cloud Console:
* https://console.cloud.google.com/
* Habilita "Gmail API"
* Crea credenciales OAuth2 (tipo "Aplicación web")
* URI de redirección: https://resetea.net/api/gmail/callback
* Copia GOOGLE_CLIENT_ID y GOOGLE_CLIENT_SECRET en .env
*/
const { google } = require('googleapis');
const crypto = require('crypto');
const { google } = require('googleapis');
const { buildLetterText, PROVIDER_DATA } = require('../services/mailer');
// ── Estado temporal en memoria (un objeto por token de sesión) ──
// NO se persiste en disco. Si el servidor reinicia, se pierden los
// tokens pendientes (el usuario debe repetir el flujo OAuth).
const pendingSends = new Map(); // sessionId → { token, letterParams }
const ALLOWED_PROVIDERS = new Set(Object.keys(PROVIDER_DATA));
const STATE_MAX_BYTES = 4096;
const SALT = () => process.env.SALT || 'resetea-oauth-default-insecure';
/* ── Elimina CRLF y tabuladores — previene header injection en RFC 2822 ── */
function sanitizeHeader(str, maxLen = 300) {
return String(str || '').replace(/[\r\n\t\x00-\x1F\x7F]/g, ' ').trim().slice(0, maxLen);
}
/* ── Firma HMAC-SHA256 del state — previene state forgery en OAuth callback ── */
function signState(payload) {
const nonce = crypto.randomBytes(16).toString('hex');
const data = { ...payload, _nonce: nonce };
const sig = crypto.createHmac('sha256', SALT())
.update(JSON.stringify(data))
.digest('hex');
return Buffer.from(JSON.stringify({ ...data, _sig: sig })).toString('base64url');
}
function verifyState(raw) {
if (!raw || typeof raw !== 'string') throw new Error('State ausente');
if (Buffer.byteLength(raw, 'utf8') > STATE_MAX_BYTES) throw new Error('State demasiado grande');
let obj;
try { obj = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')); }
catch { throw new Error('State malformado'); }
const { _sig, ...data } = obj;
if (typeof _sig !== 'string' || _sig.length !== 64) throw new Error('Firma ausente o malformada');
const expected = crypto.createHmac('sha256', SALT()).update(JSON.stringify(data)).digest('hex');
const sigBuf = Buffer.from(_sig, 'hex');
const expectedBuf = Buffer.from(expected, 'hex');
if (!crypto.timingSafeEqual(sigBuf, expectedBuf)) throw new Error('Firma inválida');
/* Devuelve payload sin los campos internos */
const { _nonce, ...params } = data;
return params;
}
function getOAuth2Client() {
return new google.auth.OAuth2(
@ -34,25 +53,31 @@ function getOAuth2Client() {
);
}
// ── GET /api/gmail/auth ──────────────────────────────────────────
// El frontend envía los parámetros de la carta como query params
// para que los podamos recuperar en el callback.
/* ── GET /api/gmail/auth ─────────────────────────────────────────── */
exports.authInit = (req, res) => {
const { provider, name, email, nickname, phone, address, extra, requestType } = req.query;
if (!provider || !email || !name) {
if (!provider || !email || !name)
return res.status(400).json({ error: 'Faltan parámetros obligatorios (provider, name, email).' });
}
if (!PROVIDER_DATA[provider]) {
return res.status(400).json({ error: 'Proveedor no soportado.' });
}
// Guardamos el state en base64 (no sensible: no contiene credenciales)
const state = Buffer.from(JSON.stringify({ provider, name, email, nickname, phone, address, extra, requestType })).toString('base64url');
if (!ALLOWED_PROVIDERS.has(provider))
return res.status(400).json({ error: 'Proveedor no soportado.' });
/* Sanitizamos los campos que luego irán a cabeceras de email */
const state = signState({
provider,
name: sanitizeHeader(name, 200),
email: sanitizeHeader(email, 200),
nickname: sanitizeHeader(nickname, 100),
phone: sanitizeHeader(phone, 30),
address: sanitizeHeader(address, 300),
extra: sanitizeHeader(extra, 500),
requestType: sanitizeHeader(requestType, 20),
});
const oauth2Client = getOAuth2Client();
const authUrl = oauth2Client.generateAuthUrl({
access_type: 'online', // no refresh token — uso puntual
access_type: 'online',
scope: ['https://www.googleapis.com/auth/gmail.send'],
prompt: 'consent',
state,
@ -61,48 +86,55 @@ exports.authInit = (req, res) => {
res.redirect(authUrl);
};
// ── GET /api/gmail/callback ──────────────────────────────────────
/* ── GET /api/gmail/callback ─────────────────────────────────────── */
exports.authCallback = async (req, res) => {
const { code, state, error } = req.query;
if (error) {
return res.redirect('/plantillas.html?oauth=cancelled');
}
if (!code || !state) {
if (error) return res.redirect('/plantillas.html?oauth=cancelled');
if (!code || !state)
return res.status(400).send('Parámetros OAuth inválidos.');
}
let params;
try {
params = JSON.parse(Buffer.from(state, 'base64url').toString());
} catch {
return res.status(400).send('State OAuth inválido.');
params = verifyState(state);
} catch (e) {
console.warn('OAuth state rejection:', e.message);
return res.status(400).send('State OAuth inválido o expirado.');
}
/* Validar provider contra whitelist (por si el state fue manipulado antes de que
implementáramos la firma, o si la firma falla silenciosamente en el futuro) */
if (!ALLOWED_PROVIDERS.has(params.provider)) {
return res.status(400).send('Proveedor no soportado.');
}
try {
const oauth2Client = getOAuth2Client();
const { tokens } = await oauth2Client.getToken(code);
// Enviamos el email inmediatamente (no almacenamos el token)
const { tokens } = await oauth2Client.getToken(code);
oauth2Client.setCredentials(tokens);
const gmail = google.gmail({ version: 'v1', auth: oauth2Client });
const gmail = google.gmail({ version: 'v1', auth: oauth2Client });
const providerInfo = PROVIDER_DATA[params.provider];
if (!providerInfo.email) {
return res.redirect(`/plantillas.html?oauth=no_email&provider=${params.provider}&formUrl=${encodeURIComponent(providerInfo.formUrl || '')}`);
return res.redirect(
`/plantillas.html?oauth=no_email&provider=${encodeURIComponent(params.provider)}` +
`&formUrl=${encodeURIComponent(providerInfo.formUrl || '')}`
);
}
const letterText = buildLetterText({
providerInfo,
senderName: params.name,
senderEmail: params.email,
senderNick: params.nickname || '',
senderPhone: params.phone || '',
senderAddress: params.address || '',
extra: params.extra || '',
senderNick: params.nickname || '',
senderPhone: params.phone || '',
senderAddress: params.address || '',
extra: params.extra || '',
});
// Construir email RFC 2822 en base64url
/* Cabeceras RFC 2822 — name y email ya vienen sanitizados desde authInit */
const subject = `Ejercicio derecho de supresión (RGPD Art. 17) — ${providerInfo.name}`;
const rawEmail = [
`From: ${params.name} <${params.email}>`,
@ -114,14 +146,11 @@ exports.authCallback = async (req, res) => {
letterText,
].join('\r\n');
const encoded = Buffer.from(rawEmail).toString('base64url');
await gmail.users.messages.send({
userId: 'me',
requestBody: { raw: encoded },
requestBody: { raw: Buffer.from(rawEmail).toString('base64url') },
});
// Token descartado aquí (fuera de scope, GC lo recogerá)
res.redirect(`/plantillas.html?oauth=ok&provider=${encodeURIComponent(providerInfo.name)}`);
} catch (err) {

55
api/routes/stats.js Normal file
View file

@ -0,0 +1,55 @@
'use strict';
const { get } = require('../services/stats');
// Whitelist estricta: solo claves conocidas salen en la respuesta
const PROVIDER_LABELS = {
instagram: 'Instagram',
facebook: 'Facebook',
twitter_x: 'X (Twitter)',
linkedin: 'LinkedIn',
tiktok: 'TikTok',
snapchat: 'Snapchat',
microsoft: 'Microsoft',
apple: 'Apple',
google: 'Google',
amazon: 'Amazon',
reddit: 'Reddit',
discord: 'Discord',
};
const VALID_DATE = /^\d{4}-\d{2}-\d{2}$/;
function safeInt(v) {
const n = Math.floor(Number(v));
return Number.isFinite(n) && n >= 0 ? n : 0;
}
module.exports = (req, res) => {
const raw = get();
// Solo proveedores de la whitelist — cualquier clave extraña en stats.json se descarta
const providers = Object.entries(raw.by_provider || {})
.filter(([key]) => Object.prototype.hasOwnProperty.call(PROVIDER_LABELS, key))
.map(([key, v]) => ({
name: PROVIDER_LABELS[key], // nombre viene de whitelist, no del fichero
icon_key: key, // clave corta para iconos en cliente (solo a-z_)
sent: safeInt(v.sent),
redirected: safeInt(v.redirected),
total: safeInt(v.sent) + safeInt(v.redirected),
}))
.sort((a, b) => b.total - a.total);
// updated: solo si es una fecha válida YYYY-MM-DD, si no null
const updated = typeof raw.updated === 'string' && VALID_DATE.test(raw.updated)
? raw.updated
: null;
res.json({
total_sent: safeInt(raw.total_sent),
total_redirected: safeInt(raw.total_redirected),
total_searches: safeInt(raw.total_searches),
providers,
updated,
});
};

View file

@ -84,12 +84,14 @@ ${today}`;
exports.PROVIDER_DATA = PROVIDER_DATA;
exports.sendErasureMail = async ({ provider, email, nickname, phone, address, extra }) => {
const providerInfo = PROVIDER_DATA[provider] || {
name: provider,
email: `privacy@${provider}.com`,
company: provider,
address: '',
};
const providerInfo = PROVIDER_DATA[provider];
/* Sin fallback: el provider ya fue validado en erase.js contra ALLOWED_PROVIDERS.
Si llegara algo desconocido aquí sería un bug, no un caso legítimo.
No construir email destino dinámico eso sería un open relay. */
if (!providerInfo) {
throw new Error(`Proveedor desconocido: ${provider}`);
}
// Si el proveedor no tiene email directo, no enviamos (devolvemos aviso)
if (!providerInfo.email) {

55
api/services/stats.js Normal file
View file

@ -0,0 +1,55 @@
'use strict';
const fs = require('fs');
const path = require('path');
const DATA_FILE = path.join(__dirname, '..', 'data', 'stats.json');
const EMPTY = () => ({
total_sent: 0,
total_redirected: 0,
total_errors: 0,
total_searches: 0,
by_provider: Object.create(null), // null prototype — previene prototype pollution
updated: null,
});
function load() {
try {
return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
} catch {
return EMPTY();
}
}
function save(data) {
const dir = path.dirname(DATA_FILE);
fs.mkdirSync(dir, { recursive: true });
const tmp = DATA_FILE + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
fs.renameSync(tmp, DATA_FILE); // atómico en mismo filesystem
}
function record({ type, provider }) {
const s = load();
if (type === 'sent') s.total_sent++;
else if (type === 'redirected') s.total_redirected++;
else if (type === 'error') s.total_errors++;
else if (type === 'search') s.total_searches++;
if (provider && (type === 'sent' || type === 'redirected')) {
if (!s.by_provider[provider]) s.by_provider[provider] = { sent: 0, redirected: 0 };
if (type === 'sent') s.by_provider[provider].sent++;
if (type === 'redirected') s.by_provider[provider].redirected++;
}
s.updated = new Date().toISOString().slice(0, 10);
save(s);
}
function get() {
return load();
}
module.exports = { record, get };

39
infra/set-relay-credentials.sh Executable file
View file

@ -0,0 +1,39 @@
#!/bin/bash
# =================================================================
# set-relay-credentials.sh — Configura credenciales Brevo en Postfix
# Uso: sudo bash /var/www/resetea.net/infra/set-relay-credentials.sh LOGIN SMTP_KEY
#
# LOGIN = email con que te registraste en Brevo
# SMTP_KEY = clave SMTP de Brevo (SMTP & API → SMTP → Generate key)
# =================================================================
if [[ $EUID -ne 0 ]]; then
echo "ERROR: Ejecuta como root: sudo bash $0 LOGIN SMTP_KEY"
exit 1
fi
if [[ -z "$1" || -z "$2" ]]; then
echo "Uso: sudo bash $0 LOGIN SMTP_KEY"
echo "Ejemplo: sudo bash $0 user@email.com xsmtpsib-abc123..."
exit 1
fi
LOGIN="$1"
SMTP_KEY="$2"
echo "[smtp-relay.brevo.com]:587 ${LOGIN}:${SMTP_KEY}" > /etc/postfix/sasl_passwd
chmod 600 /etc/postfix/sasl_passwd
postmap /etc/postfix/sasl_passwd
echo " → sasl_passwd actualizado"
systemctl restart postfix
echo " → Postfix arrancado"
# Test de envío
echo ""
echo "Haciendo test de envío..."
echo "Test resetea.net SMTP relay $(date)" | sendmail -v -f privacy@resetea.net privacy@resetea.net 2>&1 | head -10
echo ""
echo "Verifica el log: sudo journalctl -u postfix -n 20"
echo "O el log de mail: sudo tail -20 /var/log/mail.log"

183
infra/setup-mail.sh Executable file
View file

@ -0,0 +1,183 @@
#!/bin/bash
# =================================================================
# setup-mail.sh — Postfix (relay Brevo) + opendkim para resetea.net
# Uso: sudo bash /var/www/resetea.net/infra/setup-mail.sh
# =================================================================
set -e
DOMAIN="resetea.net"
SELECTOR="mail"
DKIM_DIR="/etc/opendkim/keys/${DOMAIN}"
NODE_BIN="/home/capitansito/.nvm/versions/node/v18.20.8/bin/node"
APP_DIR="/var/www/resetea.net/api"
APP_USER="capitansito"
# ── Verificaciones previas ────────────────────────────────────────
if [[ $EUID -ne 0 ]]; then
echo "ERROR: Ejecuta como root: sudo bash $0"
exit 1
fi
if [[ ! -f "${NODE_BIN}" ]]; then
echo "ERROR: node no encontrado en ${NODE_BIN}"
echo "Ajusta NODE_BIN al inicio del script."
exit 1
fi
echo ""
echo "╔══════════════════════════════════════════════╗"
echo "║ SETUP MAIL — resetea.net ║"
echo "╚══════════════════════════════════════════════╝"
# ── [1/6] Instalar paquetes ───────────────────────────────────────
echo ""
echo "[1/6] Instalando postfix, opendkim, opendkim-tools..."
DEBIAN_FRONTEND=noninteractive apt-get install -y \
postfix libsasl2-modules opendkim opendkim-tools
# ── [2/6] Configurar Postfix ──────────────────────────────────────
echo ""
echo "[2/6] Configurando Postfix..."
postconf -e "myhostname = ${DOMAIN}"
postconf -e "myorigin = ${DOMAIN}"
postconf -e "inet_interfaces = loopback-only"
postconf -e "mydestination = localhost"
postconf -e "mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128"
# Relay Brevo — credenciales se añaden con set-relay-credentials.sh
postconf -e "relayhost = [smtp-relay.brevo.com]:587"
postconf -e "smtp_sasl_auth_enable = yes"
postconf -e "smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd"
postconf -e "smtp_sasl_security_options = noanonymous"
postconf -e "smtp_tls_security_level = encrypt"
postconf -e "smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt"
postconf -e "smtp_use_tls = yes"
# Integración opendkim via milter
postconf -e "milter_protocol = 6"
postconf -e "milter_default_action = accept"
postconf -e "smtpd_milters = inet:localhost:12301"
postconf -e "non_smtpd_milters = inet:localhost:12301"
# Placeholder de credenciales (vacío hasta ejecutar set-relay-credentials.sh)
if [[ ! -f /etc/postfix/sasl_passwd ]]; then
echo "[smtp-relay.brevo.com]:587 BREVO_LOGIN:BREVO_SMTP_KEY" > /etc/postfix/sasl_passwd
chmod 600 /etc/postfix/sasl_passwd
postmap /etc/postfix/sasl_passwd
fi
# ── [3/6] Generar claves DKIM ─────────────────────────────────────
echo ""
echo "[3/6] Generando claves DKIM (2048 bits)..."
mkdir -p "${DKIM_DIR}"
if [[ -f "${DKIM_DIR}/${SELECTOR}.private" ]]; then
echo " → Clave ya existente, se mantiene (no se regenera)."
else
opendkim-genkey -b 2048 -d "${DOMAIN}" -D "${DKIM_DIR}" -s "${SELECTOR}" -v
echo " → Clave generada en ${DKIM_DIR}/"
fi
chown -R opendkim:opendkim /etc/opendkim/
chmod 711 "${DKIM_DIR}" # traversable pero no listable por otros
chmod 600 "${DKIM_DIR}/${SELECTOR}.private"
chmod 644 "${DKIM_DIR}/${SELECTOR}.txt" # clave pública — legible por el script
# ── [4/6] Configurar opendkim ─────────────────────────────────────
echo ""
echo "[4/6] Configurando opendkim..."
cat > /etc/opendkim.conf << EOF
AutoRestart Yes
AutoRestartRate 10/1h
UMask 002
Syslog yes
SyslogSuccess Yes
LogWhy Yes
Canonicalization relaxed/simple
ExternalIgnoreList refile:/etc/opendkim/TrustedHosts
InternalHosts refile:/etc/opendkim/TrustedHosts
KeyTable refile:/etc/opendkim/KeyTable
SigningTable refile:/etc/opendkim/SigningTable
Mode sv
PidFile /run/opendkim/opendkim.pid
SignatureAlgorithm rsa-sha256
UserID opendkim
Socket inet:12301@localhost
EOF
cat > /etc/opendkim/TrustedHosts << EOF
127.0.0.1
localhost
${DOMAIN}
EOF
cat > /etc/opendkim/KeyTable << EOF
${SELECTOR}._domainkey.${DOMAIN} ${DOMAIN}:${SELECTOR}:${DKIM_DIR}/${SELECTOR}.private
EOF
cat > /etc/opendkim/SigningTable << EOF
*@${DOMAIN} ${SELECTOR}._domainkey.${DOMAIN}
EOF
# ── [5/6] Servicio systemd para resetea backend ───────────────────
echo ""
echo "[5/6] Creando servicio systemd resetea..."
cat > /etc/systemd/system/resetea.service << EOF
[Unit]
Description=RESETEA.NET backend Node.js
After=network.target
[Service]
Type=simple
User=${APP_USER}
WorkingDirectory=${APP_DIR}
ExecStart=${NODE_BIN} app.js
Restart=on-failure
RestartSec=5
EnvironmentFile=${APP_DIR}/.env
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable resetea
systemctl start resetea && echo " → resetea backend arrancado" || echo " ⚠ Error arrancando resetea — revisa: journalctl -u resetea -n 20"
# ── [6/6] Arrancar opendkim y postfix ─────────────────────────────
echo ""
echo "[6/6] Arrancando opendkim y postfix..."
systemctl enable opendkim
systemctl restart opendkim && echo " → opendkim OK" || echo " ⚠ Error en opendkim"
sleep 1
# Postfix NO se arranca hasta que haya credenciales reales en sasl_passwd
echo " → Postfix: esperando credenciales Brevo antes de arrancar."
echo " Ejecuta set-relay-credentials.sh cuando tengas las credenciales."
# ── Resumen final ─────────────────────────────────────────────────
echo ""
echo "════════════════════════════════════════════════════════"
echo " REGISTRO DKIM — añadir en DNS de Gandi:"
echo "────────────────────────────────────────────────────────"
echo " Nombre: ${SELECTOR}._domainkey"
echo " Tipo: TXT"
DKIM_P=$(cat "${DKIM_DIR}/${SELECTOR}.txt" | grep -o '"p=.*"' | tr -d '"' | tr -d ' ')
echo " Valor: v=DKIM1; k=rsa; ${DKIM_P}"
echo ""
echo " (archivo completo en ${DKIM_DIR}/${SELECTOR}.txt)"
echo "════════════════════════════════════════════════════════"
echo ""
echo "SIGUIENTE PASO:"
echo " 1. Crea cuenta gratis en https://app.brevo.com"
echo " 2. Ve a: SMTP & API → SMTP → 'Generate a new SMTP Key'"
echo " 3. Ejecuta:"
echo " sudo bash /var/www/resetea.net/infra/set-relay-credentials.sh TU_EMAIL_BREVO TU_SMTP_KEY"
echo " 4. Añade el registro DKIM de arriba en Gandi"
echo " 5. Ejecuta el managedns.sh setup-mail-dns para SPF y DMARC"
echo ""

View file

@ -0,0 +1,80 @@
CONTEXTO — Tarea pendiente de completar (requiere sudo)
==========================================================
PROBLEMA
--------
El backend Node.js (api/app.js, puerto 8787) no está corriendo
y nginx no tiene el proxy /api/ configurado. Esto causa que:
- El botón "Enviar solicitudes GDPR" del panel principal dé error de conexión
- El egosurfing real (/api/egosearch) tampoco funcione
LO QUE YA ESTÁ HECHO
---------------------
1. Backend preparado: /var/www/resetea.net/api/app.js
2. .env creado: /var/www/resetea.net/api/.env (con SALT generado)
3. Seguridad egosearch mejorada (sanitización, validación URLs, límite tamaño)
4. Config nginx correcta preparada en: /tmp/resetea-nginx.conf
5. Servicio systemd preparado en: /tmp/resetea-api.service
LO QUE FALTA (necesita sudo)
-----------------------------
Comando 1 — Activar proxy nginx:
sudo cp /tmp/resetea-nginx.conf /etc/nginx/sites-enabled/resetea.net && sudo nginx -t && sudo systemctl reload nginx
Comando 2 — Arrancar backend como servicio:
sudo cp /tmp/resetea-api.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now resetea-api
Comando 3 — Verificar:
sudo systemctl status resetea-api
curl -s http://127.0.0.1:8787/api/health
CONTENIDO DE /tmp/resetea-nginx.conf
--------------------------------------
server {
listen 80;
listen [::]:80;
server_name resetea.net;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name resetea.net;
root /var/www/resetea.net/public;
index index.html;
ssl_certificate /etc/letsencrypt/live/resetea.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/resetea.net/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location /api/ {
proxy_pass http://127.0.0.1:8787;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 15s;
proxy_connect_timeout 5s;
}
location / {
try_files $uri $uri/ =404;
}
}
CONTENIDO DE /tmp/resetea-api.service
---------------------------------------
[Unit]
Description=RESETEA.NET API Backend
After=network.target
[Service]
Type=simple
User=capitansito
WorkingDirectory=/var/www/resetea.net/api
ExecStart=/home/capitansito/.nvm/versions/node/v18.20.8/bin/node app.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target

View file

@ -112,6 +112,7 @@
<a class="nav-btn nav-btn--primary" href="concienciacion.html">Concienciación</a>
<a class="nav-btn" href="index.html">Resetea</a>
<a class="nav-btn" href="egosurfing.html">Egosurfing</a>
<a class="nav-btn" href="stats.html">Estadísticas</a>
</nav>
</div>
</header>

View file

@ -377,6 +377,7 @@
<a class="nav-btn" href="tipos.html">Tipos de info</a>
<a class="nav-btn" href="concienciacion.html">Concienciación</a>
<a class="nav-btn nav-btn--primary" href="egosurfing.html">Egosurfing</a>
<a class="nav-btn" href="stats.html">Estadísticas</a>
</nav>
</div>
</header>

1
public/icons/apple.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="#1a1714" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Apple</title><path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"/></svg>

After

Width:  |  Height:  |  Size: 665 B

1
public/icons/discord.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="#1a1714" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Discord</title><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1 @@
<svg fill="#1a1714" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Facebook</title><path d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"/></svg>

After

Width:  |  Height:  |  Size: 557 B

1
public/icons/gmail.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="#1a1714" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Gmail</title><path d="M24 5.457v13.909c0 .904-.732 1.636-1.636 1.636h-3.819V11.73L12 16.64l-6.545-4.91v9.273H1.636A1.636 1.636 0 0 1 0 19.366V5.457c0-2.023 2.309-3.178 3.927-1.964L5.455 4.64 12 9.548l6.545-4.91 1.528-1.145C21.69 2.28 24 3.434 24 5.457z"/></svg>

After

Width:  |  Height:  |  Size: 354 B

1
public/icons/google.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="#1a1714" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google</title><path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"/></svg>

After

Width:  |  Height:  |  Size: 472 B

View file

@ -0,0 +1 @@
<svg fill="#1a1714" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Have I Been Pwned</title><path d="M1.89 3.872 0 13.598h4.7l1.889-9.726ZM7.171 8.56l-.98 5.038h4.7l.98-5.038Zm5.936 1.306-.723 3.732h4.7l.722-3.732Zm6.192 0-.723 3.732h4.7L24 9.866ZM5.912 15.09l-.979 5.038h4.7l.98-5.038z"/></svg>

After

Width:  |  Height:  |  Size: 321 B

View file

@ -0,0 +1 @@
<svg fill="#1a1714" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Instagram</title><path d="M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

1
public/icons/netflix.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="#1a1714" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Netflix</title><path d="m5.398 0 8.348 23.602c2.346.059 4.856.398 4.856.398L10.113 0H5.398zm8.489 0v9.172l4.715 13.33V0h-4.715zM5.398 1.5V24c1.873-.225 2.81-.312 4.715-.398V14.83L5.398 1.5z"/></svg>

After

Width:  |  Height:  |  Size: 291 B

View file

@ -0,0 +1 @@
<svg fill="#1a1714" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Pinterest</title><path d="M12.017 0C5.396 0 .029 5.367.029 11.987c0 5.079 3.158 9.417 7.618 11.162-.105-.949-.199-2.403.041-3.439.219-.937 1.406-5.957 1.406-5.957s-.359-.72-.359-1.781c0-1.663.967-2.911 2.168-2.911 1.024 0 1.518.769 1.518 1.688 0 1.029-.653 2.567-.992 3.992-.285 1.193.6 2.165 1.775 2.165 2.128 0 3.768-2.245 3.768-5.487 0-2.861-2.063-4.869-5.008-4.869-3.41 0-5.409 2.562-5.409 5.199 0 1.033.394 2.143.889 2.741.099.12.112.225.085.345-.09.375-.293 1.199-.334 1.363-.053.225-.172.271-.401.165-1.495-.69-2.433-2.878-2.433-4.646 0-3.776 2.748-7.252 7.92-7.252 4.158 0 7.392 2.967 7.392 6.923 0 4.135-2.607 7.462-6.233 7.462-1.214 0-2.354-.629-2.758-1.379l-.749 2.848c-.269 1.045-1.004 2.352-1.498 3.146 1.123.345 2.306.535 3.55.535 6.607 0 11.985-5.365 11.985-11.987C23.97 5.39 18.592.026 11.985.026L12.017 0z"/></svg>

After

Width:  |  Height:  |  Size: 924 B

1
public/icons/reddit.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="#1a1714" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Reddit</title><path d="M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1 @@
<svg fill="#1a1714" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Snapchat</title><path d="M12.206.793c.99 0 4.347.276 5.93 3.821.529 1.193.403 3.219.299 4.847l-.003.06c-.012.18-.022.345-.03.51.075.045.203.09.401.09.3-.016.659-.12 1.033-.301.165-.088.344-.104.464-.104.182 0 .359.029.509.09.45.149.734.479.734.838.015.449-.39.839-1.213 1.168-.089.029-.209.075-.344.119-.45.135-1.139.36-1.333.81-.09.224-.061.524.12.868l.015.015c.06.136 1.526 3.475 4.791 4.014.255.044.435.27.42.509 0 .075-.015.149-.045.225-.24.569-1.273.988-3.146 1.271-.059.091-.12.375-.164.57-.029.179-.074.36-.134.553-.076.271-.27.405-.555.405h-.03c-.135 0-.313-.031-.538-.074-.36-.075-.765-.135-1.273-.135-.3 0-.599.015-.913.074-.6.104-1.123.464-1.723.884-.853.599-1.826 1.288-3.294 1.288-.06 0-.119-.015-.18-.015h-.149c-1.468 0-2.427-.675-3.279-1.288-.599-.42-1.107-.779-1.707-.884-.314-.045-.629-.074-.928-.074-.54 0-.958.089-1.272.149-.211.043-.391.074-.54.074-.374 0-.523-.224-.583-.42-.061-.192-.09-.389-.135-.567-.046-.181-.105-.494-.166-.57-1.918-.222-2.95-.642-3.189-1.226-.031-.063-.052-.15-.055-.225-.015-.243.165-.465.42-.509 3.264-.54 4.73-3.879 4.791-4.02l.016-.029c.18-.345.224-.645.119-.869-.195-.434-.884-.658-1.332-.809-.121-.029-.24-.074-.346-.119-1.107-.435-1.257-.93-1.197-1.273.09-.479.674-.793 1.168-.793.146 0 .27.029.383.074.42.194.789.3 1.104.3.234 0 .384-.06.465-.105l-.046-.569c-.098-1.626-.225-3.651.307-4.837C7.392 1.077 10.739.807 11.727.807l.419-.015h.06z"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

1
public/icons/spotify.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="#1a1714" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Spotify</title><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>

After

Width:  |  Height:  |  Size: 712 B

View file

@ -0,0 +1 @@
<svg fill="#1a1714" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Telegram</title><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>

After

Width:  |  Height:  |  Size: 757 B

1
public/icons/tiktok.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="#1a1714" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>TikTok</title><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg>

After

Width:  |  Height:  |  Size: 722 B

1
public/icons/twitch.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="#1a1714" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Twitch</title><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z"/></svg>

After

Width:  |  Height:  |  Size: 307 B

View file

@ -0,0 +1 @@
<svg fill="#1a1714" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>WhatsApp</title><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

1
public/icons/x.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="#1a1714" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>X</title><path d="M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z"/></svg>

After

Width:  |  Height:  |  Size: 314 B

1
public/icons/youtube.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="#1a1714" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>YouTube</title><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>

After

Width:  |  Height:  |  Size: 474 B

View file

@ -36,8 +36,8 @@
--caoba-mid: #a0522d; /* siena */
--caoba-lt: #f0e6df; /* caoba muy claro */
--sage: #4a7c59; /* verde salvia */
--sage-lt: #e8f2eb; /* verde salvia muy claro */
--sage: #1a7a4a; /* verde irlandés */
--sage-lt: #e0f4ea; /* verde irlandés muy claro */
--lime: #96c21a; /* verde lima */
--lime-lt: #f4fae0; /* verde lima muy claro */
@ -379,14 +379,199 @@ a:hover { color: var(--caoba-mid); text-decoration: underline; }
PANEL (checklist grid)
*/
.panel {
padding: 4rem 0;
padding: 3.5rem 0 4rem;
background: var(--bg);
border-top: 1px solid var(--border);
}
.panel-title {
font-size: clamp(1.3rem, 3vw, 1.9rem);
margin-bottom: 0.4rem;
}
.section-desc {
color: var(--muted);
margin-bottom: 2rem;
font-size: 0.95rem;
margin-bottom: 0;
font-size: 0.93rem;
max-width: 72ch;
line-height: 1.5;
}
/* Barra de progreso compacta */
.progress-track {
display: flex;
align-items: center;
gap: 1rem;
margin: 1.2rem 0 1.8rem;
}
.progress-track .progress-bar { flex: 1; margin: 0; }
.progress-track .progress-label { white-space: nowrap; }
/* ── Secciones del panel ── */
.panel-global-header {
margin-bottom: 0.5rem;
}
.panel-title {
font-size: clamp(1.3rem, 3vw, 1.9rem);
margin-bottom: 0.4rem;
}
.panel-section {
background: var(--surface);
border: 1.5px solid var(--border);
border-radius: 18px;
padding: 1.6rem 1.8rem 1.4rem;
box-shadow: var(--shadow-sm);
margin-bottom: 1.4rem;
}
.panel-section:last-child { margin-bottom: 0; }
.panel-section-header {
display: flex;
align-items: center;
gap: 0.9rem;
margin-bottom: 1.2rem;
flex-wrap: wrap;
}
.panel-section-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 1.2rem;
border-radius: 20px;
background: var(--caoba);
color: #fff;
font-family: 'Recion', 'Georgia', serif;
font-size: 1.05rem;
font-weight: 400;
letter-spacing: 0.04em;
flex-shrink: 0;
}
.btn-mark-section {
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-size: 0.78rem;
font-weight: 600;
padding: 0.3rem 0.85rem;
border-radius: 20px;
border: 1.5px solid var(--sage);
background: var(--sage-lt);
color: var(--sage);
cursor: pointer;
transition: background 150ms ease, color 150ms ease;
white-space: nowrap;
margin-left: auto;
}
.btn-mark-section:hover {
background: var(--sage);
color: #fff;
}
.panel-section-footer {
margin-top: 1.1rem;
display: flex;
justify-content: flex-end;
}
.btn--section-send {
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-size: 0.88rem;
font-weight: 600;
padding: 0.55rem 1.4rem;
}
/* ── Grid de cards ── */
.pitem-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.9rem;
}
/* ── Card individual ── */
.pitem-card {
background: var(--surface);
border: 1.5px solid var(--border);
border-radius: 14px;
padding: 1.1rem 1.1rem 0.9rem;
box-shadow: 0 2px 0 0 var(--border-dark);
display: flex;
flex-direction: column;
gap: 0.75rem;
transition:
border-color 150ms ease,
background 150ms ease,
box-shadow 150ms ease;
}
.pitem-card:hover {
border-color: #a8d4bc;
box-shadow: 0 3px 0 0 #a8d4bc;
}
/* Checked state — verde irlandés */
.pitem-card:has(.progress-cb:checked) {
background: var(--sage-lt);
border-color: var(--sage);
box-shadow: 0 2px 0 0 #0f5c34;
}
/* Label del checkbox */
.pitem-label {
display: flex;
align-items: flex-start;
gap: 0.55rem;
cursor: pointer;
line-height: 1.3;
}
.pitem-label input[type="checkbox"] {
accent-color: var(--sage);
width: 17px;
height: 17px;
flex-shrink: 0;
margin-top: 0.1rem;
cursor: pointer;
}
.pitem-icon {
width: 18px;
height: 18px;
object-fit: contain;
flex-shrink: 0;
vertical-align: middle;
display: inline-block;
}
.pitem-card:has(.progress-cb:checked) .pitem-icon { filter: brightness(0) saturate(100%) invert(30%) sepia(80%) saturate(400%) hue-rotate(110deg); }
.pitem-name {
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-size: 0.97rem;
font-weight: 600;
color: var(--text);
transition: color 150ms ease;
display: flex;
align-items: center;
gap: 0.45rem;
}
.pitem-card:has(.progress-cb:checked) .pitem-name {
text-decoration: line-through;
color: var(--sage);
}
/* ── Action links dentro de card ── */
.pitem-card .actions {
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
/* ── Responsive del panel ── */
@media (max-width: 900px) {
.pitem-grid { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
}
@media (max-width: 600px) {
.pitem-grid { grid-template-columns: 1fr 1fr; }
.progress-track { flex-direction: column; align-items: flex-start; gap: 0.4rem; }
.progress-track .progress-bar { width: 100%; }
}
.grid {
@ -452,14 +637,14 @@ a:hover { color: var(--caoba-mid); text-decoration: underline; }
.actions a {
display: inline-block;
padding: 0.18rem 0.55rem;
font-size: 0.72rem;
padding: 0.35rem 0.85rem;
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.03em;
border-radius: 5px;
letter-spacing: 0.02em;
border-radius: 8px;
background: var(--surface2);
color: var(--caoba);
border: 1px solid var(--border);
border: 1.5px solid var(--border);
transition: background 120ms ease, border-color 120ms ease, transform 100ms ease, box-shadow 100ms ease;
text-decoration: none;
box-shadow: 0 2px 0 0 var(--border-dark);
@ -488,8 +673,8 @@ a:hover { color: var(--caoba-mid); text-decoration: underline; }
.actions a[href*="privacy.apple"] {
background: var(--sage-lt);
color: var(--sage);
border-color: #c0d9c8;
box-shadow: 0 2px 0 0 #c0d9c8;
border-color: #a8d4bc;
box-shadow: 0 2px 0 0 #a8d4bc;
}
.actions a[href*="download"]:hover,
.actions a[href*="data-and-privacy"]:hover,
@ -619,12 +804,64 @@ a:hover { color: var(--caoba-mid); text-decoration: underline; }
.main-tagline {
font-family: 'Recion', 'Georgia', serif;
font-size: clamp(1.1rem, 2.8vw, 1.7rem);
font-size: clamp(1.05rem, 2.6vw, 1.6rem);
color: var(--acid);
letter-spacing: 0.06em;
letter-spacing: 0.05em;
font-weight: normal;
margin-bottom: 2.2rem;
margin-bottom: 1.4rem;
text-shadow: 0 0 20px rgba(200,255,0,0.25);
line-height: 1.4;
}
/* ── Carrusel de citas ── */
.quotes-strip {
width: 100%;
max-width: 680px;
margin: 0 auto 2rem;
min-height: 4.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.quote-carousel {
position: relative;
width: 100%;
text-align: center;
}
.hero-quote {
display: none;
flex-direction: column;
gap: 0.35rem;
font-size: clamp(0.78rem, 1.6vw, 0.92rem);
color: var(--muted);
font-style: italic;
line-height: 1.55;
padding: 0 0.5rem;
animation: quoteIn 600ms ease;
}
.hero-quote.active { display: flex; }
@keyframes quoteIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.q-mark {
font-family: 'Recion', Georgia, serif;
font-size: 1.1em;
color: var(--sage);
font-style: normal;
line-height: 1;
}
.hero-quote cite {
font-size: 0.75rem;
font-style: normal;
font-weight: 600;
color: var(--subtle);
letter-spacing: 0.03em;
}
.landing-nav {
@ -698,37 +935,80 @@ a:hover { color: var(--caoba-mid); text-decoration: underline; }
}
.erase-form-wrap {
max-width: 780px;
max-width: 1100px;
margin: 0 auto;
}
.form-header {
text-align: center;
margin-bottom: 1.8rem;
/* ── Layout dos columnas ── */
.form-two-col {
display: grid;
grid-template-columns: 340px 1fr;
gap: 2.5rem;
align-items: start;
}
.form-header h2 { margin-bottom: 0.4rem; font-size: clamp(1.4rem, 3vw, 1.9rem); }
.form-header p { color: var(--muted); font-size: 1.05rem; }
.email-row {
display: flex;
.form-col-email {
background: var(--surface);
border: 1.5px solid var(--border);
border-radius: 18px;
padding: 2rem 1.8rem;
box-shadow: var(--shadow-sm);
position: sticky;
top: 1.5rem;
}
.form-col-networks {
background: var(--surface);
border: 1.5px solid var(--border);
border-radius: 18px;
padding: 2rem 1.8rem;
box-shadow: var(--shadow-sm);
}
/* ── Step badge ── */
.form-step-badge {
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 2rem;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--caoba);
color: #fff;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-size: 0.95rem;
font-weight: 700;
margin-bottom: 0.75rem;
}
.form-col-title {
font-size: clamp(1.2rem, 2.5vw, 1.55rem);
margin-bottom: 0.5rem;
line-height: 1.2;
}
.form-col-sub {
font-size: 0.9rem;
color: var(--muted);
margin-bottom: 1.2rem;
line-height: 1.5;
}
/* ── Email input ── */
.email-input {
width: 100%;
max-width: 500px;
padding: 0.9rem 1.2rem;
font-size: 1.1rem;
padding: 0.9rem 1.1rem;
font-size: 1.05rem;
border: 2px solid var(--border);
border-radius: 12px;
background: var(--surface);
background: var(--surface2);
color: var(--text);
outline: none;
box-shadow: var(--shadow-sm);
transition: border-color 150ms ease, box-shadow 150ms ease;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
letter-spacing: 0.01em;
box-sizing: border-box;
}
.email-input:focus {
border-color: var(--caoba);
@ -736,134 +1016,229 @@ a:hover { color: var(--caoba-mid); text-decoration: underline; }
}
.email-input::placeholder { color: var(--subtle); }
.networks-label {
font-family: 'Recion', 'Georgia', serif;
font-size: 1.5rem;
color: var(--text);
margin-bottom: 1.2rem;
letter-spacing: 0.03em;
text-align: center;
}
.networks-section { margin-bottom: 2rem; }
.networks-sublabel {
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 0.7rem;
margin-top: 1.4rem;
text-align: center;
}
.networks-sublabel:first-child { margin-top: 0; }
.networks-sublabel--manual { color: var(--text); }
.networks-grid {
.form-privacy-list {
list-style: none;
padding: 0;
margin: 1.1rem 0 0;
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
margin-bottom: 0.25rem;
justify-content: center;
flex-direction: column;
gap: 0.4rem;
}
.form-privacy-list li {
font-size: 0.82rem;
color: var(--muted);
line-height: 1.4;
}
/* Chip base — fuente de título, grande */
.net-chip {
font-family: 'Recion', 'Georgia', serif;
font-size: 1.1rem;
letter-spacing: 0.04em;
padding: 0.7rem 1.45rem;
border-radius: 12px;
/* ── Networks header ── */
.form-col-networks-header {
display: flex;
align-items: center;
gap: 0.8rem;
flex-wrap: wrap;
margin-bottom: 1.2rem;
}
.form-col-networks-header .form-col-title { margin-bottom: 0; flex: 1; }
.form-col-networks-header .form-step-badge { margin-bottom: 0; flex-shrink: 0; }
.btn-select-all {
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-size: 0.8rem;
font-weight: 600;
padding: 0.35rem 0.9rem;
border-radius: 20px;
border: 1.5px solid var(--sage);
background: var(--sage-lt);
color: var(--sage);
cursor: pointer;
background: var(--surface);
transition: background 150ms ease, color 150ms ease;
white-space: nowrap;
flex-shrink: 0;
}
.btn-select-all:hover {
background: var(--sage);
color: #fff;
}
/* ── Group labels ── */
.networks-group-label {
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--sage);
margin: 0 0 0.6rem;
}
.networks-group-label--manual {
color: var(--muted);
margin-top: 1.4rem;
}
/* ── Grid de botones ── */
.networks-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 0.5rem;
margin-bottom: 0.2rem;
}
/* ── Logos oficiales dentro de chips ── */
.chip-icon {
width: 17px;
height: 17px;
object-fit: contain;
flex-shrink: 0;
vertical-align: middle;
display: inline-block;
}
.net-chip.selected .chip-icon { filter: brightness(0) invert(1); }
/* ── Botones de red — grandes, legibles ── */
.net-chip {
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-size: 0.97rem;
font-weight: 500;
padding: 0.7rem 0.6rem;
border-radius: 10px;
cursor: pointer;
background: var(--surface2);
color: var(--text);
border: 1.5px solid var(--border);
box-shadow: 0 3px 0 0 var(--border-dark), 0 4px 10px rgba(26,23,20,0.07);
box-shadow: 0 2px 0 0 var(--border-dark);
transition:
transform 130ms cubic-bezier(.2,.8,.4,1),
box-shadow 130ms ease,
background 130ms ease,
border-color 130ms ease,
color 130ms ease;
transform 120ms cubic-bezier(.2,.8,.4,1),
box-shadow 120ms ease,
background 120ms ease,
border-color 120ms ease,
color 120ms ease;
user-select: none;
line-height: 1.2;
-webkit-font-smoothing: subpixel-antialiased;
line-height: 1.3;
text-align: center;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
}
.net-chip:hover {
border-color: var(--acid);
color: var(--acid-dark);
background: var(--acid-lt);
transform: translateY(-3px);
box-shadow: 0 6px 0 0 #9dcc00, 0 8px 20px rgba(200,255,0,0.20);
color: var(--acid-dark);
transform: translateY(-2px);
box-shadow: 0 4px 0 0 #9dcc00, 0 6px 16px rgba(200,255,0,0.18);
}
.net-chip:hover .chip-icon { filter: brightness(0) saturate(100%) invert(40%) sepia(90%) saturate(600%) hue-rotate(40deg); }
.net-chip:active {
transform: translateY(1px);
box-shadow: 0 1px 0 0 var(--border-dark);
}
/* Auto-GDPR seleccionado — verde irlandés */
.net-chip.selected {
background: var(--caoba);
background: var(--sage);
color: #fff;
border-color: #5c2d1e;
box-shadow: 0 3px 0 0 #5c2d1e, 0 4px 12px rgba(123,63,46,0.25);
border-color: #0f5c34;
box-shadow: 0 3px 0 0 #0f5c34, 0 4px 12px rgba(26,122,74,0.28);
}
.net-chip.selected:hover {
background: var(--caoba-mid);
background: #168a52;
color: #fff;
border-color: var(--acid);
box-shadow: 0 6px 0 0 #9dcc00, 0 8px 20px rgba(200,255,0,0.25);
transform: translateY(-3px);
box-shadow: 0 5px 0 0 #9dcc00, 0 7px 18px rgba(200,255,0,0.22);
transform: translateY(-2px);
}
/* Chips manuales — texto negro, hover ácido */
/* Chips manuales — acción directa, no seleccionables */
.net-chip--manual {
background: var(--surface);
color: var(--text);
color: var(--muted);
border-color: var(--border);
box-shadow: 0 3px 0 0 var(--border-dark), 0 4px 10px rgba(26,23,20,0.06);
border-style: dashed;
}
.net-chip--manual:hover {
background: var(--acid-lt);
border-color: var(--acid);
color: var(--acid-dark);
box-shadow: 0 6px 0 0 #9dcc00, 0 8px 20px rgba(200,255,0,0.20);
transform: translateY(-3px);
background: var(--surface2);
border-color: var(--sage);
border-style: solid;
color: var(--sage);
transform: translateY(-2px);
box-shadow: 0 3px 0 0 #0f5c34;
}
.net-chip--manual.selected {
background: var(--text);
color: var(--acid);
border-color: var(--text);
box-shadow: 0 3px 0 0 #000, 0 4px 12px rgba(26,23,20,0.25);
.net-chip--manual:hover .chip-icon { filter: brightness(0) saturate(100%) invert(30%) sepia(80%) saturate(400%) hue-rotate(110deg); }
.net-chip--manual:active { transform: translateY(0); }
/* Flash al abrir enlace manual */
@keyframes chipFlash {
0% { background: var(--sage-lt); border-color: var(--sage); }
100% { background: var(--surface); border-color: var(--border); }
}
.net-chip--manual.selected:hover {
border-color: var(--acid);
box-shadow: 0 6px 0 0 #9dcc00, 0 8px 20px rgba(200,255,0,0.25);
transform: translateY(-3px);
.chip--opened { animation: chipFlash 1.2s ease forwards; }
.chip-arrow {
font-size: 0.75rem;
opacity: 0.5;
flex-shrink: 0;
}
/* Footer del form */
.form-footer-row {
/* Nota explicativa sección manual */
.networks-manual-note {
font-size: 0.78rem;
color: var(--subtle);
margin: -0.2rem 0 0.6rem;
font-style: italic;
line-height: 1.4;
}
/* ── Contador ── */
.form-counter {
margin-top: 1.1rem;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-size: 0.88rem;
font-weight: 600;
color: var(--sage);
text-align: right;
letter-spacing: 0.01em;
}
/* ── Fila de envío ── */
.form-send-row {
margin-top: 2rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.8rem;
gap: 0.9rem;
}
.btn--lg {
padding: 0.85rem 2rem;
font-size: 1rem;
.btn--send-main {
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-size: 1.15rem;
font-weight: 700;
padding: 1rem 2.8rem;
border-radius: 14px;
letter-spacing: 0.01em;
min-width: 280px;
justify-content: center;
}
.form-note {
font-size: 0.77rem;
color: var(--subtle);
text-align: center;
max-width: 48ch;
max-width: 52ch;
line-height: 1.55;
}
/* ── Responsive: una columna en móvil ── */
@media (max-width: 720px) {
.form-two-col {
grid-template-columns: 1fr;
gap: 1.2rem;
}
.form-col-email { position: static; }
.networks-grid { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); }
.btn--send-main { width: 100%; min-width: 0; }
}
/* Resultados */
.results-wrap {
margin-top: 1.5rem;
@ -886,7 +1261,7 @@ a:hover { color: var(--caoba-mid); text-decoration: underline; }
border-radius: 3px;
}
.result-ok { background: var(--sage-lt); color: var(--sage); border: 1px solid #c0d9c8; }
.result-ok { background: var(--sage-lt); color: var(--sage); border: 1px solid #a8d4bc; }
.result-form,
.result-manual { background: var(--caoba-lt); color: var(--caoba); border: 1px solid #e0c4b8; }
.result-error { background: #fef2f2; color: #c0392b; border: 1px solid #f5c6c6; }

View file

@ -3,96 +3,10 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RESETEA.NET · Reduce tu huella digital</title>
<title>RESETEA.NET · La información es poder, quitémosles poder</title>
<meta name="description"
content="Envía cartas GDPR y elimina tus datos de redes sociales, buscadores y data brokers. Sin guardar información. Enlace oficiales y textos RGPD.">
<link rel="stylesheet" href="index.css">
<style>
/* ── Panel tabs ── */
.ptabs-nav {
display: flex;
gap: 0.3rem;
border-bottom: 2px solid var(--border);
padding-bottom: 0;
flex-wrap: wrap;
margin-bottom: 0;
}
.ptab {
padding: 0.5rem 1.05rem;
border: 1px solid transparent;
border-bottom: none;
border-radius: 8px 8px 0 0;
background: none;
color: var(--muted);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: all 120ms ease;
font-family: system-ui, sans-serif;
white-space: nowrap;
position: relative;
bottom: -2px;
letter-spacing: 0.01em;
}
.ptab:hover { background: var(--surface2); color: var(--text); }
.ptab.active {
background: var(--bg);
border-color: var(--border);
border-bottom-color: var(--bg);
color: var(--caoba);
}
.ptab-pane { display: none; padding-top: 1.4rem; }
.ptab-pane.active { display: block; }
/* ── Items como grid horizontal de tarjetas ── */
.pitem-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(235px, 1fr));
gap: 0.85rem;
}
.pitem-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.9rem 1rem;
box-shadow: var(--shadow-sm);
transition: box-shadow 180ms ease, transform 180ms ease;
}
.pitem-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.pitem-card label {
display: flex;
align-items: center;
gap: 0.45rem;
cursor: pointer;
font-size: 0.88rem;
font-weight: 600;
color: var(--text);
margin-bottom: 0.55rem;
}
.pitem-card label input[type="checkbox"] {
accent-color: var(--caoba);
width: 15px; height: 15px;
flex-shrink: 0; cursor: pointer;
}
.pitem-card label:has(input:checked) {
text-decoration: line-through;
color: var(--subtle);
}
.pitem-card .actions {
margin-left: 1.4rem;
margin-top: 0;
}
@media (max-width: 600px) {
.pitem-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 420px) {
.pitem-grid { grid-template-columns: 1fr; }
.ptab { font-size: 0.72rem; padding: 0.4rem 0.7rem; }
}
</style>
</head>
<body>
@ -106,12 +20,39 @@
<section class="hero-landing">
<div class="container hero-landing-inner">
<h1 class="main-title">RESETEA</h1>
<p class="main-tagline">Reduce tu huella digital.</p>
<p class="main-tagline">La información es poder.<br>Quitémosles poder.</p>
<div class="quotes-strip">
<div class="quote-carousel" id="quote-carousel">
<blockquote class="hero-quote active">
<span class="q-mark">"</span>Matamos gente basándonos en metadatos.<span class="q-mark">"</span>
<cite>— Gen. Michael Hayden, ex-director de la NSA</cite>
</blockquote>
<blockquote class="hero-quote">
<span class="q-mark">"</span>Decir que la privacidad no te importa porque no tienes nada que ocultar es como decir que la libertad de expresión no importa porque no tienes nada que decir.<span class="q-mark">"</span>
<cite>— Edward Snowden</cite>
</blockquote>
<blockquote class="hero-quote">
<span class="q-mark">"</span>El que controla los datos del presente controla el pasado. El que controla el pasado controla el futuro.<span class="q-mark">"</span>
<cite>— Inspirado en George Orwell, <em>1984</em></cite>
</blockquote>
<blockquote class="hero-quote">
<span class="q-mark">"</span>No somos los clientes de estas empresas. Somos el producto.<span class="q-mark">"</span>
<cite>— Richard Serra &amp; Carlota Fay Schoolman, 1973</cite>
</blockquote>
<blockquote class="hero-quote">
<span class="q-mark">"</span>La vigilancia es el modelo de negocio de internet.<span class="q-mark">"</span>
<cite>— Bruce Schneier, criptógrafo y especialista en seguridad</cite>
</blockquote>
</div>
</div>
<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 landing-nav-btn--highlight" href="plantillas.html">Plantillas GDPR</a>
<a class="landing-nav-btn" href="stats.html">Estadísticas</a>
</div>
</div>
</section>
@ -123,65 +64,80 @@
<div class="container">
<div class="erase-form-wrap">
<div class="form-header">
<h2>Introduce tu correo electrónico</h2>
<p>Selecciona las redes y enviamos la carta GDPR por ti. Sin guardar nada.</p>
</div>
<div class="form-two-col">
<div class="email-row">
<input type="email" id="gdpr-email" class="email-input"
placeholder="tu@correo.com" autocomplete="email">
</div>
<p class="networks-label">Selecciona las redes que quieres eliminar:</p>
<div class="networks-section">
<p class="networks-sublabel">Envío automático de carta GDPR</p>
<div class="networks-grid" id="networks-grid">
<button class="net-chip" data-provider="instagram" data-type="api">Instagram</button>
<button class="net-chip" data-provider="facebook" data-type="api">Facebook</button>
<button class="net-chip" data-provider="twitter_x" data-type="api">X / Twitter</button>
<button class="net-chip" data-provider="linkedin" data-type="api">LinkedIn</button>
<button class="net-chip" data-provider="tiktok" data-type="api">TikTok</button>
<button class="net-chip" data-provider="snapchat" data-type="api">Snapchat</button>
<button class="net-chip" data-provider="discord" data-type="api">Discord</button>
<button class="net-chip" data-provider="reddit" data-type="api">Reddit</button>
<button class="net-chip" data-provider="microsoft" data-type="api">Microsoft</button>
<button class="net-chip" data-provider="apple" data-type="api">Apple</button>
<button class="net-chip" data-provider="google"
data-type="form"
data-form-url="https://support.google.com/policies/contact/sar_data_protection">Google</button>
<button class="net-chip" data-provider="amazon"
data-type="form"
data-form-url="https://www.amazon.es/hz/contact-us/privacy-disclosure/">Amazon</button>
<!-- ── Columna izquierda: email ── -->
<div class="form-col-email">
<div class="form-step-badge">1</div>
<h2 class="form-col-title">Tu correo electrónico</h2>
<p class="form-col-sub">Solo lo usamos para enviarte la carta GDPR. No lo guardamos ni lo compartimos.</p>
<input type="email" id="gdpr-email" class="email-input"
placeholder="tu@correo.com" autocomplete="email">
<ul class="form-privacy-list">
<li>🔒 Sin cookies ni tracking</li>
<li>🗑️ No almacenamos tu correo</li>
<li>✉️ Carta enviada al DPO directamente</li>
</ul>
</div>
<p class="networks-sublabel networks-sublabel--manual">Acceso rápido — acción manual</p>
<div class="networks-grid">
<button class="net-chip net-chip--manual" data-provider="whatsapp"
data-type="link" data-link="https://faq.whatsapp.com/1306557496340600">WhatsApp</button>
<button class="net-chip net-chip--manual" data-provider="telegram"
data-type="link" data-link="https://my.telegram.org/auth">Telegram</button>
<button class="net-chip net-chip--manual" data-provider="spotify"
data-type="link" data-link="https://support.spotify.com/es/article/close-account/">Spotify</button>
<button class="net-chip net-chip--manual" data-provider="youtube"
data-type="link" data-link="https://support.google.com/youtube/answer/55759">YouTube</button>
<button class="net-chip net-chip--manual" data-provider="netflix"
data-type="link" data-link="https://help.netflix.com/es/node/100624">Netflix</button>
<button class="net-chip net-chip--manual" data-provider="twitch"
data-type="link" data-link="https://www.twitch.tv/user/delete-account">Twitch</button>
<button class="net-chip net-chip--manual" data-provider="pinterest"
data-type="link" data-link="https://help.pinterest.com/es/article/deactivate-or-close-your-account">Pinterest</button>
<!-- ── Columna derecha: redes ── -->
<div class="form-col-networks">
<div class="form-col-networks-header">
<div class="form-step-badge">2</div>
<h2 class="form-col-title">Elige las redes</h2>
<button class="btn-select-all" id="btn-select-all" type="button">Seleccionar todas</button>
</div>
<p class="networks-group-label">Envío automático de carta GDPR</p>
<div class="networks-grid" id="networks-grid">
<button class="net-chip" data-provider="instagram" data-type="api"><img class="chip-icon" src="https://cdn.simpleicons.org/instagram/1a1714" alt=""><span class="chip-label">Instagram</span></button>
<button class="net-chip" data-provider="facebook" data-type="api"><img class="chip-icon" src="https://cdn.simpleicons.org/facebook/1a1714" alt=""><span class="chip-label">Facebook</span></button>
<button class="net-chip" data-provider="twitter_x" data-type="api"><img class="chip-icon" src="https://cdn.simpleicons.org/x/1a1714" alt=""><span class="chip-label">X / Twitter</span></button>
<button class="net-chip" data-provider="linkedin" data-type="api"><img class="chip-icon" src="https://cdn.simpleicons.org/linkedin/1a1714" alt=""><span class="chip-label">LinkedIn</span></button>
<button class="net-chip" data-provider="tiktok" data-type="api"><img class="chip-icon" src="https://cdn.simpleicons.org/tiktok/1a1714" alt=""><span class="chip-label">TikTok</span></button>
<button class="net-chip" data-provider="snapchat" data-type="api"><img class="chip-icon" src="https://cdn.simpleicons.org/snapchat/1a1714" alt=""><span class="chip-label">Snapchat</span></button>
<button class="net-chip" data-provider="discord" data-type="api"><img class="chip-icon" src="https://cdn.simpleicons.org/discord/1a1714" alt=""><span class="chip-label">Discord</span></button>
<button class="net-chip" data-provider="reddit" data-type="api"><img class="chip-icon" src="https://cdn.simpleicons.org/reddit/1a1714" alt=""><span class="chip-label">Reddit</span></button>
<button class="net-chip" data-provider="microsoft" data-type="api"><img class="chip-icon" src="https://cdn.simpleicons.org/microsoft/1a1714" alt=""><span class="chip-label">Microsoft</span></button>
<button class="net-chip" data-provider="apple" data-type="api"><img class="chip-icon" src="https://cdn.simpleicons.org/apple/1a1714" alt=""><span class="chip-label">Apple</span></button>
<button class="net-chip" data-provider="google" data-type="form"
data-form-url="https://support.google.com/policies/contact/sar_data_protection"><img class="chip-icon" src="https://cdn.simpleicons.org/google/1a1714" alt=""><span class="chip-label">Google</span></button>
<button class="net-chip" data-provider="amazon" data-type="form"
data-form-url="https://www.amazon.es/hz/contact-us/privacy-disclosure/"><img class="chip-icon" src="https://cdn.simpleicons.org/amazon/1a1714" alt=""><span class="chip-label">Amazon</span></button>
</div>
<p class="networks-group-label networks-group-label--manual">Acceso directo — clic abre el enlace oficial</p>
<p class="networks-manual-note">Estas plataformas no permiten solicitud automática. Clic en el botón abre su página oficial directamente.</p>
<div class="networks-grid networks-grid--manual">
<button class="net-chip net-chip--manual" data-provider="whatsapp"
data-link="https://faq.whatsapp.com/1306557496340600"><img class="chip-icon" src="https://cdn.simpleicons.org/whatsapp/1a1714" alt=""><span class="chip-label">WhatsApp</span> <span class="chip-arrow"></span></button>
<button class="net-chip net-chip--manual" data-provider="telegram"
data-link="https://my.telegram.org/auth"><img class="chip-icon" src="https://cdn.simpleicons.org/telegram/1a1714" alt=""><span class="chip-label">Telegram</span> <span class="chip-arrow"></span></button>
<button class="net-chip net-chip--manual" data-provider="spotify"
data-link="https://support.spotify.com/es/article/close-account/"><img class="chip-icon" src="https://cdn.simpleicons.org/spotify/1a1714" alt=""><span class="chip-label">Spotify</span> <span class="chip-arrow"></span></button>
<button class="net-chip net-chip--manual" data-provider="youtube"
data-link="https://support.google.com/youtube/answer/55759"><img class="chip-icon" src="https://cdn.simpleicons.org/youtube/1a1714" alt=""><span class="chip-label">YouTube</span> <span class="chip-arrow"></span></button>
<button class="net-chip net-chip--manual" data-provider="netflix"
data-link="https://help.netflix.com/es/node/100624"><img class="chip-icon" src="https://cdn.simpleicons.org/netflix/1a1714" alt=""><span class="chip-label">Netflix</span> <span class="chip-arrow"></span></button>
<button class="net-chip net-chip--manual" data-provider="twitch"
data-link="https://www.twitch.tv/user/delete-account"><img class="chip-icon" src="https://cdn.simpleicons.org/twitch/1a1714" alt=""><span class="chip-label">Twitch</span> <span class="chip-arrow"></span></button>
<button class="net-chip net-chip--manual" data-provider="pinterest"
data-link="https://help.pinterest.com/es/article/deactivate-or-close-your-account"><img class="chip-icon" src="https://cdn.simpleicons.org/pinterest/1a1714" alt=""><span class="chip-label">Pinterest</span> <span class="chip-arrow"></span></button>
</div>
<div class="form-counter" id="form-counter">0 redes seleccionadas</div>
</div>
</div>
<div class="form-footer-row">
<button class="btn primary btn--lg" id="gdpr-send" disabled>
<!-- ── Fila de envío ── -->
<div class="form-send-row">
<button class="btn primary btn--send-main" id="gdpr-send" disabled>
Enviar solicitudes GDPR
</button>
<p class="form-note">
No guardamos tu correo. Las cartas se envían directamente a los DPOs de cada plataforma.<br>
Las redes en verde requieren acción manual: abrimos el enlace oficial por ti.
Las redes de "acceso directo" abren su página oficial al instante, sin necesidad de enviar nada.
</p>
</div>
@ -196,31 +152,30 @@
════════════════════════════════════════════ -->
<section class="panel">
<div class="container">
<h2>Panel de acciones completo</h2>
<p class="section-desc">
Marca cada acción completada para seguir tu progreso. Todos los enlaces apuntan a páginas oficiales.
Recuerda: <strong>descarga primero tus datos</strong> antes de eliminar nada.
</p>
<div class="progress-bar"><div class="progress-fill" id="progress-fill" style="width:0%"></div></div>
<div class="progress-label" style="margin-bottom:2rem"><span id="progress-text">0 acciones completadas</span></div>
<!-- ── Navegación por tabs ── -->
<div class="ptabs-nav">
<button class="ptab active" data-tab="cuentas">Cuentas base</button>
<button class="ptab" data-tab="redes">Redes sociales</button>
<button class="ptab" data-tab="mensajeria">Mensajería</button>
<button class="ptab" data-tab="streaming">Streaming</button>
<button class="ptab" data-tab="buscadores">Buscadores</button>
<button class="ptab" data-tab="brokers">Data brokers</button>
<div class="panel-global-header">
<h2 class="panel-title">Panel de acciones completo</h2>
<p class="section-desc">
Marca cada acción completada para seguir tu progreso. Todos los enlaces apuntan a páginas oficiales.
Recuerda: <strong>descarga primero tus datos</strong> antes de eliminar nada.
</p>
</div>
<!-- ── Contenido de cada tab ── -->
<div id="ptab-cuentas" class="ptab-pane active">
<div class="pitem-grid">
<div class="progress-track">
<div class="progress-bar"><div class="progress-fill" id="progress-fill" style="width:0%"></div></div>
<span class="progress-label" id="progress-text">0 acciones completadas</span>
</div>
<!-- ── Cuentas base ── -->
<div class="panel-section">
<div class="panel-section-header">
<span class="panel-section-badge">Cuentas base</span>
<button class="btn-mark-section" type="button" data-section="cuentas-base">Marcar todas</button>
</div>
<div class="pitem-grid" id="psec-cuentas-base">
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> Google</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/google/1a1714" alt=""> Google</span></label>
<div class="actions">
<a href="https://myaccount.google.com/data-and-privacy" target="_blank" rel="noopener">Descarga datos</a>
<a href="https://myaccount.google.com/delete-services-or-account" target="_blank" rel="noopener">Eliminar cuenta</a>
@ -229,7 +184,7 @@
</div>
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> Microsoft / Outlook</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/microsoft/1a1714" alt=""> Microsoft / Outlook</span></label>
<div class="actions">
<a href="https://account.microsoft.com/account/privacy" target="_blank" rel="noopener">Privacidad</a>
<a href="https://account.live.com/closeaccount.aspx" target="_blank" rel="noopener">Cerrar cuenta</a>
@ -238,7 +193,7 @@
</div>
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> Apple ID</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/apple/1a1714" alt=""> Apple ID</span></label>
<div class="actions">
<a href="https://privacy.apple.com/" target="_blank" rel="noopener">Portal privacidad</a>
<a href="https://support.apple.com/es-es/111001" target="_blank" rel="noopener">Eliminar cuenta</a>
@ -247,7 +202,7 @@
</div>
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> Amazon</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/amazon/1a1714" alt=""> Amazon</span></label>
<div class="actions">
<a href="https://www.amazon.es/gp/help/customer/display.html?nodeId=GX7NJQ4ZB8MHFRNJ" target="_blank" rel="noopener">Política privacidad</a>
<a href="https://www.amazon.es/gp/help/customer/display.html?nodeId=GXPU3YPMBDQWWHGZ" target="_blank" rel="noopener">Cerrar cuenta</a>
@ -256,13 +211,21 @@
</div>
</div>
<div class="panel-section-footer">
<button class="btn ghost btn--section-send" type="button" data-section="cuentas-base">✓ Marcar sección completada</button>
</div>
</div>
<div id="ptab-redes" class="ptab-pane">
<div class="pitem-grid">
<!-- ── Redes sociales ── -->
<div class="panel-section">
<div class="panel-section-header">
<span class="panel-section-badge">Redes sociales</span>
<button class="btn-mark-section" type="button" data-section="redes-sociales">Marcar todas</button>
</div>
<div class="pitem-grid" id="psec-redes-sociales">
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> Instagram</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/instagram/1a1714" alt=""> Instagram</span></label>
<div class="actions">
<a href="https://www.instagram.com/download/request/" target="_blank" rel="noopener">Descarga datos</a>
<a href="https://www.instagram.com/accounts/remove/request/permanent/" target="_blank" rel="noopener">Eliminar cuenta</a>
@ -271,7 +234,7 @@
</div>
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> Facebook</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/facebook/1a1714" alt=""> Facebook</span></label>
<div class="actions">
<a href="https://www.facebook.com/dyi/" target="_blank" rel="noopener">Descarga datos</a>
<a href="https://www.facebook.com/help/delete_account" target="_blank" rel="noopener">Eliminar cuenta</a>
@ -280,7 +243,7 @@
</div>
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> X / Twitter</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/x/1a1714" alt=""> X / Twitter</span></label>
<div class="actions">
<a href="https://x.com/settings/download_your_data" target="_blank" rel="noopener">Descarga datos</a>
<a href="https://x.com/settings/deactivate" target="_blank" rel="noopener">Desactivar cuenta</a>
@ -289,7 +252,7 @@
</div>
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> LinkedIn</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/linkedin/1a1714" alt=""> LinkedIn</span></label>
<div class="actions">
<a href="https://www.linkedin.com/mypreferences/d/data-export" target="_blank" rel="noopener">Descarga datos</a>
<a href="https://www.linkedin.com/mypreferences/d/close-your-account" target="_blank" rel="noopener">Cerrar cuenta</a>
@ -298,7 +261,7 @@
</div>
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> TikTok</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/tiktok/1a1714" alt=""> TikTok</span></label>
<div class="actions">
<a href="https://www.tiktok.com/setting/privacy?settingPage=privacy" target="_blank" rel="noopener">Privacidad</a>
<a href="https://support.tiktok.com/es/safety-hic/account-and-user-safety/account-deletion" target="_blank" rel="noopener">Eliminar cuenta</a>
@ -307,7 +270,7 @@
</div>
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> Snapchat</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/snapchat/1a1714" alt=""> Snapchat</span></label>
<div class="actions">
<a href="https://accounts.snapchat.com/accounts/downloadmydata" target="_blank" rel="noopener">Descarga datos</a>
<a href="https://accounts.snapchat.com/accounts/delete_account" target="_blank" rel="noopener">Eliminar cuenta</a>
@ -316,7 +279,7 @@
</div>
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> Pinterest</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/pinterest/1a1714" alt=""> Pinterest</span></label>
<div class="actions">
<a href="https://www.pinterest.es/settings/privacy/" target="_blank" rel="noopener">Privacidad</a>
<a href="https://help.pinterest.com/es/article/deactivate-or-close-your-account" target="_blank" rel="noopener">Eliminar cuenta</a>
@ -324,7 +287,7 @@
</div>
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> Reddit</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/reddit/1a1714" alt=""> Reddit</span></label>
<div class="actions">
<a href="https://www.reddit.com/settings/data-request" target="_blank" rel="noopener">Descarga datos</a>
<a href="https://www.reddit.com/settings/account" target="_blank" rel="noopener">Eliminar cuenta</a>
@ -333,7 +296,7 @@
</div>
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> Discord</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/discord/1a1714" alt=""> Discord</span></label>
<div class="actions">
<a href="https://discord.com/privacy" target="_blank" rel="noopener">Privacidad</a>
<a href="https://support.discord.com/hc/es/articles/212500837" target="_blank" rel="noopener">Eliminar cuenta</a>
@ -342,13 +305,21 @@
</div>
</div>
<div class="panel-section-footer">
<button class="btn ghost btn--section-send" type="button" data-section="redes-sociales">✓ Marcar sección completada</button>
</div>
</div>
<div id="ptab-mensajeria" class="ptab-pane">
<div class="pitem-grid">
<!-- ── Mensajería ── -->
<div class="panel-section">
<div class="panel-section-header">
<span class="panel-section-badge">Mensajería</span>
<button class="btn-mark-section" type="button" data-section="mensajeria">Marcar todas</button>
</div>
<div class="pitem-grid" id="psec-mensajeria">
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> WhatsApp</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/whatsapp/1a1714" alt=""> WhatsApp</span></label>
<div class="actions">
<a href="https://faq.whatsapp.com/1180414079177245" target="_blank" rel="noopener">Solicitar datos</a>
<a href="https://faq.whatsapp.com/1306557496340600" target="_blank" rel="noopener">Eliminar cuenta</a>
@ -356,7 +327,7 @@
</div>
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> Telegram</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/telegram/1a1714" alt=""> Telegram</span></label>
<div class="actions">
<a href="https://my.telegram.org/auth" target="_blank" rel="noopener">Eliminar cuenta</a>
<a href="https://telegram.org/privacy" target="_blank" rel="noopener">Política privacidad</a>
@ -364,20 +335,28 @@
</div>
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> Gmail (correo)</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/gmail/1a1714" alt=""> Gmail (correo)</span></label>
<div class="actions">
<a href="https://myaccount.google.com/deleteaccount" target="_blank" rel="noopener">Gestionar</a>
</div>
</div>
</div>
<div class="panel-section-footer">
<button class="btn ghost btn--section-send" type="button" data-section="mensajeria">✓ Marcar sección completada</button>
</div>
</div>
<div id="ptab-streaming" class="ptab-pane">
<div class="pitem-grid">
<!-- ── Streaming ── -->
<div class="panel-section">
<div class="panel-section-header">
<span class="panel-section-badge">Streaming</span>
<button class="btn-mark-section" type="button" data-section="streaming">Marcar todas</button>
</div>
<div class="pitem-grid" id="psec-streaming">
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> Spotify</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/spotify/1a1714" alt=""> Spotify</span></label>
<div class="actions">
<a href="https://www.spotify.com/es/account/privacy/" target="_blank" rel="noopener">Privacidad</a>
<a href="https://support.spotify.com/es/article/close-account/" target="_blank" rel="noopener">Cerrar cuenta</a>
@ -385,7 +364,7 @@
</div>
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> Netflix</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/netflix/1a1714" alt=""> Netflix</span></label>
<div class="actions">
<a href="https://help.netflix.com/es/node/407" target="_blank" rel="noopener">Cancelar suscripción</a>
<a href="https://help.netflix.com/es/node/100624" target="_blank" rel="noopener">Eliminar perfil</a>
@ -393,7 +372,7 @@
</div>
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> Twitch</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/twitch/1a1714" alt=""> Twitch</span></label>
<div class="actions">
<a href="https://www.twitch.tv/user/delete-account" target="_blank" rel="noopener">Eliminar cuenta</a>
<a href="https://www.twitch.tv/p/es-es/legal/privacy-notice/" target="_blank" rel="noopener">Política privacidad</a>
@ -401,7 +380,7 @@
</div>
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> YouTube</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/youtube/1a1714" alt=""> YouTube</span></label>
<div class="actions">
<a href="https://myaccount.google.com/data-and-privacy" target="_blank" rel="noopener">Gestionar datos</a>
<a href="https://support.google.com/youtube/answer/55759" target="_blank" rel="noopener">Cerrar canal</a>
@ -409,13 +388,21 @@
</div>
</div>
<div class="panel-section-footer">
<button class="btn ghost btn--section-send" type="button" data-section="streaming">✓ Marcar sección completada</button>
</div>
</div>
<div id="ptab-buscadores" class="ptab-pane">
<div class="pitem-grid">
<!-- ── Buscadores ── -->
<div class="panel-section">
<div class="panel-section-header">
<span class="panel-section-badge">Buscadores</span>
<button class="btn-mark-section" type="button" data-section="buscadores">Marcar todas</button>
</div>
<div class="pitem-grid" id="psec-buscadores">
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> Google — Derecho al olvido (UE)</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/google/1a1714" alt=""> Google — Derecho al olvido (UE)</span></label>
<div class="actions">
<a href="https://reportcontent.google.com/forms/rtbf" target="_blank" rel="noopener">Formulario RTBF</a>
<a href="plantillas.html" target="_blank">Carta GDPR</a>
@ -423,76 +410,88 @@
</div>
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> Google — Contenido obsoleto</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/google/1a1714" alt=""> Google — Contenido obsoleto</span></label>
<div class="actions">
<a href="https://search.google.com/search-console/remove-outdated-content" target="_blank" rel="noopener">Herramienta eliminación</a>
</div>
</div>
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> Google — Info personal</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/google/1a1714" alt=""> Google — Info personal</span></label>
<div class="actions">
<a href="https://support.google.com/websearch/troubleshooter/9685456" target="_blank" rel="noopener">Solicitar eliminación</a>
</div>
</div>
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> Bing — Eliminación de contenido</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/microsoftbing/1a1714" alt=""> Bing — Eliminación de contenido</span></label>
<div class="actions">
<a href="https://www.bing.com/webmasters/tools/content-removal" target="_blank" rel="noopener">Formulario Bing</a>
</div>
</div>
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> Bing — Derecho al olvido</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/microsoftbing/1a1714" alt=""> Bing — Derecho al olvido</span></label>
<div class="actions">
<a href="https://www.microsoft.com/es-es/concern/bing" target="_blank" rel="noopener">Solicitud Bing</a>
</div>
</div>
</div>
<div class="panel-section-footer">
<button class="btn ghost btn--section-send" type="button" data-section="buscadores">✓ Marcar sección completada</button>
</div>
</div>
<div id="ptab-brokers" class="ptab-pane">
<div class="pitem-grid">
<!-- ── Data brokers ── -->
<div class="panel-section">
<div class="panel-section-header">
<span class="panel-section-badge">Data brokers</span>
<button class="btn-mark-section" type="button" data-section="data-brokers">Marcar todas</button>
</div>
<div class="pitem-grid" id="psec-data-brokers">
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> Acxiom — Opt-out</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name">Acxiom — Opt-out</span></label>
<div class="actions">
<a href="https://isapps.acxiom.com/optout/optout.aspx" target="_blank" rel="noopener">Opt-out oficial</a>
</div>
</div>
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> Epsilon — Opt-out</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name">Epsilon — Opt-out</span></label>
<div class="actions">
<a href="https://www.epsilon.com/us/privacy-policy" target="_blank" rel="noopener">Política y opt-out</a>
</div>
</div>
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> Have I Been Pwned</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name"><img class="pitem-icon" src="https://cdn.simpleicons.org/haveibeenpwned/1a1714" alt=""> Have I Been Pwned</span></label>
<div class="actions">
<a href="https://haveibeenpwned.com/" target="_blank" rel="noopener">Verificar email</a>
</div>
</div>
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> DeleteMe (referencia)</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name">DeleteMe (referencia)</span></label>
<div class="actions">
<a href="https://joindeleteme.com/sites-we-remove-from/" target="_blank" rel="noopener">Lista de brokers</a>
</div>
</div>
<div class="pitem-card">
<label><input type="checkbox" class="progress-cb"> AEPD — Reclamación</label>
<label class="pitem-label"><input type="checkbox" class="progress-cb"><span class="pitem-name">AEPD — Reclamación</span></label>
<div class="actions">
<a href="https://sedeagpd.gob.es/sede-electronica-web/" target="_blank" rel="noopener">Sede AEPD</a>
</div>
</div>
</div>
<div class="panel-section-footer">
<button class="btn ghost btn--section-send" type="button" data-section="data-brokers">✓ Marcar sección completada</button>
</div>
</div>
</div>
</section>
@ -579,25 +578,56 @@
'use strict';
/* ── Chips de redes ─────────────────────────── */
const chips = document.querySelectorAll('.net-chip');
const sendBtn = document.getElementById('gdpr-send');
const emailIn = document.getElementById('gdpr-email');
const results = document.getElementById('gdpr-results');
/* Solo chips seleccionables (auto + form) — los manuales tienen acción directa */
const chips = document.querySelectorAll('.net-chip:not(.net-chip--manual)');
const manualChips = document.querySelectorAll('.net-chip--manual');
const sendBtn = document.getElementById('gdpr-send');
const emailIn = document.getElementById('gdpr-email');
const results = document.getElementById('gdpr-results');
const counter = document.getElementById('form-counter');
const selectAllBtn = document.getElementById('btn-select-all');
/* Chips seleccionables: toggle + actualiza botón */
chips.forEach(chip => {
chip.addEventListener('click', () => {
chip.classList.toggle('selected');
syncBtn();
});
});
/* Chips manuales: acción directa, abren el enlace oficial sin selección */
manualChips.forEach(chip => {
chip.addEventListener('click', () => {
window.open(chip.dataset.link, '_blank', 'noopener noreferrer');
chip.classList.add('chip--opened');
setTimeout(() => chip.classList.remove('chip--opened'), 1200);
});
});
emailIn.addEventListener('input', syncBtn);
selectAllBtn.addEventListener('click', () => {
const allSelected = [...chips].every(c => c.classList.contains('selected'));
chips.forEach(c => c.classList.toggle('selected', !allSelected));
selectAllBtn.textContent = allSelected ? 'Seleccionar todas' : 'Deseleccionar todas';
syncBtn();
});
function validEmail(v) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); }
function syncBtn() {
const ok = validEmail(emailIn.value) &&
[...chips].some(c => c.classList.contains('selected'));
const selected = [...chips].filter(c => c.classList.contains('selected'));
const count = selected.length;
counter.textContent = count === 0 ? '0 redes seleccionadas'
: count === 1 ? '1 red seleccionada'
: count + ' redes seleccionadas';
const allSel = count === chips.length;
selectAllBtn.textContent = allSel ? 'Deseleccionar todas' : 'Seleccionar todas';
const ok = validEmail(emailIn.value) && count > 0;
sendBtn.disabled = !ok;
sendBtn.textContent = ok
? `Enviar cartas GDPR (${count})`
: 'Enviar cartas GDPR';
}
/* ── Envío ──────────────────────────────────── */
@ -613,13 +643,7 @@ sendBtn.addEventListener('click', async () => {
for (const chip of selected) {
const provider = chip.dataset.provider;
const type = chip.dataset.type;
const name = chip.textContent.trim();
if (type === 'link') {
window.open(chip.dataset.link, '_blank', 'noopener noreferrer');
addResult(name, 'manual', 'Enlace oficial abierto — completa la eliminación manualmente.');
continue;
}
const name = chip.querySelector('.chip-label')?.textContent.trim() || chip.textContent.trim();
if (type === 'form') {
window.open(chip.dataset.formUrl, '_blank', 'noopener noreferrer');
@ -650,10 +674,7 @@ sendBtn.addEventListener('click', async () => {
}
sendBtn.textContent = 'Solicitudes enviadas ✓';
setTimeout(() => {
sendBtn.textContent = 'Enviar solicitudes GDPR';
syncBtn();
}, 4000);
setTimeout(() => { syncBtn(); }, 4000);
});
function addResult(name, type, msg) {
@ -663,18 +684,7 @@ function addResult(name, type, msg) {
results.appendChild(div);
}
/* ── Panel tabs ────────────────────────────── */
document.querySelectorAll('.ptab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.ptab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.ptab-pane').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
const pane = document.getElementById('ptab-' + tab.dataset.tab);
if (pane) pane.classList.add('active');
});
});
/* ── Progreso checklist ─────────────────────── */
/* ── Panel checklist ────────────────────────── */
const cbs = document.querySelectorAll('.progress-cb');
const fill = document.getElementById('progress-fill');
const label = document.getElementById('progress-text');
@ -687,6 +697,35 @@ function updateProgress() {
}
cbs.forEach(cb => cb.addEventListener('change', updateProgress));
updateProgress();
/* ── Marcar sección completa ─────────────────── */
document.querySelectorAll('.btn-mark-section, .btn--section-send').forEach(btn => {
btn.addEventListener('click', () => {
const grid = document.getElementById('psec-' + btn.dataset.section);
if (!grid) return;
const boxes = grid.querySelectorAll('.progress-cb');
const allDone = [...boxes].every(cb => cb.checked);
boxes.forEach(cb => { cb.checked = !allDone; });
updateProgress();
/* Actualizar texto del botón "Marcar todas" de esta sección */
const secHeader = grid.closest('.panel-section').querySelector('.btn-mark-section');
if (secHeader) secHeader.textContent = allDone ? 'Marcar todas' : 'Desmarcar todas';
const secFooter = grid.closest('.panel-section').querySelector('.btn--section-send');
if (secFooter) secFooter.textContent = allDone ? '✓ Marcar sección completada' : '↩ Desmarcar sección';
});
});
/* ── Carrusel de citas — rota cada 6 s ── */
(function () {
const quotes = document.querySelectorAll('.hero-quote');
if (!quotes.length) return;
let current = 0;
setInterval(() => {
quotes[current].classList.remove('active');
current = (current + 1) % quotes.length;
quotes[current].classList.add('active');
}, 6000);
})();
</script>
</body>

View file

@ -217,6 +217,7 @@
<a class="nav-btn" href="concienciacion.html">Concienciación</a>
<a class="nav-btn" href="index.html">Resetea</a>
<a class="nav-btn" href="egosurfing.html">Egosurfing</a>
<a class="nav-btn" href="stats.html">Estadísticas</a>
</nav>
</div>
</header>
@ -637,6 +638,28 @@ En aplicación del artículo 21.3 RGPD, en el caso de tratamiento con fines de m
}
};
/* ============================================================
UTILIDADES DE SEGURIDAD
esc() — escapa HTML para inserción en innerHTML
safeHref() — valida que una URL sea http/https (bloquea javascript:)
============================================================ */
function esc(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
function safeHref(url) {
try {
const u = new URL(String(url || ''));
if (u.protocol !== 'http:' && u.protocol !== 'https:') return '#';
return u.href;
} catch { return '#'; }
}
/* ============================================================
ESTADO
============================================================ */
@ -654,8 +677,8 @@ document.addEventListener('DOMContentLoaded', () => {
function renderPlatformGrid() {
const grid = document.getElementById('platform-grid');
grid.innerHTML = Object.entries(PLATFORMS).map(([key, p]) => `
<button class="platform-btn" data-key="${key}" onclick="selectPlatform('${key}')">
${p.name}
<button class="platform-btn" data-key="${esc(key)}" onclick="selectPlatform('${esc(key)}')">
${esc(p.name)}
</button>
`).join('');
}
@ -663,8 +686,8 @@ function renderPlatformGrid() {
function renderTypeGrid() {
const grid = document.getElementById('type-grid');
grid.innerHTML = Object.entries(REQUEST_TYPES).map(([key, t]) => `
<button class="type-btn" data-key="${key}" onclick="selectType('${key}')">
${t.label}
<button class="type-btn" data-key="${esc(key)}" onclick="selectType('${esc(key)}')">
${esc(t.label)}
</button>
`).join('');
}
@ -681,14 +704,14 @@ function showPlatformInfo(key) {
const p = PLATFORMS[key];
const info = document.getElementById('platform-info');
info.innerHTML = `
<div class="pi-row"><span class="pi-label">Empresa:</span><span>${p.company}</span></div>
<div class="pi-row"><span class="pi-label">Dirección:</span><span>${p.address}</span></div>
<div class="pi-row"><span class="pi-label">Empresa:</span><span>${esc(p.company)}</span></div>
<div class="pi-row"><span class="pi-label">Dirección:</span><span>${esc(p.address)}</span></div>
<div class="pi-row"><span class="pi-label">DPO / Privacidad:</span><span>${
p.dpoEmail ? `<a href="mailto:${p.dpoEmail}">${p.dpoEmail}</a>` : '—'
p.dpoEmail ? `<a href="mailto:${esc(p.dpoEmail)}">${esc(p.dpoEmail)}</a>` : '—'
}</span></div>
<div class="pi-row"><span class="pi-label">Eliminar cuenta:</span><span><a href="${p.deleteUrl}" target="_blank" rel="noopener">Abrir enlace oficial</a></span></div>
${p.formUrl ? `<div class="pi-row"><span class="pi-label">Formulario GDPR:</span><span><a href="${p.formUrl}" target="_blank" rel="noopener">Formulario oficial</a></span></div>` : ''}
${p.notes ? `<div class="pi-row" style="margin-top:0.5rem;"><span class="pi-label" style="color:var(--accent);">Nota:</span><span style="color:var(--muted);font-size:0.82rem;">${p.notes}</span></div>` : ''}
<div class="pi-row"><span class="pi-label">Eliminar cuenta:</span><span><a href="${safeHref(p.deleteUrl)}" target="_blank" rel="noopener">Abrir enlace oficial</a></span></div>
${p.formUrl ? `<div class="pi-row"><span class="pi-label">Formulario GDPR:</span><span><a href="${safeHref(p.formUrl)}" target="_blank" rel="noopener">Formulario oficial</a></span></div>` : ''}
${p.notes ? `<div class="pi-row" style="margin-top:0.5rem;"><span class="pi-label" style="color:var(--accent);">Nota:</span><span style="color:var(--muted);font-size:0.82rem;">${esc(p.notes)}</span></div>` : ''}
`;
info.classList.add('visible');
}
@ -788,12 +811,12 @@ ${address ? address + '\n' : ''}${today}`;
<ol>
<li><strong>Copia la carta</strong> con el botón "Copiar carta".</li>
<li>${p.dpoEmail
? `<strong>Envíala por email</strong> a <a href="mailto:${p.dpoEmail}">${p.dpoEmail}</a>.`
: `<strong>Usa el formulario oficial</strong>: <a href="${p.formUrl || p.deleteUrl}" target="_blank" rel="noopener">abrir formulario</a>.`
? `<strong>Envíala por email</strong> a <a href="mailto:${esc(p.dpoEmail)}">${esc(p.dpoEmail)}</a>.`
: `<strong>Usa el formulario oficial</strong>: <a href="${safeHref(p.formUrl || p.deleteUrl)}" target="_blank" rel="noopener">abrir formulario</a>.`
}</li>
<li><strong>Guarda el acuse de recibo</strong> (captura o email de confirmación). El plazo de 30 días empieza desde la recepción.</li>
<li>Si no responden antes del <strong>${deadline}</strong>, presenta reclamación en la <a href="https://sedeagpd.gob.es/sede-electronica-web/" target="_blank" rel="noopener">sede AEPD</a>.</li>
${p.deleteUrl ? `<li>Si también quieres eliminar tu cuenta, accede aquí: <a href="${p.deleteUrl}" target="_blank" rel="noopener">${p.name} — eliminación de cuenta</a>.</li>` : ''}
<li>Si no responden antes del <strong>${esc(deadline)}</strong>, presenta reclamación en la <a href="https://sedeagpd.gob.es/sede-electronica-web/" target="_blank" rel="noopener">sede AEPD</a>.</li>
${p.deleteUrl ? `<li>Si también quieres eliminar tu cuenta, accede aquí: <a href="${safeHref(p.deleteUrl)}" target="_blank" rel="noopener">${esc(p.name)} — eliminación de cuenta</a>.</li>` : ''}
</ol>
`;

374
public/stats.html Normal file
View file

@ -0,0 +1,374 @@
<!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>
/* ── Nav ── */
.nav { display:flex; flex-wrap:wrap; gap:.4rem; justify-content:center; padding:1rem 1.2rem; background:var(--surface); border-bottom:1px solid var(--border); }
.nav-btn { padding:.38rem .9rem; border-radius:20px; border:1px solid var(--border); background:transparent; color:var(--muted); font-size:.8rem; font-weight:600; text-decoration:none; transition:all 130ms; white-space:nowrap; }
.nav-btn:hover { background:var(--surface2); color:var(--text); }
.nav-btn--active { background:var(--caoba); color:#fff; border-color:var(--caoba); }
/* ── 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>
<!-- ── Navegación ── -->
<nav class="nav" aria-label="Navegación principal">
<a class="nav-btn" href="index.html">Resetea</a>
<a class="nav-btn" href="tipos.html">Tipos de info</a>
<a class="nav-btn" href="concienciacion.html">Concienciación</a>
<a class="nav-btn" href="egosurfing.html">Egosurfing</a>
<a class="nav-btn" href="plantillas.html">Plantillas GDPR</a>
<a class="nav-btn nav-btn--active" href="stats.html">Estadísticas</a>
</nav>
<!-- ── 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:'📸', 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>

View file

@ -26,6 +26,7 @@
<a class="nav-btn" href="concienciacion.html">Concienciación</a>
<a class="nav-btn" href="index.html">Resetea</a>
<a class="nav-btn" href="egosurfing.html">Egosurfing</a>
<a class="nav-btn" href="stats.html">Estadísticas</a>
</nav>
</div>
</header>