feat(ui): notificaciones toast y mapa que sobrevive al cambio de pestaña
- 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).
This commit is contained in:
+26
-7
@@ -11,6 +11,10 @@ import {
|
|||||||
import { obtenerCategoria } from "./constants/categorias.js";
|
import { obtenerCategoria } from "./constants/categorias.js";
|
||||||
import { enlaceGoogleMapsDesdeLocal, enlaceWazeDesdeLocal } from "./utils/geo.js";
|
import { enlaceGoogleMapsDesdeLocal, enlaceWazeDesdeLocal } from "./utils/geo.js";
|
||||||
import StarRating from "./components/StarRating.jsx";
|
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 = {
|
const S = {
|
||||||
page: { minHeight:"100vh", background:"#F2EDE8", fontFamily:"-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" },
|
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 (!email || !pass) { setErr("Rellena todos los campos."); return; }
|
||||||
if (modo === "registro" && pass !== pass2) { setErr("Las contraseñas no coinciden."); return; }
|
if (modo === "registro" && pass !== pass2) { setErr("Las contraseñas no coinciden."); return; }
|
||||||
setBusy(true);
|
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)); }
|
catch(e) { setErr(errMsg(e.code)); }
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
};
|
};
|
||||||
@@ -127,9 +134,20 @@ function PanelAdmin({ email }) {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [guardando, setGuardando] = useState(false);
|
const [guardando, setGuardando] = useState(false);
|
||||||
const [buscador, setBuscador] = useState("");
|
const [buscador, setBuscador] = useState("");
|
||||||
|
const { toast, mostrarToast } = useToast();
|
||||||
|
|
||||||
const cargarReportes = () => obtenerReportes().then(setReportes).catch(() => {});
|
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(() => {
|
useEffect(() => {
|
||||||
setCargando(true);
|
setCargando(true);
|
||||||
let cancelado = false;
|
let cancelado = false;
|
||||||
@@ -143,10 +161,10 @@ function PanelAdmin({ email }) {
|
|||||||
return () => { cancelado = true; limpiarPendientes?.(); limpiarPublicados?.(); };
|
return () => { cancelado = true; limpiarPendientes?.(); limpiarPublicados?.(); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const aprobar = async (p) => { setBusy(true); try { await aprobarPropuesta(p); } catch(e) { alert("Error: "+e.message); } setBusy(false); };
|
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); } catch(e) { alert("Error: "+e.message); } };
|
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(); } catch(e) { alert("Error: "+e.message); } };
|
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)); } catch(e) { alert("Error: "+e.message); } };
|
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 addPalabra = () => {
|
||||||
const p = nuevaP.trim().toLowerCase();
|
const p = nuevaP.trim().toLowerCase();
|
||||||
@@ -155,8 +173,8 @@ function PanelAdmin({ email }) {
|
|||||||
};
|
};
|
||||||
const guardarFiltro = async () => {
|
const guardarFiltro = async () => {
|
||||||
setGuardando(true);
|
setGuardando(true);
|
||||||
try { await guardarPalabrasFiltradas(palabras); alert("✅ Filtro guardado."); }
|
try { await guardarPalabrasFiltradas(palabras); mostrarToast("💾 Filtro de palabras guardado", "exito"); }
|
||||||
catch(e) { alert("Error: "+e.message); }
|
catch(e) { mostrarToast("Error: "+e.message, "error"); }
|
||||||
setGuardando(false);
|
setGuardando(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -318,6 +336,7 @@ function PanelAdmin({ email }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Toast toast={toast} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-4
@@ -167,11 +167,19 @@ export default function App() {
|
|||||||
return a.nombre.localeCompare(b.nombre);
|
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(() => {
|
useEffect(() => {
|
||||||
if (vista !== "mapa") return;
|
if (vista !== "mapa") return;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (!mapRef.current) return;
|
if (!mapRef.current) return;
|
||||||
|
if (mapInstanceRef.current) {
|
||||||
|
mapInstanceRef.current.invalidateSize();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!window.L) {
|
if (!window.L) {
|
||||||
const link = document.createElement("link");
|
const link = document.createElement("link");
|
||||||
link.rel = "stylesheet";
|
link.rel = "stylesheet";
|
||||||
@@ -468,8 +476,9 @@ export default function App() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{vista === "mapa" && (
|
{/* El contenedor del mapa se mantiene siempre montado (solo se oculta con
|
||||||
<div style={{ borderRadius: "var(--border-radius-lg)", overflow: "hidden", border: "0.5px solid var(--color-border-tertiary)" }}>
|
CSS) para que Leaflet no pierda su instancia al cambiar de pestaña. */}
|
||||||
|
<div style={{ display: vista === "mapa" ? "block" : "none", borderRadius: "var(--border-radius-lg)", overflow: "hidden", border: "0.5px solid var(--color-border-tertiary)" }}>
|
||||||
<div style={{ padding: "8px 12px", background: "var(--color-background-secondary)", borderBottom: "0.5px solid var(--color-border-tertiary)", display: "flex", flexWrap: "wrap", gap: 10 }}>
|
<div style={{ padding: "8px 12px", background: "var(--color-background-secondary)", borderBottom: "0.5px solid var(--color-border-tertiary)", display: "flex", flexWrap: "wrap", gap: 10 }}>
|
||||||
{NOMBRES_CATEGORIAS.map((cat) => (
|
{NOMBRES_CATEGORIAS.map((cat) => (
|
||||||
<span key={cat} style={{ fontSize: 11, display: "flex", alignItems: "center", gap: 4, color: "var(--color-text-secondary)" }}>
|
<span key={cat} style={{ fontSize: 11, display: "flex", alignItems: "center", gap: 4, color: "var(--color-text-secondary)" }}>
|
||||||
@@ -489,7 +498,6 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", textAlign: "center", marginTop: "1.5rem" }}>
|
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", textAlign: "center", marginTop: "1.5rem" }}>
|
||||||
Los locales pasan por revisión antes de publicarse · ¿Tienes un negocio? Proponlo arriba
|
Los locales pasan por revisión antes de publicarse · ¿Tienes un negocio? Proponlo arriba
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { useState } from "react";
|
|||||||
import CategoriaBadge from "./CategoriaBadge.jsx";
|
import CategoriaBadge from "./CategoriaBadge.jsx";
|
||||||
import StarRating from "./StarRating.jsx";
|
import StarRating from "./StarRating.jsx";
|
||||||
import ReportarLocal from "./ReportarLocal.jsx";
|
import ReportarLocal from "./ReportarLocal.jsx";
|
||||||
|
import Toast from "./Toast.jsx";
|
||||||
import useFavoritos from "../hooks/useFavoritos.js";
|
import useFavoritos from "../hooks/useFavoritos.js";
|
||||||
|
import useToast from "../hooks/useToast.js";
|
||||||
import { confirmarLocal } from "../firebase.js";
|
import { confirmarLocal } from "../firebase.js";
|
||||||
import { enlaceGoogleMapsDesdeLocal, enlaceWazeDesdeLocal, formatearDistancia } from "../utils/geo.js";
|
import { enlaceGoogleMapsDesdeLocal, enlaceWazeDesdeLocal, formatearDistancia } from "../utils/geo.js";
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ function mesesDesde(fecha) {
|
|||||||
|
|
||||||
export default function LocalCard({ local, distanciaKm }) {
|
export default function LocalCard({ local, distanciaKm }) {
|
||||||
const { esFavorito, alternarFavorito } = useFavoritos();
|
const { esFavorito, alternarFavorito } = useFavoritos();
|
||||||
|
const { toast, mostrarToast } = useToast();
|
||||||
const [confirmando, setConfirmando] = useState(false);
|
const [confirmando, setConfirmando] = useState(false);
|
||||||
const [confirmado, setConfirmado] = useState(false);
|
const [confirmado, setConfirmado] = useState(false);
|
||||||
const [fechaConfirmacion, setFechaConfirmacion] = useState(local.fechaConfirmacion || local.fecha);
|
const [fechaConfirmacion, setFechaConfirmacion] = useState(local.fechaConfirmacion || local.fecha);
|
||||||
@@ -47,6 +50,12 @@ export default function LocalCard({ local, distanciaKm }) {
|
|||||||
setConfirmando(false);
|
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 compartir = async () => {
|
||||||
const url = `${window.location.origin}${window.location.pathname}?local=${local.id}`;
|
const url = `${window.location.origin}${window.location.pathname}?local=${local.id}`;
|
||||||
const texto = `${local.nombre}${local.provincia ? ` · ${local.provincia}` : ""}`;
|
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 }}>
|
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 16, color: "var(--color-text-tertiary)", padding: 4 }}>
|
||||||
<i className="ti ti-share-2" aria-hidden="true"></i>
|
<i className="ti ti-share-2" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => alternarFavorito(local.id)} title={favorito ? "Quitar de favoritos" : "Guardar en favoritos"}
|
<button type="button" onClick={handleAlternarFavorito} title={favorito ? "Quitar de favoritos" : "Guardar en favoritos"}
|
||||||
aria-pressed={favorito}
|
aria-pressed={favorito}
|
||||||
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 16, color: favorito ? "#8B0000" : "var(--color-text-tertiary)", padding: 4 }}>
|
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 16, color: favorito ? "#8B0000" : "var(--color-text-tertiary)", padding: 4 }}>
|
||||||
<i className={favorito ? "ti ti-heart-filled" : "ti ti-heart"} aria-hidden="true"></i>
|
<i className={favorito ? "ti ti-heart-filled" : "ti ti-heart"} aria-hidden="true"></i>
|
||||||
@@ -152,6 +161,8 @@ export default function LocalCard({ local, distanciaKm }) {
|
|||||||
)}
|
)}
|
||||||
<ReportarLocal localId={local.id} />
|
<ReportarLocal localId={local.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Toast toast={toast} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div key={toast.key} role="status" aria-live="polite" style={{
|
||||||
|
position: "fixed", left: "50%", bottom: 24, transform: "translateX(-50%)",
|
||||||
|
background: bg, color, padding: "10px 18px", borderRadius: 999, fontSize: 13,
|
||||||
|
fontWeight: 500, boxShadow: "0 6px 20px rgba(0,0,0,0.18)", zIndex: 2000,
|
||||||
|
display: "flex", alignItems: "center", gap: 8, maxWidth: "90vw",
|
||||||
|
animation: "localesp-toast-in 0.25s ease-out",
|
||||||
|
}}>
|
||||||
|
<i className={`ti ${icono}`} aria-hidden="true" style={{ fontSize: 15 }}></i>
|
||||||
|
{toast.mensaje}
|
||||||
|
<style>{"@keyframes localesp-toast-in{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}}"}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
// <Toast toast={toast} />
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user