From d48b74cbd4a8f2a4c5c76a3e41d29be2df7a8813 Mon Sep 17 00:00:00 2001 From: Edgar Friendly Date: Sat, 15 Aug 2026 20:37:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20notificaciones=20toast=20y=20mapa?= =?UTF-8?q?=20que=20sobrevive=20al=20cambio=20de=20pesta=C3=B1a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- src/AdminPanel.jsx | 33 +++++++++++++++++++----- src/App.jsx | 50 +++++++++++++++++++++--------------- src/components/LocalCard.jsx | 13 +++++++++- src/components/Toast.jsx | 24 +++++++++++++++++ src/hooks/useToast.js | 18 +++++++++++++ 5 files changed, 109 insertions(+), 29 deletions(-) create mode 100644 src/components/Toast.jsx create mode 100644 src/hooks/useToast.js diff --git a/src/AdminPanel.jsx b/src/AdminPanel.jsx index 84dfca7..eb86ca6 100644 --- a/src/AdminPanel.jsx +++ b/src/AdminPanel.jsx @@ -11,6 +11,10 @@ import { 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" }, @@ -41,7 +45,10 @@ function PantallaAuth() { 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 { + 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); }; @@ -127,9 +134,20 @@ function PanelAdmin({ email }) { 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; @@ -143,10 +161,10 @@ function PanelAdmin({ email }) { 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); cargarReportes(); } catch(e) { alert("Error: "+e.message); } }; - const descartar = async (id) => { try { await descartarReporte(id); setReportes(r => r.filter(x => x.id !== id)); } catch(e) { alert("Error: "+e.message); } }; + 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(); @@ -155,8 +173,8 @@ function PanelAdmin({ email }) { }; const guardarFiltro = async () => { setGuardando(true); - try { await guardarPalabrasFiltradas(palabras); alert("✅ Filtro guardado."); } - catch(e) { alert("Error: "+e.message); } + try { await guardarPalabrasFiltradas(palabras); mostrarToast("💾 Filtro de palabras guardado", "exito"); } + catch(e) { mostrarToast("Error: "+e.message, "error"); } setGuardando(false); }; @@ -318,6 +336,7 @@ function PanelAdmin({ email }) { )} + ); } diff --git a/src/App.jsx b/src/App.jsx index 010664c..1e9b281 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -167,11 +167,19 @@ export default function App() { return a.nombre.localeCompare(b.nombre); }); - // Mapa Leaflet + // Mapa Leaflet. El contenedor se mantiene siempre montado (ver más abajo, + // solo se oculta con CSS) para que la instancia de Leaflet no se pierda al + // cambiar de pestaña. Si ya existe, solo recalculamos su tamaño: Leaflet no + // lo hace solo cuando el contenedor pasa de display:none a visible, y por + // eso antes hacía falta refrescar la página para que el mapa se viera bien. useEffect(() => { if (vista !== "mapa") return; const timer = setTimeout(() => { if (!mapRef.current) return; + if (mapInstanceRef.current) { + mapInstanceRef.current.invalidateSize(); + return; + } if (!window.L) { const link = document.createElement("link"); link.rel = "stylesheet"; @@ -468,28 +476,28 @@ export default function App() { )} - {vista === "mapa" && ( -
-
- {NOMBRES_CATEGORIAS.map((cat) => ( - - - {CATEGORIAS[cat].emoji} {cat} - - ))} -
- {locales.length === 0 &&
Todavía no hay locales publicados
} - {locales.length > 0 && localesFiltrados.length === 0 &&
Ningún local coincide con el filtro seleccionado
} -
-
- - - {localesFiltrados.length} {localesFiltrados.length === 1 ? "local marcado" : "locales marcados"} - {localesFiltrados.length !== locales.length && ` de ${locales.length}`} + {/* El contenedor del mapa se mantiene siempre montado (solo se oculta con + CSS) para que Leaflet no pierda su instancia al cambiar de pestaña. */} +
+
+ {NOMBRES_CATEGORIAS.map((cat) => ( + + + {CATEGORIAS[cat].emoji} {cat} -
+ ))}
- )} + {locales.length === 0 &&
Todavía no hay locales publicados
} + {locales.length > 0 && localesFiltrados.length === 0 &&
Ningún local coincide con el filtro seleccionado
} +
+
+ + + {localesFiltrados.length} {localesFiltrados.length === 1 ? "local marcado" : "locales marcados"} + {localesFiltrados.length !== locales.length && ` de ${locales.length}`} + +
+

