// AdminPanel.jsx — /admin // No aparece en ningún menú público. Acceso solo por URL directa. import { useState, useEffect } from "react"; import { escucharAuth, loginAdmin, registrarAdmin, cerrarSesion, suscribirPendientes, aprobarPropuesta, rechazarPropuesta, suscribirLocales, deleteLocal, obtenerReportes, descartarReporte, obtenerPalabrasFiltradas, guardarPalabrasFiltradas, } from "./firebase.js"; import { obtenerCategoria } from "./constants/categorias.js"; import { enlaceGoogleMapsDesdeLocal, enlaceWazeDesdeLocal } from "./utils/geo.js"; import StarRating from "./components/StarRating.jsx"; import Toast from "./components/Toast.jsx"; import useToast from "./hooks/useToast.js"; const CLAVE_TOAST_LOGIN = "localesp_mostrar_toast_login"; const S = { page: { minHeight:"100vh", background:"#F2EDE8", fontFamily:"-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" }, header: { background:"#1A1A1A", color:"white", padding:"14px 28px", display:"flex", alignItems:"center", justifyContent:"space-between", position:"sticky", top:0, zIndex:10 }, wrap: { maxWidth:920, margin:"0 auto", padding:"28px 20px 60px" }, card: { background:"white", borderRadius:12, border:"1px solid #E8E0D5", padding:"20px 24px", marginBottom:14 }, input: { width:"100%", boxSizing:"border-box", padding:"10px 13px", border:"1px solid #DDD", borderRadius:8, fontSize:14, fontFamily:"inherit", outline:"none" }, btn: (bg="#8B0000",fg="white") => ({ padding:"9px 20px", background:bg, color:fg, border:"none", borderRadius:8, cursor:"pointer", fontWeight:600, fontSize:14, fontFamily:"inherit" }), tab: (a) => ({ padding:"11px 22px", border:"none", background:"none", cursor:"pointer", fontFamily:"inherit", fontSize:14, fontWeight:a?700:400, color:a?"#8B0000":"#888", borderBottom:a?"2px solid #8B0000":"2px solid transparent", marginBottom:-2 }), badge: (c,bg) => ({ display:"inline-block", padding:"3px 10px", borderRadius:20, fontSize:11, fontWeight:600, background:bg, color:c }), tag: { display:"inline-flex", alignItems:"center", gap:5, background:"#F0EBE5", borderRadius:20, padding:"4px 12px", fontSize:13 }, label: { fontSize:11, color:"#999", fontWeight:700, textTransform:"uppercase", letterSpacing:"0.5px", display:"block", marginBottom:5 }, }; // ── Pantalla de login/registro ──────────────────────────────────────────────── function PantallaAuth() { const [modo, setModo] = useState("login"); const [email, setEmail] = useState(""); const [pass, setPass] = useState(""); const [pass2, setPass2] = useState(""); const [err, setErr] = useState(""); const [busy, setBusy] = useState(false); const errMsg = code => ({ "auth/wrong-password":"Contraseña incorrecta.", "auth/user-not-found":"No existe ningún admin con ese email.", "auth/invalid-credential":"Email o contraseña incorrectos.", "auth/email-already-in-use":"Ese email ya está registrado.", "auth/weak-password":"Mínimo 6 caracteres.", "auth/invalid-email":"Email no válido.", "auth/too-many-requests":"Demasiados intentos. Espera un momento." }[code] || "Error inesperado."); const submit = async () => { setErr(""); if (!email || !pass) { setErr("Rellena todos los campos."); return; } if (modo === "registro" && pass !== pass2) { setErr("Las contraseñas no coinciden."); return; } setBusy(true); try { modo === "login" ? await loginAdmin(email, pass) : await registrarAdmin(email, pass); try { sessionStorage.setItem(CLAVE_TOAST_LOGIN, modo === "login" ? "1" : "0"); } catch { /* sessionStorage no disponible */ } } catch(e) { setErr(errMsg(e.code)); } setBusy(false); }; return (
🔐

Panel de Administración

Locales Españoles · acceso restringido

{[{id:"login",l:"Iniciar sesión"},{id:"registro",l:"Crear admin"}].map(t => ( ))}
setEmail(e.target.value)} onKeyDown={e=>e.key==="Enter"&&submit()} />
setPass(e.target.value)} onKeyDown={e=>e.key==="Enter"&&submit()} />
{modo==="registro" &&
setPass2(e.target.value)} onKeyDown={e=>e.key==="Enter"&&submit()} />
} {err &&

{err}

}

