feat: rediseño UI completo + infra email + stats
209
CONTEXT.txt
Normal 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
|
|
@ -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)
|
||||
10
api/app.js
|
|
@ -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' }));
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 ""
|
||||
80
mejoraegosurfingyfuncionalidad.txt
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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 |
1
public/icons/facebook.svg
Normal 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
|
|
@ -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
|
|
@ -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 |
1
public/icons/haveibeenpwned.svg
Normal 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 |
1
public/icons/instagram.svg
Normal 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
|
|
@ -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 |
1
public/icons/pinterest.svg
Normal 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
|
|
@ -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 |
1
public/icons/snapchat.svg
Normal 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
|
|
@ -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 |
1
public/icons/telegram.svg
Normal 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
|
|
@ -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
|
|
@ -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 |
1
public/icons/whatsapp.svg
Normal 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
|
|
@ -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
|
|
@ -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 |
591
public/index.css
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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
|
|
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/* Los números vienen del servidor sanitizados, pero validamos aquí también */
|
||||
function safeNum(v) {
|
||||
const n = Math.floor(Number(v));
|
||||
return Number.isFinite(n) && n >= 0 ? n : 0;
|
||||
}
|
||||
|
||||
/* Iconos por clave — si la clave no está mapeada, se usa un emoji neutro */
|
||||
const ICONS = {
|
||||
instagram:'📸', facebook:'👤', twitter_x:'🐦', linkedin:'💼',
|
||||
tiktok:'🎵', snapchat:'👻', microsoft:'🪟', apple:'🍎',
|
||||
google:'🔍', amazon:'📦', reddit:'🤖', discord:'💬',
|
||||
};
|
||||
|
||||
function animateCount(el, target, ms) {
|
||||
if (!el) return;
|
||||
if (target === 0) { el.textContent = '0'; return; }
|
||||
const start = performance.now();
|
||||
const tick = (now) => {
|
||||
const p = Math.min((now - start) / ms, 1);
|
||||
const ease = 1 - Math.pow(1 - p, 3);
|
||||
el.textContent = Math.round(ease * target).toLocaleString('es-ES');
|
||||
if (p < 1) requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function renderProviders(providers, maxTotal) {
|
||||
if (!providers.length) return '';
|
||||
|
||||
const rows = providers.map(p => {
|
||||
const total = safeNum(p.total);
|
||||
const sent = safeNum(p.sent);
|
||||
const redir = safeNum(p.redirected);
|
||||
const onlyRedir = redir > 0 && sent === 0;
|
||||
const pct = maxTotal > 0 ? Math.max(2, Math.round((total / maxTotal) * 100)) : 0;
|
||||
|
||||
/* icon_key viene del servidor whitelistado (a-z_), pero igual lo escapamos */
|
||||
const icon = ICONS[esc(p.icon_key)] || '🔹';
|
||||
|
||||
/* p.name viene de la whitelist del servidor, pero escapeamos de todas formas */
|
||||
return `<div class="provider-row">
|
||||
<span class="provider-name">${icon} ${esc(p.name)}</span>
|
||||
<div class="bar-track" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="bar-fill${onlyRedir ? ' redirect' : ''}" data-pct="${pct}"></div>
|
||||
</div>
|
||||
<span class="provider-count">${total.toLocaleString('es-ES')}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return `<h2 class="section-title">Por plataforma</h2>
|
||||
<div class="legend">
|
||||
<div class="legend-item"><div class="legend-dot sent"></div> Enviado por email</div>
|
||||
<div class="legend-item"><div class="legend-dot redirect"></div> Formulario oficial</div>
|
||||
</div>
|
||||
<div class="provider-list">${rows}</div>`;
|
||||
}
|
||||
|
||||
function renderPrivacy() {
|
||||
return `<details class="privacy-block">
|
||||
<summary class="privacy-summary">
|
||||
<span>🔒</span>
|
||||
<span>Política de privacidad de estas estadísticas</span>
|
||||
<span class="arrow">▶</span>
|
||||
</summary>
|
||||
<div class="privacy-body">
|
||||
<h3>Qué se cuenta</h3>
|
||||
<ul>
|
||||
<li>Número de correos GDPR enviados correctamente.</li>
|
||||
<li>Número de redirecciones a formularios oficiales (Google, Amazon…).</li>
|
||||
<li>Número de búsquedas OSINT realizadas en el egosurfing.</li>
|
||||
<li>Desglose por plataforma: cuántas solicitudes recibió cada servicio.</li>
|
||||
</ul>
|
||||
<h3>Qué NO se registra nunca</h3>
|
||||
<ul>
|
||||
<li><strong>Ninguna dirección de email.</strong> Los emails se usan para enviar la carta y se descartan.</li>
|
||||
<li><strong>Ninguna dirección IP.</strong> Ni del que solicita ni de nadie más.</li>
|
||||
<li><strong>Ningún nombre, alias, teléfono ni dirección postal.</strong></li>
|
||||
<li><strong>Ninguna marca de tiempo individual.</strong> Solo la fecha del último envío (YYYY-MM-DD) como indicador de actividad.</li>
|
||||
<li><strong>Sin cookies, sin fingerprinting, sin analytics de terceros.</strong></li>
|
||||
</ul>
|
||||
<h3>Cómo funciona técnicamente</h3>
|
||||
<ul>
|
||||
<li>El servidor mantiene un fichero <code>stats.json</code> con contadores enteros.</li>
|
||||
<li>Cada solicitud suma 1 al contador correspondiente. Nada más.</li>
|
||||
<li>No existe ninguna tabla de eventos, logs de acceso ni base de datos.</li>
|
||||
<li>La única referencia trazable es un hash SHA-256 truncado (12 chars) generado con una sal privada — sirve para que el usuario confirme su solicitud, pero es irreversible.</li>
|
||||
<li>Estos datos agregados son públicos precisamente porque no contienen información personal.</li>
|
||||
</ul>
|
||||
<h3>Base legal</h3>
|
||||
<p style="margin-top:.4rem">
|
||||
Al no procesarse ningún dato de carácter personal, estas estadísticas no están sujetas al RGPD (Reglamento UE 2016/679) ni a la LOPDGDD (LO 3/2018). Son datos estadísticos anónimos en el sentido del Considerando 26 del RGPD.
|
||||
</p>
|
||||
</div>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
const sent = safeNum(data.total_sent);
|
||||
const redir = safeNum(data.total_redirected);
|
||||
const search = safeNum(data.total_searches);
|
||||
const total = sent + redir;
|
||||
const maxProv = data.providers?.length ? safeNum(data.providers[0].total) : 0;
|
||||
|
||||
/* updated: solo mostramos si es fecha válida (ya validada en servidor) */
|
||||
const updatedStr = data.updated
|
||||
? `Última actividad registrada: ${esc(data.updated)}`
|
||||
: 'Sin actividad registrada aún';
|
||||
|
||||
const kpis = `<div class="kpi-grid">
|
||||
<div class="kpi-card sent">
|
||||
<div class="kpi-num" id="k-sent">0</div>
|
||||
<div class="kpi-label">Emails enviados</div>
|
||||
<div class="kpi-sub">Solicitudes Art. 17 GDPR</div>
|
||||
</div>
|
||||
<div class="kpi-card redirect">
|
||||
<div class="kpi-num" id="k-redir">0</div>
|
||||
<div class="kpi-label">Formularios web</div>
|
||||
<div class="kpi-sub">Google, Amazon y similares</div>
|
||||
</div>
|
||||
<div class="kpi-card search">
|
||||
<div class="kpi-num" id="k-search">0</div>
|
||||
<div class="kpi-label">Búsquedas OSINT</div>
|
||||
<div class="kpi-sub">Egosurfings realizados</div>
|
||||
</div>
|
||||
<div class="kpi-card total">
|
||||
<div class="kpi-num" id="k-total">0</div>
|
||||
<div class="kpi-label">Total solicitudes</div>
|
||||
<div class="kpi-sub">Emails + formularios</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const providersHtml = total > 0
|
||||
? renderProviders(data.providers || [], maxProv)
|
||||
: `<div class="empty-state"><div class="big">📬</div>
|
||||
El desglose por plataforma aparecerá cuando se envíen las primeras solicitudes.</div>`;
|
||||
|
||||
document.getElementById('content').innerHTML =
|
||||
kpis + providersHtml + renderPrivacy() +
|
||||
`<div class="updated-note">${updatedStr}</div>`;
|
||||
|
||||
animateCount(document.getElementById('k-sent'), sent, 900);
|
||||
animateCount(document.getElementById('k-redir'), redir, 900);
|
||||
animateCount(document.getElementById('k-search'), search, 900);
|
||||
animateCount(document.getElementById('k-total'), total, 900);
|
||||
|
||||
setTimeout(() => {
|
||||
document.querySelectorAll('.bar-fill').forEach(el => {
|
||||
el.style.width = el.dataset.pct + '%';
|
||||
});
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function renderError(msg) {
|
||||
document.getElementById('content').innerHTML =
|
||||
`<div class="empty-state">
|
||||
<div class="big">⚠️</div>
|
||||
${esc(msg) || 'No se pudieron cargar las estadísticas.'}<br>
|
||||
<small style="color:var(--subtle)">El backend puede estar arrancando. Inténtalo en unos segundos.</small>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* La función fetch no envía cookies ni credenciales — same-origin, solo lectura */
|
||||
fetch('/api/stats', { credentials: 'omit' })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(render)
|
||||
.catch(e => renderError(e.message));
|
||||
|
||||
}());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||