fix(ui): botones alineados al cuerpo, FAB grande en PC y botón superior de propuesta

- FAB y aviso PWA flanquean la columna central (720px): right/left
  max(16px, calc((100vw - 720px)/2 + 16px)) para acompañar al contenido
  en pantallas anchas sin pegarse al borde de la ventana
- FAB de 64px en PC (54 en móvil); PWA abajo a la izquierda en PC y móvil
- Nuevo BotonProponerArriba (solo PC): píldora "Proponer local" arriba a
  la derecha, alineada al cuerpo, que abre la misma pasarela
This commit is contained in:
2026-08-16 11:50:59 +02:00
parent 003b1e209f
commit 3ce84531d1
8 changed files with 207 additions and 103 deletions
+5 -3
View File
@@ -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 (
<div style={{ maxWidth: 720, margin: "0 auto", padding: "1.5rem 1rem", fontFamily: "var(--font-sans)" }}>
<div style={{ maxWidth: ANCHO_CUERPO, margin: "0 auto", padding: "1.5rem 1rem", fontFamily: "var(--font-sans)" }}>
{/* Header */}
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: "1.5rem", gap: 12, flexWrap: "wrap" }}>
<div>
@@ -424,8 +425,9 @@ export default function App() {
</button>
</p>
{/* Botón flotante y pasarela de propuesta */}
{/* Botones de propuesta (ambos abren la misma pasarela) y modal */}
<BotonFlotante onClick={abrirPasarela} />
<BotonProponerArriba onClick={abrirPasarela} />
{pasarelaAbierta && (
<ModalPasarela
form={form}
+16 -33
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import usePwaInstall from "../hooks/usePwaInstall.js";
import useEsMovil from "../hooks/useEsMovil.js";
import { ANCHO_CUERPO } from "../styles/shared.js";
const CLAVE_VISITAS = "localesp_visitas";
const CLAVE_CERRADO = "localesp_banner_cerrado_hasta";
@@ -33,12 +34,12 @@ function estaCerradoRecientemente() {
// modo standalone (o el navegador confirma que está instalada), el aviso
// se oculta porque ya no aporta nada mostrarlo.
//
// Colocación (no debe tapar el FAB de "proponer local", abajo a la derecha):
// - Móvil: tarjeta completa abajo a la IZQUIERDA, respetando la barra
// inferior de la PWA (env(safe-area-inset-bottom)).
// - PC: botón compacto arriba a la izquierda; al pulsarlo instala
// directamente o, si el navegador no soporta el diálogo nativo,
// despliega la tarjeta con las instrucciones.
// Colocación: abajo a la IZQUIERDA en PC y móvil, alineada con el borde
// izquierdo del cuerpo central (columna de ANCHO_CUERPO px), flanqueando el
// contenido junto al FAB de "proponer local" (abajo a la derecha): en
// pantallas anchas acompaña a la columna en vez de pegarse al borde de la
// ventana; en estrechas queda a 16px. Respeta la barra inferior de la PWA
// (env(safe-area-inset-bottom)) y en PC se muestra algo más grande.
//
// Para no ser intrusivo, solo se muestra a partir de la 2ª visita del
// usuario (contador en localStorage) y, si se cierra con ✕, no vuelve a
@@ -46,7 +47,6 @@ function estaCerradoRecientemente() {
export default function BannerMovil() {
const [cerrado, setCerrado] = useState(estaCerradoRecientemente);
const [visitasSuficientes, setVisitasSuficientes] = useState(false);
const [desplegada, setDesplegada] = useState(false); // tarjeta expandida bajo el botón en PC
const { isInstalled, canPromptInstall, promptInstall, isIos } = usePwaInstall();
const [instalando, setInstalando] = useState(false);
const [resultado, setResultado] = useState(null); // "accepted" | "dismissed" | null
@@ -70,11 +70,8 @@ export default function BannerMovil() {
const outcome = await promptInstall();
setResultado(outcome);
setInstalando(false);
if (outcome === "dismissed") setDesplegada(true); // se muestra el mensaje "más tarde"
};
// Tarjeta completa (instrucciones / instalación). `onCerrarTarjeta`
// difiere: en móvil aplica la regla de 14 días; en PC solo la recoge.
const tarjeta = (onCerrarTarjeta, estiloExtra = {}) => (
<div style={{ position: "relative", background: "white", border: "1px solid #E5DDD5", borderRadius: 14, padding: "14px 18px", boxShadow: "0 6px 24px rgba(0,0,0,0.10)", width: 280, boxSizing: "border-box", fontFamily: "var(--font-sans)", ...estiloExtra }}>
<button onClick={onCerrarTarjeta} aria-label="Cerrar aviso de instalación"
@@ -113,30 +110,16 @@ export default function BannerMovil() {
</div>
);
// ── Móvil: tarjeta abajo a la izquierda (el FAB está abajo a la derecha) ──
if (esMovil) {
return (
<div style={{ position: "fixed", left: 16, bottom: 0, zIndex: 999, paddingBottom: "env(safe-area-inset-bottom)" }}>
{tarjeta(handleCerrar, { marginTop: 16 })}
</div>
);
}
// ── PC: botón compacto arriba a la izquierda ──
// ── Aviso abajo a la izquierda (PC y móvil), alineado con el borde del cuerpo ──
return (
<div style={{ position: "fixed", top: 20, left: 20, zIndex: 999 }}>
<div style={{ display: "flex", alignItems: "stretch", gap: 6 }}>
<button
onClick={canPromptInstall ? handleInstalar : () => setDesplegada((v) => !v)}
disabled={instalando}
aria-expanded={desplegada}
style={{ display: "flex", alignItems: "center", gap: 7, background: "#8B0000", color: "white", border: "none", borderRadius: 999, padding: "9px 16px", fontSize: 13, fontWeight: 700, cursor: instalando ? "default" : "pointer", fontFamily: "var(--font-sans)", boxShadow: "0 4px 14px rgba(139,0,0,0.30)", opacity: instalando ? 0.7 : 1 }}>
{instalando ? "Abriendo instalación..." : "📲 Instalar app"}
</button>
<button onClick={handleCerrar} aria-label="Ocultar aviso de instalación (no volverá a mostrarse en 14 días)" title="No mostrar más durante 14 días"
style={{ background: "white", border: "1px solid #E5DDD5", borderRadius: 999, width: 30, cursor: "pointer", color: "#999", fontSize: 13, boxShadow: "0 4px 14px rgba(0,0,0,0.08)" }}></button>
</div>
{desplegada && tarjeta(() => setDesplegada(false), { marginTop: 8 })}
<div style={{
position: "fixed",
left: `max(16px, calc((100vw - ${ANCHO_CUERPO}px) / 2 + 16px))`,
bottom: 0,
zIndex: 999,
paddingBottom: "calc(16px + env(safe-area-inset-bottom))",
}}>
{tarjeta(handleCerrar, { width: esMovil ? 280 : 320 })}
</div>
);
}
+17 -10
View File
@@ -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 (
<div
style={{
position: "fixed",
right: 16,
// Abajo a la derecha, alineado con el borde derecho del cuerpo central:
// en pantallas anchas flanquea la columna de contenido; en estrechas, 16px.
right: `max(16px, calc((100vw - ${ANCHO_CUERPO}px) / 2 + 16px))`,
bottom: 0,
zIndex: 900,
paddingBottom: "env(safe-area-inset-bottom)",
@@ -32,10 +39,10 @@ export default function BotonFlotante({ onClick }) {
onBlur={() => 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",
}}
>
<i className="ti ti-plus" aria-hidden="true"></i>
+33
View File
@@ -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 (
<button
type="button"
onClick={onClick}
aria-label="Añade un nuevo local al directorio"
style={{
position: "fixed",
top: 20,
right: `max(16px, calc((100vw - ${ANCHO_CUERPO}px) / 2 + 16px))`,
zIndex: 900,
display: "flex", alignItems: "center", gap: 8,
background: "#8B0000", color: "white", border: "none", borderRadius: 999,
padding: "11px 22px", fontSize: 15, fontWeight: 600,
cursor: "pointer", fontFamily: "var(--font-sans)",
boxShadow: "0 4px 14px rgba(139,0,0,0.35)",
}}
>
<i className="ti ti-plus" aria-hidden="true"></i> Proponer local
</button>
);
}
+6
View File
@@ -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;
+45 -55
View File
@@ -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(<BannerMovil />);
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(<BannerMovil />);
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(<BannerMovil />);
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(<BannerMovil />);
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(<BannerMovil />);
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(<BannerMovil />);
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(<BannerMovil />);
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(<BannerMovil />);
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();
});
});
+40 -2
View File
@@ -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(<BotonFlotante onClick={() => {}} />);
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(<BotonFlotante onClick={() => {}} />);
const boton = screen.getByRole("button");
expect(boton.style.width).toBe("54px");
expect(boton.style.fontSize).toBe("24px");
});
});
@@ -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(<BotonProponerArriba onClick={onClick} />);
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(<BotonProponerArriba onClick={() => {}} />);
expect(screen.queryByRole("button")).toBeNull();
});
});