Esta página no está indexada en el sitio público

); } // ── Tarjeta propuesta pendiente ─────────────────────────────────────────────── function TarjetaPropuesta({ p, onAprobar, onRechazar, busy }) { const cat = obtenerCategoria(p.categoria); const fecha = new Date(p.fechaPropuesta).toLocaleDateString("es-ES", { day:"numeric", month:"short", hour:"2-digit", minute:"2-digit" }); const gmUrl = p.enlaceGoogleMaps || enlaceGoogleMapsDesdeLocal(p); const wazeUrl = enlaceWazeDesdeLocal(p); return (
{p.nombre} {p.categoria && {cat.emoji} {p.categoria}} {p.subcategoria && {p.subcategoria}}
📍 {p.provincia} {p.direccion && (gmUrl ? 🗺 {p.direccion} : 🗺 {p.direccion})} Enviado: {fecha}
{p.descripcion &&

"{p.descripcion}"

}
{gmUrl && Ver en Google Maps ↗} {wazeUrl && Ver en Waze ↗}
); } // ── Panel principal ─────────────────────────────────────────────────────────── function PanelAdmin({ email }) { const [tab, setTab] = useState("pendientes"); const [pendientes, setPendientes] = useState([]); const [publicados, setPublicados] = useState([]); const [reportes, setReportes] = useState([]); const [palabras, setPalabras] = useState([]); const [nuevaP, setNuevaP] = useState(""); const [cargando, setCargando] = useState(true); const [busy, setBusy] = useState(false); const [guardando, setGuardando] = useState(false); const [buscador, setBuscador] = useState(""); const { toast, mostrarToast } = useToast(); const cargarReportes = () => obtenerReportes().then(setReportes).catch(() => {}); useEffect(() => { try { const marca = sessionStorage.getItem(CLAVE_TOAST_LOGIN); if (marca !== null) { mostrarToast(marca === "1" ? `Sesión iniciada como ${email}` : "Cuenta de administrador creada", "exito"); sessionStorage.removeItem(CLAVE_TOAST_LOGIN); } } catch { /* sessionStorage no disponible */ } }, []); useEffect(() => { setCargando(true); let cancelado = false; let limpiarPendientes, limpiarPublicados; suscribirPendientes(l => { if (!cancelado) { setPendientes(l); setCargando(false); } }, () => !cancelado && setCargando(false)) .then(fn => { if (cancelado) fn?.(); else limpiarPendientes = fn; }); suscribirLocales(l => !cancelado && setPublicados(l), () => {}) .then(fn => { if (cancelado) fn?.(); else limpiarPublicados = fn; }); obtenerPalabrasFiltradas().then(p => !cancelado && setPalabras(p)); cargarReportes(); return () => { cancelado = true; limpiarPendientes?.(); limpiarPublicados?.(); }; }, []); const aprobar = async (p) => { setBusy(true); try { await aprobarPropuesta(p); mostrarToast(`✅ "${p.nombre}" aprobado y publicado`, "exito"); } catch(e) { mostrarToast("Error: "+e.message, "error"); } setBusy(false); }; const rechazar = async (id) => { if (!confirm("¿Rechazar y eliminar esta propuesta?")) return; try { await rechazarPropuesta(id); mostrarToast("Propuesta rechazada", "info"); } catch(e) { mostrarToast("Error: "+e.message, "error"); } }; const eliminar = async (id) => { if (!confirm("¿Eliminar este local publicado?")) return; try { await deleteLocal(id); cargarReportes(); mostrarToast("Local eliminado", "info"); } catch(e) { mostrarToast("Error: "+e.message, "error"); } }; const descartar = async (id) => { try { await descartarReporte(id); setReportes(r => r.filter(x => x.id !== id)); mostrarToast("Aviso descartado", "info"); } catch(e) { mostrarToast("Error: "+e.message, "error"); } }; const addPalabra = () => { const p = nuevaP.trim().toLowerCase(); if (!p || palabras.includes(p)) return; setPalabras([...palabras, p]); setNuevaP(""); }; const guardarFiltro = async () => { setGuardando(true); try { await guardarPalabrasFiltradas(palabras); mostrarToast("💾 Filtro de palabras guardado", "exito"); } catch(e) { mostrarToast("Error: "+e.message, "error"); } setGuardando(false); }; const pubFiltrados = publicados.filter(l => !buscador || l.nombre.toLowerCase().includes(buscador.toLowerCase()) || l.provincia?.toLowerCase().includes(buscador.toLowerCase()) ); const TABS = [ { id:"pendientes", l:`Pendientes (${pendientes.length})` }, { id:"publicados", l:`Publicados (${publicados.length})` }, { id:"reportes", l:`Reportes${reportes.length ? ` (${reportes.length})` : ""}` }, { id:"filtro", l:"Filtro de palabras" }, ]; return (
🛡️

