// 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,
obtenerPalabrasFiltradas, guardarPalabrasFiltradas,
} from "./firebase.js";
import { obtenerCategoria } from "./constants/categorias.js";
import StarRating from "./components/StarRating.jsx";
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); }
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 => (
{ setModo(t.id); setErr(""); }}
style={{ flex:1, padding:"10px 0", border:"none", background:"none", cursor:"pointer", fontFamily:"inherit", fontSize:14, fontWeight:modo===t.id?700:400, color:modo===t.id?"#8B0000":"#888", borderBottom:modo===t.id?"2px solid #8B0000":"2px solid transparent", marginBottom:-1 }}>
{t.l}
))}
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" });
return (
{p.nombre}
{p.categoria && {cat.emoji} {p.categoria} }
{p.subcategoria && {p.subcategoria} }
📍 {p.provincia}
{p.direccion && 🗺 {p.direccion} }
Enviado: {fecha}
{p.descripcion &&
"{p.descripcion}"
}
{p.enlaceGoogleMaps &&
Ver en Google Maps ↗ }
onAprobar(p)} disabled={busy} style={{ ...S.btn("#1E8449"), padding:"8px 16px", fontSize:13 }}>✓ Aprobar
onRechazar(p.id)} disabled={busy} style={{ ...S.btn("#C0392B"), padding:"8px 16px", fontSize:13 }}>✕ Rechazar
);
}
// ── Panel principal ───────────────────────────────────────────────────────────
function PanelAdmin({ email }) {
const [tab, setTab] = useState("pendientes");
const [pendientes, setPendientes] = useState([]);
const [publicados, setPublicados] = 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("");
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));
return () => { cancelado = true; limpiarPendientes?.(); limpiarPublicados?.(); };
}, []);
const aprobar = async (p) => { setBusy(true); try { await aprobarPropuesta(p); } catch(e) { alert("Error: "+e.message); } setBusy(false); };
const rechazar = async (id) => { if (!confirm("¿Rechazar y eliminar esta propuesta?")) return; try { await rechazarPropuesta(id); } catch(e) { alert("Error: "+e.message); } };
const eliminar = async (id) => { if (!confirm("¿Eliminar este local publicado?")) return; try { await deleteLocal(id); } catch(e) { alert("Error: "+e.message); } };
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); alert("✅ Filtro guardado."); }
catch(e) { alert("Error: "+e.message); }
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:"filtro", l:"Filtro de palabras" },
];
return (
🛡️
Panel de Administración
Locales Españoles · {email}
Cerrar sesión
{/* Tabs */}
{TABS.map(t => setTab(t.id)} style={S.tab(tab===t.id)}>{t.l} )}
{/* ── 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 &&
{l.direccion}
}
eliminar(l.id)} style={{ background:"none", border:"1px solid #FADADD", borderRadius:8, padding:"6px 14px", color:"#C0392B", cursor:"pointer", fontSize:12, fontWeight:600, flexShrink:0 }}>Eliminar
))
}
>
)}
{/* ── 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()} />
Añadir
{palabras.length === 0
?
No hay palabras filtradas todavía.
:
{palabras.map(p => (
{p}
setPalabras(palabras.filter(x=>x!==p))} style={{ background:"none", border:"none", cursor:"pointer", color:"#AAA", padding:0, fontSize:15, lineHeight:1 }}>✕
))}
}
{guardando ? "Guardando..." : "💾 Guardar filtro"}
ℹ️ Cómo funciona el flujo completo
El usuario rellena el formulario público y pulsa "Enviar propuesta" .
Antes de guardar, se comprueba el filtro de palabras. Si hay coincidencia → se rechaza al instante con un aviso.
Si pasa el filtro → se guarda en /pendientes en Firestore.
Aquí en el panel aparece la tarjeta. Tú pulsas Aprobar → pasa a /locales y se publica. O Rechazar → se elimina sin publicar.
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 ;
if (!user) return ;
return ;
}