Files
localesp/src/AdminPanel.jsx
T
edgar.friendly 257fb9c622
deploy-branch / deploy (push) Successful in 2m36s
deploy-branch / teardown (push) Skipped
refactor(ui): modulariza App.jsx y unifica branding y caché PWA
- Extrae App.jsx en módulos reutilizables: constants/ (categorias,
  provincias), utils/geo, styles/shared, hooks/usePwaInstall y
  components/ (StarRating, LocalCard, CampoUbicacion, BannerMovil,
  CategoriaBadge)
- AdminPanel reutiliza constantes y componentes compartidos y corrige
  la limpieza de suscripciones de Firebase al desmontar
- Añade logos de marca (logo-localesp.png + @2x) y regenera los iconos
  PWA con tamaños menores
- sw.js: sube la caché del shell a v2 e incluye los nuevos iconos y logos
2026-08-05 19:07:00 +02:00

281 lines
18 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (
<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" });
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 && <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>}
{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);
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 (
<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 && <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. 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} />;
}