Primera versión
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
// 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";
|
||||
|
||||
const CATEGORIAS = {
|
||||
"Restauración": { emoji:"🍽️", color:"#8B0000", bg:"#FFF0F0" },
|
||||
"Pequeño comercio": { emoji:"🛍️", color:"#1A5276", bg:"#EBF5FB" },
|
||||
"Peluquería y estética": { emoji:"✂️", color:"#76448A", bg:"#F5EEF8" },
|
||||
"Servicios del hogar": { emoji:"🔧", color:"#1E8449", bg:"#EAFAF1" },
|
||||
"Otros": { emoji:"📌", color:"#784212", bg:"#FDF2E9" },
|
||||
};
|
||||
|
||||
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 (
|
||||
<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 = CATEGORIAS[p.categoria] || {};
|
||||
const fecha = new Date(p.fechaPropuesta).toLocaleDateString("es-ES", { day:"numeric", month:"short", hour:"2-digit", minute:"2-digit" });
|
||||
return (
|
||||
<div style={{ ...S.card, borderLeft:`4px solid ${cat.color||"#CCC"}`, 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>
|
||||
{cat.emoji && <span style={S.badge(cat.color, cat.bg)}>{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", gap:16, fontSize:13, color:"#666" }}>
|
||||
<span>📍 {p.provincia}</span>
|
||||
{p.direccion && <span>🗺 {p.direccion}</span>}
|
||||
<span style={{ color:"#D4AF37" }}>{"★".repeat(p.puntuacion||0)}{"☆".repeat(5-(p.puntuacion||0))}</span>
|
||||
<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>}
|
||||
{p.enlaceGoogleMaps && <a href={p.enlaceGoogleMaps} target="_blank" rel="noopener noreferrer" style={{ display:"inline-block", marginTop:6, fontSize:12, color:"#1A73E8" }}>Ver en Google Maps ↗</a>}
|
||||
</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 [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);
|
||||
const u1 = suscribirPendientes(l => { setPendientes(l); setCargando(false); }, () => setCargando(false));
|
||||
const u2 = suscribirLocales(l => setPublicados(l), () => {});
|
||||
obtenerPalabrasFiltradas().then(p => setPalabras(p));
|
||||
return () => { u1(); u2(); };
|
||||
}, []);
|
||||
|
||||
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 (
|
||||
<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>
|
||||
{CATEGORIAS[l.categoria] && <span style={S.badge(CATEGORIAS[l.categoria].color, CATEGORIAS[l.categoria].bg)}>{CATEGORIAS[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 && <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>
|
||||
))
|
||||
}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 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>
|
||||
</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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user