- Añade sistema de avisos tipo snackbar (Toast.jsx + useToast.js) con auto-cierre, animación de entrada y estilos info/éxito/error. - LocalCard: feedback visual al añadir/quitar un local de favoritos. - AdminPanel: sustituye los alert() por toasts al aprobar, rechazar, eliminar locales, descartar avisos y guardar el filtro de palabras; además saluda con un toast tras iniciar sesión o crear cuenta. - App: el contenedor del mapa Leaflet queda siempre montado (oculto con CSS) y se llama a invalidateSize() al volver a la pestaña de mapa; así el mapa ya no se rompe al navegar entre vistas (antes obligaba a recargar la página).
352 lines
23 KiB
React
352 lines
23 KiB
React
// 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 (
|
||
<div style={{ ...S.page, display:"flex", alignItems:"center", justifyContent:"center" }}>
|
||
<div style={{ width:"100%", maxWidth:400, padding:"0 20px" }}>
|
||
<div style={{ textAlign:"center", marginBottom:28 }}>
|
||
<div style={{ fontSize:52, marginBottom:8 }}>🔐</div>
|
||
<h1 style={{ margin:0, fontSize:22, color:"#1A1A1A" }}>Panel de Administración</h1>
|
||
<p style={{ margin:"6px 0 0", color:"#999", fontSize:13 }}>Locales Españoles · acceso restringido</p>
|
||
</div>
|
||
<div style={{ ...S.card, padding:"24px 28px" }}>
|
||
<div style={{ display:"flex", borderBottom:"1px solid #EEE", marginBottom:20 }}>
|
||
{[{id:"login",l:"Iniciar sesión"},{id:"registro",l:"Crear admin"}].map(t => (
|
||
<button key={t.id} onClick={() => { 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}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div style={{ display:"flex", flexDirection:"column", gap:14 }}>
|
||
<div><label style={S.label}>Email</label><input style={S.input} type="email" placeholder="admin@ejemplo.com" value={email} onChange={e=>setEmail(e.target.value)} onKeyDown={e=>e.key==="Enter"&&submit()} /></div>
|
||
<div><label style={S.label}>Contraseña</label><input style={S.input} type="password" placeholder="Mínimo 6 caracteres" value={pass} onChange={e=>setPass(e.target.value)} onKeyDown={e=>e.key==="Enter"&&submit()} /></div>
|
||
{modo==="registro" && <div><label style={S.label}>Repetir contraseña</label><input style={S.input} type="password" placeholder="Repite la contraseña" value={pass2} onChange={e=>setPass2(e.target.value)} onKeyDown={e=>e.key==="Enter"&&submit()} /></div>}
|
||
{err && <p style={{ margin:0, color:"#C0392B", fontSize:13, background:"#FDEDEC", padding:"8px 12px", borderRadius:8 }}>{err}</p>}
|
||
<button onClick={submit} disabled={busy} style={{ ...S.btn(), opacity:busy?0.6:1, marginTop:4 }}>
|
||
{busy ? "..." : modo==="login" ? "Entrar" : "Crear cuenta de administrador"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<p style={{ textAlign:"center", fontSize:12, color:"#CCC", marginTop:14 }}>Esta página no está indexada en el sitio público</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 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 (
|
||
<div style={{ ...S.card, borderLeft:`4px solid ${cat.color}`, marginBottom:10 }}>
|
||
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"flex-start", flexWrap:"wrap", gap:12 }}>
|
||
<div style={{ flex:1 }}>
|
||
<div style={{ display:"flex", alignItems:"center", gap:8, flexWrap:"wrap", marginBottom:6 }}>
|
||
<span style={{ fontWeight:700, fontSize:17 }}>{p.nombre}</span>
|
||
{p.categoria && <span style={S.badge(cat.textColor, cat.bgLight)}>{cat.emoji} {p.categoria}</span>}
|
||
{p.subcategoria && <span style={{ fontSize:12, color:"#888", background:"#F5F5F5", padding:"2px 8px", borderRadius:20 }}>{p.subcategoria}</span>}
|
||
</div>
|
||
<div style={{ display:"flex", flexWrap:"wrap", alignItems:"center", gap:16, fontSize:13, color:"#666" }}>
|
||
<span>📍 {p.provincia}</span>
|
||
{p.direccion && (gmUrl ? <a href={gmUrl} target="_blank" rel="noopener noreferrer" style={{ color:"#666", textDecoration:"underline" }}>🗺 {p.direccion}</a> : <span>🗺 {p.direccion}</span>)}
|
||
<StarRating value={p.puntuacion} readOnly size={14} />
|
||
<span style={{ color:"#AAA" }}>Enviado: {fecha}</span>
|
||
</div>
|
||
{p.descripcion && <p style={{ margin:"8px 0 0", fontSize:13, color:"#666", fontStyle:"italic", background:"#FAFAFA", padding:"8px 12px", borderRadius:8 }}>"{p.descripcion}"</p>}
|
||
<div style={{ display:"flex", gap:10 }}>
|
||
{gmUrl && <a href={gmUrl} target="_blank" rel="noopener noreferrer" style={{ display:"inline-block", marginTop:6, fontSize:12, color:"#1A73E8" }}>Ver en Google Maps ↗</a>}
|
||
{wazeUrl && <a href={wazeUrl} target="_blank" rel="noopener noreferrer" style={{ display:"inline-block", marginTop:6, fontSize:12, color:"#0A9FD9" }}>Ver en Waze ↗</a>}
|
||
</div>
|
||
</div>
|
||
<div style={{ display:"flex", gap:8, flexShrink:0 }}>
|
||
<button onClick={() => onAprobar(p)} disabled={busy} style={{ ...S.btn("#1E8449"), padding:"8px 16px", fontSize:13 }}>✓ Aprobar</button>
|
||
<button onClick={() => onRechazar(p.id)} disabled={busy} style={{ ...S.btn("#C0392B"), padding:"8px 16px", fontSize:13 }}>✕ Rechazar</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 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 (
|
||
<div style={S.page}>
|
||
<div style={S.header}>
|
||
<div style={{ display:"flex", alignItems:"center", gap:12 }}>
|
||
<span style={{ fontSize:24 }}>🛡️</span>
|
||
<div>
|
||
<p style={{ margin:0, fontWeight:700, fontSize:16 }}>Panel de Administración</p>
|
||
<p style={{ margin:0, fontSize:12, opacity:0.55 }}>Locales Españoles · {email}</p>
|
||
</div>
|
||
</div>
|
||
<button onClick={cerrarSesion} style={{ background:"rgba(255,255,255,0.1)", border:"1px solid rgba(255,255,255,0.2)", color:"white", padding:"7px 16px", borderRadius:8, cursor:"pointer", fontSize:13, fontFamily:"inherit" }}>
|
||
Cerrar sesión
|
||
</button>
|
||
</div>
|
||
|
||
<div style={S.wrap}>
|
||
{/* Tabs */}
|
||
<div style={{ display:"flex", borderBottom:"2px solid #E8E0D5", marginBottom:24 }}>
|
||
{TABS.map(t => <button key={t.id} onClick={() => setTab(t.id)} style={S.tab(tab===t.id)}>{t.l}</button>)}
|
||
</div>
|
||
|
||
{/* ── Pendientes ── */}
|
||
{tab === "pendientes" && (
|
||
cargando ? <p style={{ color:"#999", textAlign:"center", padding:"3rem" }}>Cargando propuestas...</p>
|
||
: pendientes.length === 0 ? (
|
||
<div style={{ textAlign:"center", padding:"4rem", color:"#AAA" }}>
|
||
<div style={{ fontSize:52, marginBottom:12 }}>🎉</div>
|
||
<p style={{ margin:0, fontSize:16, fontWeight:600, color:"#888" }}>Sin propuestas pendientes</p>
|
||
<p style={{ margin:"6px 0 0", fontSize:13 }}>Cuando un usuario proponga un local, aparecerá aquí para que lo apruebes o rechaces.</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<p style={{ margin:"0 0 16px", fontSize:13, color:"#888" }}>
|
||
{pendientes.length} propuesta{pendientes.length!==1?"s":""} esperando revisión.
|
||
</p>
|
||
{pendientes.map(p => <TarjetaPropuesta key={p.id} p={p} onAprobar={aprobar} onRechazar={rechazar} busy={busy} />)}
|
||
</>
|
||
)
|
||
)}
|
||
|
||
{/* ── Publicados ── */}
|
||
{tab === "publicados" && (
|
||
<>
|
||
<div style={{ marginBottom:16 }}>
|
||
<input style={{ ...S.input, maxWidth:380 }} placeholder="🔍 Buscar por nombre o provincia..." value={buscador} onChange={e => setBuscador(e.target.value)} />
|
||
</div>
|
||
{pubFiltrados.length === 0
|
||
? <p style={{ color:"#AAA", textAlign:"center", padding:"2rem" }}>{publicados.length===0 ? "No hay locales publicados." : "Sin resultados."}</p>
|
||
: pubFiltrados.map(l => (
|
||
<div key={l.id} style={{ ...S.card, padding:"12px 18px", marginBottom:8 }}>
|
||
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"center", gap:12 }}>
|
||
<div style={{ flex:1 }}>
|
||
<div style={{ display:"flex", alignItems:"center", gap:8, flexWrap:"wrap" }}>
|
||
<span style={{ fontWeight:600, fontSize:15 }}>{l.nombre}</span>
|
||
{l.categoria && <span style={S.badge(obtenerCategoria(l.categoria).textColor, obtenerCategoria(l.categoria).bgLight)}>{obtenerCategoria(l.categoria).emoji} {l.categoria}</span>}
|
||
<span style={{ fontSize:12, color:"#AAA" }}>📍 {l.provincia} · {new Date(l.fecha).toLocaleDateString("es-ES",{day:"numeric",month:"short",year:"numeric"})}</span>
|
||
</div>
|
||
{l.direccion && (() => {
|
||
const url = l.enlaceGoogleMaps || enlaceGoogleMapsDesdeLocal(l);
|
||
return url
|
||
? <a href={url} target="_blank" rel="noopener noreferrer" style={{ margin:"3px 0 0", fontSize:13, color:"#888", textDecoration:"underline", display:"block" }}>{l.direccion}</a>
|
||
: <p style={{ margin:"3px 0 0", fontSize:13, color:"#888" }}>{l.direccion}</p>;
|
||
})()}
|
||
</div>
|
||
<button onClick={() => 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</button>
|
||
</div>
|
||
</div>
|
||
))
|
||
}
|
||
</>
|
||
)}
|
||
|
||
{/* ── Reportes ── */}
|
||
{tab === "reportes" && (
|
||
reportes.length === 0 ? (
|
||
<div style={{ textAlign:"center", padding:"4rem", color:"#AAA" }}>
|
||
<div style={{ fontSize:52, marginBottom:12 }}>🚩</div>
|
||
<p style={{ margin:0, fontSize:16, fontWeight:600, color:"#888" }}>Sin reportes pendientes</p>
|
||
<p style={{ margin:"6px 0 0", fontSize:13 }}>Cuando alguien reporte un local (cerrado, datos incorrectos...), aparecerá aquí.</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<p style={{ margin:"0 0 16px", fontSize:13, color:"#888" }}>{reportes.length} reporte{reportes.length!==1?"s":""} sin revisar.</p>
|
||
{reportes.map(r => (
|
||
<div key={r.id} style={{ ...S.card, borderLeft:"4px solid #C0392B", marginBottom:10 }}>
|
||
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"flex-start", flexWrap:"wrap", gap:12 }}>
|
||
<div style={{ flex:1 }}>
|
||
<div style={{ display:"flex", alignItems:"center", gap:8, flexWrap:"wrap", marginBottom:6 }}>
|
||
<span style={{ fontWeight:700, fontSize:16 }}>{r.localNombre || "(local ya eliminado)"}</span>
|
||
<span style={S.badge("#C0392B", "#FDEDEC")}>{{ cerrado:"Ha cerrado", datos_incorrectos:"Datos incorrectos", inapropiado:"Contenido inapropiado", otro:"Otro motivo" }[r.motivo] || r.motivo}</span>
|
||
</div>
|
||
<p style={{ margin:0, fontSize:13, color:"#666" }}>📍 {r.localProvincia} {r.localDireccion ? `· ${r.localDireccion}` : ""}</p>
|
||
{r.comentario && <p style={{ margin:"8px 0 0", fontSize:13, color:"#666", fontStyle:"italic", background:"#FAFAFA", padding:"8px 12px", borderRadius:8 }}>"{r.comentario}"</p>}
|
||
<p style={{ margin:"6px 0 0", fontSize:11, color:"#AAA" }}>Reportado: {new Date(r.fecha).toLocaleDateString("es-ES", { day:"numeric", month:"short", hour:"2-digit", minute:"2-digit" })}</p>
|
||
</div>
|
||
<div style={{ display:"flex", gap:8, flexShrink:0 }}>
|
||
{r.localId && <button onClick={() => eliminar(r.localId)} style={{ ...S.btn("#C0392B"), padding:"8px 16px", fontSize:13 }}>🗑 Eliminar local</button>}
|
||
<button onClick={() => descartar(r.id)} style={{ ...S.btn("#EEE","#666"), padding:"8px 16px", fontSize:13 }}>Descartar aviso</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</>
|
||
)
|
||
)}
|
||
|
||
{/* ── Filtro de palabras ── */}
|
||
{tab === "filtro" && (
|
||
<div style={{ maxWidth:620 }}>
|
||
<div style={S.card}>
|
||
<p style={{ margin:"0 0 4px", fontWeight:700, fontSize:16 }}>Palabras prohibidas</p>
|
||
<p style={{ margin:"0 0 18px", fontSize:13, color:"#666", lineHeight:1.6 }}>
|
||
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.
|
||
</p>
|
||
<div style={{ display:"flex", gap:8, marginBottom:16 }}>
|
||
<input style={{ ...S.input, flex:1 }} placeholder="Añadir palabra o frase..." value={nuevaP}
|
||
onChange={e => setNuevaP(e.target.value)} onKeyDown={e => e.key==="Enter" && addPalabra()} />
|
||
<button onClick={addPalabra} style={S.btn()}>Añadir</button>
|
||
</div>
|
||
{palabras.length === 0
|
||
? <p style={{ color:"#BBB", fontSize:13, fontStyle:"italic" }}>No hay palabras filtradas todavía.</p>
|
||
: <div style={{ display:"flex", flexWrap:"wrap", gap:8, marginBottom:16 }}>
|
||
{palabras.map(p => (
|
||
<span key={p} style={S.tag}>
|
||
{p}
|
||
<button onClick={() => setPalabras(palabras.filter(x=>x!==p))} style={{ background:"none", border:"none", cursor:"pointer", color:"#AAA", padding:0, fontSize:15, lineHeight:1 }}>✕</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
}
|
||
<button onClick={guardarFiltro} disabled={guardando} style={{ ...S.btn(), opacity:guardando?0.6:1 }}>
|
||
{guardando ? "Guardando..." : "💾 Guardar filtro"}
|
||
</button>
|
||
</div>
|
||
<div style={{ ...S.card, background:"#FFFBF0", border:"1px solid #F5E6C8" }}>
|
||
<p style={{ margin:"0 0 8px", fontWeight:600, fontSize:14, color:"#784212" }}>ℹ️ Cómo funciona el flujo completo</p>
|
||
<ol style={{ margin:0, paddingLeft:20, fontSize:13, color:"#555", lineHeight:2 }}>
|
||
<li>El usuario rellena el formulario público y pulsa <em>"Enviar propuesta"</em>.</li>
|
||
<li>Antes de guardar, se comprueba el filtro de palabras. Si hay coincidencia → se rechaza al instante con un aviso.</li>
|
||
<li>Si pasa el filtro → se guarda en <code>/pendientes</code> en Firestore.</li>
|
||
<li>Aquí en el panel aparece la tarjeta. Tú pulsas <strong>Aprobar</strong> → pasa a <code>/locales</code> y se publica. O <strong>Rechazar</strong> → se elimina sin publicar.</li>
|
||
<li>Solo los locales en <code>/locales</code> son visibles en la web y la app móvil.</li>
|
||
</ol>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<Toast toast={toast} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Raíz del panel ────────────────────────────────────────────────────────────
|
||
export default function AdminPanel() {
|
||
const [user, setUser] = useState(undefined);
|
||
useEffect(() => escucharAuth(u => setUser(u)), []);
|
||
if (user === undefined) return <div style={{ ...S.page, display:"flex", alignItems:"center", justifyContent:"center" }}><p style={{ color:"#999" }}>Cargando...</p></div>;
|
||
if (!user) return <PantallaAuth />;
|
||
return <PanelAdmin email={user.email} />;
|
||
}
|