/* Blackstock — Modal de autenticación (registro + login) conectado a * api.blackstock.es. Reusa exactamente los mismos endpoints que * clientes.blackstock.es (el flujo y los códigos de error son idénticos). * * Vistas: * register: nombre + username + email + tel? + password + confirm + términos * verify: código de 6 dígitos + reenvío con cooldown 60s * login: email + password * success: redirección * * Endpoints: * GET /api/client/csrf * POST /api/client/register * POST /api/client/verify-email * POST /api/client/resend-verification * POST /api/client/login * GET /api/client/me * * window.bsOpenAuth(planHint?, mode?='register') * window.bsCloseAuth() */ (function () { const API = 'https://api.blackstock.es/api'; const RESEND_COOLDOWN_SEC = 60; const VERIFY_TTL_MIN = 10; let csrfToken = ''; async function fetchCsrf() { if (csrfToken) return csrfToken; try { const r = await fetch(`${API}/client/csrf`, { credentials: 'include' }); const j = await r.json().catch(() => ({})); if (j?.csrfToken) csrfToken = j.csrfToken; } catch (_) {} return csrfToken; } async function apiPost(path, body, retry = true) { if (!csrfToken) await fetchCsrf(); const res = await fetch(`${API}${path}`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken, }, body: JSON.stringify(body || {}), }); let data = null; try { data = await res.json(); } catch (_) {} if (data?.csrfToken) csrfToken = data.csrfToken; if (!res.ok) { if (retry && data?.error === 'csrf_invalid') { csrfToken = ''; await fetchCsrf(); return apiPost(path, body, false); } const err = new Error(data?.error || `http_${res.status}`); err.status = res.status; err.data = data; throw err; } return data; } async function apiGet(path) { try { const r = await fetch(`${API}${path}`, { credentials: 'include' }); if (!r.ok) return null; return await r.json().catch(() => null); } catch (_) { return null; } } // Sesión cacheada en window para que el avatar/dropdown la lea sin // hacer otra request. window.bsSession = window.bsSession || { authenticated: false, account: null }; async function refreshSession() { const data = await apiGet('/client/me'); const auth = !!(data?.authenticated && data?.account); window.bsSession = { authenticated: auth, account: auth ? data.account : null, }; if (data?.csrfToken) csrfToken = data.csrfToken; document.dispatchEvent(new CustomEvent('bs:session-changed', { detail: window.bsSession })); return window.bsSession; } window.bsRefreshSession = refreshSession; async function bsLogout() { try { await apiPost('/client/logout', {}); } catch (_) {} csrfToken = ''; window.bsSession = { authenticated: false, account: null }; document.dispatchEvent(new CustomEvent('bs:session-changed', { detail: window.bsSession })); } window.bsLogout = bsLogout; const ERR_MESSAGES = { email_invalid: 'El email no es válido.', email_exists: 'Ya existe una cuenta con ese email. Inicia sesión.', username_invalid: 'Usuario no válido (3-32 caracteres, letras/números/._-).', username_exists: 'Ese nombre de usuario ya está cogido.', password_invalid: 'La contraseña debe tener entre 6 y 120 caracteres.', register_disabled: 'El registro está deshabilitado temporalmente.', verification_unavailable: 'No podemos enviar el código ahora. Inténtalo en unos minutos.', verification_send_failed: 'No se pudo enviar el código. Comprueba el email.', verification_session_missing: 'La sesión de verificación ha caducado. Empieza de nuevo.', verification_code_invalid: 'Código incorrecto. Revísalo.', verification_code_expired: 'El código ha caducado. Pulsa "Reenviar".', verification_resend_wait: 'Espera unos segundos antes de pedir otro código.', email_already_verified: 'Esa cuenta ya está verificada. Inicia sesión.', invalid_credentials: 'Email o contraseña incorrectos.', account_inactive: 'Tu cuenta no está activa. Contacta soporte.', email_not_verified: 'Tu email no está verificado. Te reenviamos el código.', rate_limited: 'Demasiados intentos. Espera unos minutos.', }; const friendlyError = (err) => { if (!err) return 'Algo ha ido mal. Inténtalo de nuevo.'; const code = String(err.data?.error || err.message || '').toLowerCase(); return ERR_MESSAGES[code] || (err.data?.message || 'No pudimos completar la acción.'); }; const isValidEmail = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(e || '').trim()); const isValidUsername = (u) => /^[a-zA-Z0-9._-]{3,32}$/.test(String(u || '').trim()); const isValidName = (n) => String(n || '').trim().length >= 2 && String(n || '').trim().length <= 80; const isValidPhone = (p) => !p || /^[+0-9\s().-]{6,24}$/.test(String(p || '').trim()); const usernameSuggestion = (email) => { const local = String(email || '').split('@')[0] || ''; return local.replace(/[^a-zA-Z0-9._-]/g, '').slice(0, 20); }; const AuthModal = ({ open, onClose, planHint, initialMode }) => { const [view, setView] = React.useState(initialMode === 'login' ? 'login' : 'register'); // Register fields const [name, setName] = React.useState(''); const [username, setUsername] = React.useState(''); const [email, setEmail] = React.useState(''); const [phone, setPhone] = React.useState(''); const [password, setPassword] = React.useState(''); const [confirm, setConfirm] = React.useState(''); const [terms, setTerms] = React.useState(false); // Verify const [code, setCode] = React.useState(''); const [resendIn, setResendIn] = React.useState(0); // Login const [loginIdent, setLoginIdent] = React.useState(''); const [loginPass, setLoginPass] = React.useState(''); // Generales const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(''); const [okMsg, setOkMsg] = React.useState(''); const [shake, setShake] = React.useState(false); React.useEffect(() => { if (open) { setView(initialMode === 'login' ? 'login' : 'register'); setError(''); setOkMsg(''); setLoading(false); setResendIn(0); setCode(''); fetchCsrf().catch(() => {}); } }, [open, initialMode]); React.useEffect(() => { if (resendIn <= 0) return; const t = setInterval(() => setResendIn((s) => Math.max(0, s - 1)), 1000); return () => clearInterval(t); }, [resendIn]); React.useEffect(() => { if (!open) return; const onKey = (e) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); const prev = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { window.removeEventListener('keydown', onKey); document.body.style.overflow = prev; }; }, [open, onClose]); if (!open) return null; const triggerShake = () => { setShake(true); setTimeout(() => setShake(false), 380); }; const goSuccess = (nextPath) => { setView('success'); setOkMsg('¡Listo! Te llevamos al panel…'); refreshSession().catch(() => {}); setTimeout(() => { window.location.href = nextPath || 'https://clientes.blackstock.es/perfil/'; }, 900); }; // ─── Registro ────────────────────────────────────────────── const handleRegister = async (e) => { e?.preventDefault?.(); setError(''); const cleanEmail = email.trim().toLowerCase(); const cleanUsername = username.trim() || usernameSuggestion(cleanEmail); if (!isValidName(name)) { setError('Pon tu nombre completo.'); triggerShake(); return; } if (!isValidEmail(cleanEmail)) { setError('Email no válido.'); triggerShake(); return; } if (!isValidUsername(cleanUsername)) { setError('Usuario: 3-32 caracteres (letras/números/._-).'); triggerShake(); return; } if (!isValidPhone(phone)) { setError('Teléfono no válido.'); triggerShake(); return; } if (password.length < 6) { setError('Contraseña mínima 6 caracteres.'); triggerShake(); return; } if (password !== confirm) { setError('Las contraseñas no coinciden.'); triggerShake(); return; } if (!terms) { setError('Debes aceptar los términos para continuar.'); triggerShake(); return; } setLoading(true); try { const res = await apiPost('/client/register', { name: name.trim(), email: cleanEmail, username: cleanUsername, password, phone: phone.trim() || undefined, }); if (res?.requiresEmailVerification) { setView('verify'); setResendIn(RESEND_COOLDOWN_SEC); setOkMsg(`Te hemos enviado un código a ${cleanEmail}.`); } else { goSuccess(res?.nextPath); } } catch (err) { setError(friendlyError(err)); triggerShake(); } finally { setLoading(false); } }; // ─── Verificar código ────────────────────────────────────── const handleVerify = async (e) => { e?.preventDefault?.(); setError(''); const c = code.replace(/\D/g, '').slice(0, 6); if (c.length !== 6) { setError('El código tiene 6 dígitos.'); triggerShake(); return; } setLoading(true); try { const res = await apiPost('/client/verify-email', { code: c }); goSuccess(res?.nextPath); } catch (err) { setError(friendlyError(err)); setCode(''); triggerShake(); } finally { setLoading(false); } }; const handleResend = async () => { if (resendIn > 0 || loading) return; setError(''); setLoading(true); try { await apiPost('/client/resend-verification', {}); setResendIn(RESEND_COOLDOWN_SEC); setOkMsg('Código reenviado. Mira tu bandeja y spam.'); } catch (err) { setError(friendlyError(err)); } finally { setLoading(false); } }; // ─── Login ────────────────────────────────────────────────── const handleLogin = async (e) => { e?.preventDefault?.(); setError(''); const ident = loginIdent.trim(); if (!ident || loginPass.length < 1) { setError('Email y contraseña requeridos.'); triggerShake(); return; } setLoading(true); try { const res = await apiPost('/client/login', { email: ident.toLowerCase(), password: loginPass, }); if (res?.requiresEmailVerification) { setView('verify'); setResendIn(RESEND_COOLDOWN_SEC); setOkMsg('Te reenviamos el código de verificación.'); } else { goSuccess(res?.nextPath); } } catch (err) { // Si el server dice email_not_verified con 403, abrimos verify if (err?.data?.error === 'email_not_verified') { setView('verify'); setResendIn(RESEND_COOLDOWN_SEC); setOkMsg('Tu email no está verificado. Te enviamos un nuevo código.'); } else { setError(friendlyError(err)); triggerShake(); } } finally { setLoading(false); } }; const totalSteps = view === 'register' ? 50 : view === 'verify' ? 90 : view === 'login' ? 50 : 100; return (
{okMsg}