Los locales pasan por revisión antes de publicarse · ¿Tienes un negocio? Proponlo arriba diff --git a/src/components/LocalCard.jsx b/src/components/LocalCard.jsx index 7b1adcd..4a85279 100644 --- a/src/components/LocalCard.jsx +++ b/src/components/LocalCard.jsx @@ -2,7 +2,9 @@ import { useState } from "react"; import CategoriaBadge from "./CategoriaBadge.jsx"; import StarRating from "./StarRating.jsx"; import ReportarLocal from "./ReportarLocal.jsx"; +import Toast from "./Toast.jsx"; import useFavoritos from "../hooks/useFavoritos.js"; +import useToast from "../hooks/useToast.js"; import { confirmarLocal } from "../firebase.js"; import { enlaceGoogleMapsDesdeLocal, enlaceWazeDesdeLocal, formatearDistancia } from "../utils/geo.js"; @@ -24,6 +26,7 @@ function mesesDesde(fecha) { export default function LocalCard({ local, distanciaKm }) { const { esFavorito, alternarFavorito } = useFavoritos(); + const { toast, mostrarToast } = useToast(); const [confirmando, setConfirmando] = useState(false); const [confirmado, setConfirmado] = useState(false); const [fechaConfirmacion, setFechaConfirmacion] = useState(local.fechaConfirmacion || local.fecha); @@ -47,6 +50,12 @@ export default function LocalCard({ local, distanciaKm }) { setConfirmando(false); }; + const handleAlternarFavorito = () => { + const nuevoEstado = !favorito; + alternarFavorito(local.id); + mostrarToast(nuevoEstado ? "❤️ Añadido a favoritos" : "Quitado de favoritos", nuevoEstado ? "exito" : "info"); + }; + const compartir = async () => { const url = `${window.location.origin}${window.location.pathname}?local=${local.id}`; const texto = `${local.nombre}${local.provincia ? ` · ${local.provincia}` : ""}`; @@ -82,7 +91,7 @@ export default function LocalCard({ local, distanciaKm }) { style={{ background: "none", border: "none", cursor: "pointer", fontSize: 16, color: "var(--color-text-tertiary)", padding: 4 }}> -

+ + ); } diff --git a/src/components/Toast.jsx b/src/components/Toast.jsx new file mode 100644 index 0000000..1d2afa6 --- /dev/null +++ b/src/components/Toast.jsx @@ -0,0 +1,24 @@ +const ESTILOS = { + info: { bg: "#2C3E50", color: "white", icono: "ti-info-circle" }, + exito: { bg: "#1E8449", color: "white", icono: "ti-check" }, + error: { bg: "#C0392B", color: "white", icono: "ti-x" }, +}; + +// Aviso flotante breve, de solo lectura: se controla desde el hook useToast. +export default function Toast({ toast }) { + if (!toast) return null; + const { bg, color, icono } = ESTILOS[toast.tipo] || ESTILOS.info; + return ( +
+ + {toast.mensaje} + +
+ ); +} diff --git a/src/hooks/useToast.js b/src/hooks/useToast.js new file mode 100644 index 0000000..2e6f617 --- /dev/null +++ b/src/hooks/useToast.js @@ -0,0 +1,18 @@ +import { useCallback, useRef, useState } from "react"; + +// Aviso breve tipo "snackbar" que aparece y desaparece solo. Uso: +// const { toast, mostrarToast } = useToast(); +// mostrarToast("Añadido a favoritos", "exito"); +// +export default function useToast() { + const [toast, setToast] = useState(null); // { mensaje, tipo } + const timerRef = useRef(null); + + const mostrarToast = useCallback((mensaje, tipo = "info", duracion = 2200) => { + clearTimeout(timerRef.current); + setToast({ mensaje, tipo, key: Date.now() }); + timerRef.current = setTimeout(() => setToast(null), duracion); + }, []); + + return { toast, mostrarToast }; +}