diff --git a/src/App.jsx b/src/App.jsx index e89b61e..96caf4d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -3,11 +3,12 @@ import { comprobarDuplicado, contienepalabrasProhibidas, enviarPropuesta, obtene import { CATEGORIAS, COLORES_MAPA, NOMBRES_CATEGORIAS, obtenerCategoria } from "./constants/categorias.js"; import { COORDS_PROVINCIAS, PROVINCIAS, CENTRO_ESPANA } from "./constants/provincias.js"; import { distanciaKm as calcularDistanciaKm, enlaceGoogleMapsDesdeLocal, enlaceWazeDesdeLocal } from "./utils/geo.js"; -import { inputStyle } from "./styles/shared.js"; +import { inputStyle, ANCHO_CUERPO } from "./styles/shared.js"; import LocalCard from "./components/LocalCard.jsx"; import BannerMovil from "./components/BannerMovil.jsx"; import ModalPrivacidad from "./components/ModalPrivacidad.jsx"; import BotonFlotante from "./components/BotonFlotante.jsx"; +import BotonProponerArriba from "./components/BotonProponerArriba.jsx"; import ModalPasarela from "./components/ModalPasarela.jsx"; import useFavoritos from "./hooks/useFavoritos.js"; import useUbicacion from "./hooks/useUbicacion.js"; @@ -254,7 +255,7 @@ export default function App() { const mediaGlobal = locales.length ? (locales.reduce((s, l) => s + l.puntuacion, 0) / locales.length).toFixed(1) : "—"; return ( -
+
{/* Header */}
@@ -424,8 +425,9 @@ export default function App() {

- {/* Botón flotante y pasarela de propuesta */} + {/* Botones de propuesta (ambos abren la misma pasarela) y modal */} + {pasarelaAbierta && ( (
- -
- {desplegada && tarjeta(() => setDesplegada(false), { marginTop: 8 })} +
+ {tarjeta(handleCerrar, { width: esMovil ? 280 : 320 })}
); } diff --git a/src/components/BotonFlotante.jsx b/src/components/BotonFlotante.jsx index c556f47..8e3496b 100644 --- a/src/components/BotonFlotante.jsx +++ b/src/components/BotonFlotante.jsx @@ -1,21 +1,28 @@ import { useState } from "react"; +import useEsMovil from "../hooks/useEsMovil.js"; +import { ANCHO_CUERPO } from "../styles/shared.js"; const TEXTO_TOOLTIP = "Añade un nuevo local al directorio"; -// Botón de acción flotante (FAB) para proponer un local: fijo en la esquina -// inferior derecha, visible en PC y móvil, respetando la barra de la PWA con -// env(safe-area-inset-bottom). Su z-index queda por debajo del modal de la -// pasarela (1000) y del toast (2000). Muestra un tooltip propio al hacer -// hover o recibir foco (posicionado a la izquierda) y expone aria-label -// para accesibilidad sin ratón. +// Botón de acción flotante (FAB) para proponer un local: fijo abajo a la +// derecha, visible en PC y móvil, alineado con el borde derecho del cuerpo +// central (columna de ANCHO_CUERPO px) para que acompañe al contenido en +// pantallas anchas. En PC es más grande (64px) que en móvil (54px). Respeta +// la barra de la PWA con env(safe-area-inset-bottom). Muestra un tooltip +// propio al hacer hover o recibir foco (posicionado a la izquierda) y expone +// aria-label para accesibilidad sin ratón. export default function BotonFlotante({ onClick }) { const [tooltipVisible, setTooltipVisible] = useState(false); + const esMovil = useEsMovil(); + const tamano = esMovil ? 54 : 64; // más grande en PC return (
setTooltipVisible(false)} style={{ display: "flex", alignItems: "center", justifyContent: "center", - width: 54, height: 54, borderRadius: "50%", + width: tamano, height: tamano, borderRadius: "50%", background: "#8B0000", color: "white", border: "none", - boxShadow: "0 4px 14px rgba(139,0,0,0.4)", - fontSize: 24, cursor: "pointer", + boxShadow: esMovil ? "0 4px 14px rgba(139,0,0,0.4)" : "0 6px 20px rgba(139,0,0,0.45)", + fontSize: esMovil ? 24 : 28, cursor: "pointer", }} > diff --git a/src/components/BotonProponerArriba.jsx b/src/components/BotonProponerArriba.jsx new file mode 100644 index 0000000..da32877 --- /dev/null +++ b/src/components/BotonProponerArriba.jsx @@ -0,0 +1,33 @@ +import useEsMovil from "../hooks/useEsMovil.js"; +import { ANCHO_CUERPO } from "../styles/shared.js"; + +// Botón "Proponer local" arriba a la derecha (solo PC): hace exactamente lo +// mismo que el FAB inferior — abre la pasarela de propuesta — pero da un +// punto de acceso visible en la zona superior, alineado con el borde derecho +// del cuerpo central (columna de ANCHO_CUERPO px) para acompañar al contenido +// en pantallas anchas. En móvil no se renderiza (ahí basta el FAB inferior). +export default function BotonProponerArriba({ onClick }) { + const esMovil = useEsMovil(); + if (esMovil) return null; + + return ( + + ); +} diff --git a/src/styles/shared.js b/src/styles/shared.js index fe036a2..38fecc1 100644 --- a/src/styles/shared.js +++ b/src/styles/shared.js @@ -14,3 +14,9 @@ export const inputStyle = { fontSize: 14, outline: "none", }; + +// Ancho máximo del cuerpo central de la página (App.jsx). Los elementos fijos +// (p. ej. el aviso PWA en PC) se alinean a su borde de contenido con +// calc((100vw - ANCHO_CUERPO) / 2 + padding), de modo que respiren con la +// columna aunque la ventana sea muy ancha. +export const ANCHO_CUERPO = 720; diff --git a/tests/component/BannerMovil.spec.jsx b/tests/component/BannerMovil.spec.jsx index c2eff81..ca6428e 100644 --- a/tests/component/BannerMovil.spec.jsx +++ b/tests/component/BannerMovil.spec.jsx @@ -22,6 +22,16 @@ const matchMediaMock = (esMovil) => (_query) => ({ removeListener: () => {}, }); +// En pantallas anchas la tarjeta acompaña a la columna de 720px (mismo borde +// que el contenido); en estrechas queda a 16px del borde de la ventana. +const PATRON_ALINEADO_CUERPO = /left:\s*max\(16px,\s*[^;]*100vw[^;]*720px[^;]*16px[^;]*\)/; + +function contenedorFijo() { + const fijos = [...document.querySelectorAll("div")].filter((d) => d.style.position === "fixed"); + expect(fijos.length).toBe(1); + return fijos[0]; +} + beforeEach(() => { vi.clearAllMocks(); estadoHook.isInstalled = false; @@ -35,72 +45,52 @@ afterEach(() => { cleanup(); }); -describe("BannerMovil en PC", () => { - beforeEach(() => vi.stubGlobal("matchMedia", matchMediaMock(false))); - - it("muestra un botón compacto arriba a la izquierda, sin la tarjeta", () => { +describe("BannerMovil (PC y móvil): abajo a la izquierda, alineado al cuerpo", () => { + it("en PC: tarjeta abajo a la izquierda alineada con el borde del cuerpo central, más ancha", () => { + vi.stubGlobal("matchMedia", matchMediaMock(false)); render(); - const boton = screen.getByRole("button", { name: /instalar app/i }); - expect(boton).toBeTruthy(); - - const contenedor = boton.closest("div").parentElement; - expect(contenedor.style.position).toBe("fixed"); - expect(contenedor.style.top).toBe("20px"); - expect(contenedor.style.left).toBe("20px"); - expect(screen.queryByText("App para móvil")).toBeNull(); // tarjeta plegada - }); - - it("sin diálogo nativo, el botón despliega la tarjeta con instrucciones; el ✕ de la tarjeta solo la recoge", () => { - render(); - const boton = screen.getByRole("button", { name: /instalar app/i }); - expect(boton.getAttribute("aria-expanded")).toBe("false"); - - fireEvent.click(boton); expect(screen.getByText("App para móvil")).toBeTruthy(); - expect(screen.getByText(/Instálala desde el menú de tu navegador/i)).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: "Cerrar aviso de instalación" })); - expect(screen.queryByText("App para móvil")).toBeNull(); - // la tarjeta se cierra sin activar la regla de 14 días - expect(localStorage.getItem("localesp_banner_cerrado_hasta")).toBeNull(); + const contenedor = contenedorFijo(); + expect(contenedor.style.bottom).toBe("0px"); + expect(contenedor.getAttribute("style")).toMatch(PATRON_ALINEADO_CUERPO); + expect(contenedor.style.right).toBe(""); // nunca a la derecha, ahí vive el FAB + // en PC la tarjeta es más grande + const tarjeta = screen.getByRole("button", { name: "Cerrar aviso de instalación" }).parentElement; + expect(tarjeta.style.width).toBe("320px"); }); - it("con diálogo nativo, el botón instala directamente sin desplegar la tarjeta", async () => { + it("en móvil: tarjeta abajo a la izquierda, a 16px en ventana estrecha, más compacta", () => { + vi.stubGlobal("matchMedia", matchMediaMock(true)); + render(); + const contenedor = contenedorFijo(); + // misma fórmula: con vw < 720px el navegador la evalúa a 16px + expect(contenedor.getAttribute("style")).toMatch(PATRON_ALINEADO_CUERPO); + expect(contenedor.style.bottom).toBe("0px"); + expect(contenedor.style.right).toBe(""); + const tarjeta = screen.getByRole("button", { name: "Cerrar aviso de instalación" }).parentElement; + expect(tarjeta.style.width).toBe("280px"); + }); + + it("el ✕ aplica la regla de 14 días y oculta el aviso", () => { + vi.stubGlobal("matchMedia", matchMediaMock(false)); + render(); + fireEvent.click(screen.getByRole("button", { name: "Cerrar aviso de instalación" })); + expect(localStorage.getItem("localesp_banner_cerrado_hasta")).toBeTruthy(); + expect(screen.queryByText("App para móvil")).toBeNull(); + }); + + it("con diálogo nativo, el botón de la tarjeta instala directamente", async () => { + vi.stubGlobal("matchMedia", matchMediaMock(false)); estadoHook.canPromptInstall = true; render(); fireEvent.click(screen.getByRole("button", { name: /instalar app/i })); expect(estadoHook.promptInstall).toHaveBeenCalledTimes(1); - expect(screen.queryByText("App para móvil")).toBeNull(); }); - it("el ✕ del botón aplica la regla de 14 días y oculta el aviso", () => { + it("sin diálogo nativo muestra las instrucciones del navegador (no iOS)", () => { + vi.stubGlobal("matchMedia", matchMediaMock(false)); render(); - fireEvent.click(screen.getByRole("button", { name: /ocultar aviso de instalación/i })); - expect(localStorage.getItem("localesp_banner_cerrado_hasta")).toBeTruthy(); - expect(screen.queryByRole("button", { name: /instalar app/i })).toBeNull(); - }); -}); - -describe("BannerMovil en móvil", () => { - beforeEach(() => vi.stubGlobal("matchMedia", matchMediaMock(true))); - - it("muestra la tarjeta abajo a la izquierda (sin invadir el FAB de la derecha)", () => { - render(); - expect(screen.getByText("App para móvil")).toBeTruthy(); - const fijos = [...document.querySelectorAll("div")].filter((d) => d.style.position === "fixed"); - expect(fijos.length).toBe(1); - const contenedor = fijos[0]; - expect(contenedor.style.position).toBe("fixed"); - expect(contenedor.style.left).toBe("16px"); - expect(contenedor.style.bottom).toBe("0px"); - // (el paddingBottom: env(safe-area-inset-bottom) no se serializa en jsdom; se verifica visualmente) - expect(contenedor.style.right).toBe(""); // nunca a la derecha, ahí vive el FAB - }); - - it("el ✕ de la tarjeta aplica la regla de 14 días", () => { - render(); - fireEvent.click(screen.getByRole("button", { name: "Cerrar aviso de instalación" })); - expect(localStorage.getItem("localesp_banner_cerrado_hasta")).toBeTruthy(); - expect(screen.queryByText("App para móvil")).toBeNull(); + expect(screen.getByText(/Instálala desde el menú de tu navegador/i)).toBeTruthy(); }); }); diff --git a/tests/component/BotonFlotante.spec.jsx b/tests/component/BotonFlotante.spec.jsx index 1761a91..48aafdd 100644 --- a/tests/component/BotonFlotante.spec.jsx +++ b/tests/component/BotonFlotante.spec.jsx @@ -1,9 +1,25 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import BotonFlotante from "../../src/components/BotonFlotante.jsx"; -afterEach(cleanup); +const matchMediaMock = (esMovil) => (_query) => ({ + matches: esMovil, + media: _query, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, +}); + +beforeEach(() => { + // por defecto PC (botón grande); cada prueba puede sobreescribirlo + vi.stubGlobal("matchMedia", matchMediaMock(false)); +}); +afterEach(() => { + vi.unstubAllGlobals(); + cleanup(); +}); describe("BotonFlotante", () => { it("expone su propósito con aria-label (accesible sin hover)", () => { @@ -36,4 +52,26 @@ describe("BotonFlotante", () => { fireEvent.click(screen.getByRole("button")); expect(onClick).toHaveBeenCalledTimes(1); }); + + it("está abajo a la derecha, alineado con el borde derecho del cuerpo central, y en PC es más grande", () => { + render( {}} />); + const boton = screen.getByRole("button"); + const contenedor = boton.closest("div").parentElement; // button → relativo → fijo + expect(contenedor.style.position).toBe("fixed"); + expect(contenedor.style.bottom).toBe("0px"); + // en pantallas anchas acompaña a la columna de 720px; mínimo 16px + expect(contenedor.getAttribute("style")).toMatch(/right:\s*max\(16px,\s*[^;]*100vw[^;]*720px[^;]*16px[^;]*\)/); + // en PC el FAB mide 64px (en móvil 54) + expect(boton.style.width).toBe("64px"); + expect(boton.style.height).toBe("64px"); + expect(boton.style.fontSize).toBe("28px"); + }); + + it("en móvil mantiene el tamaño compacto (54px)", () => { + vi.stubGlobal("matchMedia", matchMediaMock(true)); + render( {}} />); + const boton = screen.getByRole("button"); + expect(boton.style.width).toBe("54px"); + expect(boton.style.fontSize).toBe("24px"); + }); }); diff --git a/tests/component/BotonProponerArriba.spec.jsx b/tests/component/BotonProponerArriba.spec.jsx new file mode 100644 index 0000000..a0d7bd7 --- /dev/null +++ b/tests/component/BotonProponerArriba.spec.jsx @@ -0,0 +1,45 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import BotonProponerArriba from "../../src/components/BotonProponerArriba.jsx"; + +const matchMediaMock = (esMovil) => (_query) => ({ + matches: esMovil, + media: _query, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, +}); + +beforeEach(() => { + // por defecto PC; cada prueba que lo necesite lo sobreescribe + vi.stubGlobal("matchMedia", matchMediaMock(false)); +}); +afterEach(() => { + vi.unstubAllGlobals(); + cleanup(); +}); + +describe("BotonProponerArriba (solo PC)", () => { + it("en PC: píldora arriba a la derecha, alineada con el borde del cuerpo, que abre la pasarela", () => { + const onClick = vi.fn(); + render(); + const boton = screen.getByRole("button", { name: /añade un nuevo local al directorio/i }); + expect(boton.textContent).toContain("Proponer local"); + + expect(boton.style.position).toBe("fixed"); + expect(boton.style.top).toBe("20px"); + // alineado al borde derecho de la columna de 720px; mínimo 16px + expect(boton.getAttribute("style")).toMatch(/right:\s*max\(16px,\s*[^;]*100vw[^;]*720px[^;]*16px[^;]*\)/); + + fireEvent.click(boton); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("en móvil no se renderiza", () => { + vi.stubGlobal("matchMedia", matchMediaMock(true)); + render( {}} />); + expect(screen.queryByRole("button")).toBeNull(); + }); +});