Panel de Administración

Locales Españoles · {email}

{/* Tabs */}
{TABS.map(t => )}
{/* ── Pendientes ── */} {tab === "pendientes" && ( cargando ?

Cargando propuestas...

: pendientes.length === 0 ? (
🎉

Sin propuestas pendientes

Cuando un usuario proponga un local, aparecerá aquí para que lo apruebes o rechaces.

) : ( <>

{pendientes.length} propuesta{pendientes.length!==1?"s":""} esperando revisión.

{pendientes.map(p => )} ) )} {/* ── Publicados ── */} {tab === "publicados" && ( <>
setBuscador(e.target.value)} />
{pubFiltrados.length === 0 ?

{publicados.length===0 ? "No hay locales publicados." : "Sin resultados."}

: pubFiltrados.map(l => (
{l.nombre} {l.categoria && {obtenerCategoria(l.categoria).emoji} {l.categoria}} 📍 {l.provincia} · {new Date(l.fecha).toLocaleDateString("es-ES",{day:"numeric",month:"short",year:"numeric"})}
{l.direccion && (() => { const url = l.enlaceGoogleMaps || enlaceGoogleMapsDesdeLocal(l); return url ? {l.direccion} :

{l.direccion}

; })()}
)) } )} {/* ── Reportes ── */} {tab === "reportes" && ( reportes.length === 0 ? (
🚩

Sin reportes pendientes

Cuando alguien reporte un local (cerrado, datos incorrectos...), aparecerá aquí.

) : ( <>

{reportes.length} reporte{reportes.length!==1?"s":""} sin revisar.

{reportes.map(r => (
{r.localNombre || "(local ya eliminado)"} {{ cerrado:"Ha cerrado", datos_incorrectos:"Datos incorrectos", inapropiado:"Contenido inapropiado", otro:"Otro motivo" }[r.motivo] || r.motivo}

📍 {r.localProvincia} {r.localDireccion ? `· ${r.localDireccion}` : ""}

{r.comentario &&

"{r.comentario}"

}

Reportado: {new Date(r.fecha).toLocaleDateString("es-ES", { day:"numeric", month:"short", hour:"2-digit", minute:"2-digit" })}

{r.localId && }
))} ) )} {/* ── Filtro de palabras ── */} {tab === "filtro" && (

Palabras prohibidas

Las propuestas que contengan estas palabras en nombre, dirección o descripción serán bloqueadas automáticamente, sin llegar siquiera al panel de moderación. No distingue mayúsculas ni tildes.

setNuevaP(e.target.value)} onKeyDown={e => e.key==="Enter" && addPalabra()} />
{palabras.length === 0 ?

No hay palabras filtradas todavía.

:
{palabras.map(p => ( {p} ))}
}

ℹ️ Cómo funciona el flujo completo

  1. El usuario rellena el formulario público y pulsa "Enviar propuesta".
  2. Antes de guardar, se comprueba el filtro de palabras. Si hay coincidencia → se rechaza al instante con un aviso.
  3. Si pasa el filtro → se guarda en /pendientes en Firestore.
  4. Aquí en el panel aparece la tarjeta. Tú pulsas Aprobar → pasa a /locales y se publica. O Rechazar → se elimina sin publicar.
  5. Solo los locales en /locales son visibles en la web y la app móvil.
)}
); } // ── Raíz del panel ──────────────────────────────────────────────────────────── export default function AdminPanel() { const [user, setUser] = useState(undefined); useEffect(() => escucharAuth(u => setUser(u)), []); if (user === undefined) return

Cargando...

; if (!user) return ; return ; }