2 Commits
Author SHA1 Message Date
edgar.friendly 7bb3ea22da chore(openspec): archiva pasarela-proponer-local y sincroniza specs principales
deploy-branch / teardown (push) Skipped
deploy-branch / deploy (push) Failing after 6s
- Mueve el cambio (36/36 tareas, 4/4 artefactos) a changes/archive/
- Crea las specs principales de boton-flotante-proponer-local,
  pasarela-proponer-local y proveedores-ubicacion (con Purpose)
2026-08-16 11:51:11 +02:00
edgar.friendly 3ce84531d1 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
2026-08-16 11:50:59 +02:00
18 changed files with 420 additions and 103 deletions
@@ -0,0 +1,40 @@
# Botón flotante para proponer locales
## Purpose
Punto de entrada único y siempre visible para proponer un nuevo local al directorio: un botón de acción flotante (FAB) fijo en la esquina inferior derecha, usable en PC y móvil, con tooltip explicativo y apertura de la pasarela de propuesta al pulsarlo.
## Requirements
### Requirement: Botón flotante fijo en la esquina inferior derecha
El sistema SHALL mostrar un botón de acción flotante (FAB) fijado en la esquina inferior derecha de la ventana, siempre visible sobre el contenido, tanto en PC como en móvil.
#### Scenario: Visible en escritorio
- **WHEN** la página se carga en una pantalla de escritorio
- **THEN** el botón flotante aparece fijado en la esquina inferior derecha, por encima del resto del contenido
#### Scenario: Visible en móvil
- **WHEN** la página se carga o se hace scroll en un dispositivo móvil
- **THEN** el botón flotante permanece fijo y accesible en la esquina inferior derecha, sin quedar oculto por el contenido ni interferir con la barra inferior existente
#### Scenario: Reemplazo del botón de cabecera
- **WHEN** la página se carga
- **THEN** ya no existe el botón "Proponer local" en la cabecera y el único punto de entrada para proponer un local es el botón flotante
### Requirement: Tooltip explicativo al pasar el ratón
El botón flotante SHALL mostrar un tooltip al pasar el ratón por encima (hover) y al recibir foco por teclado, explicando que sirve para añadir un nuevo local.
#### Scenario: Tooltip en hover
- **WHEN** el usuario pasa el ratón por encima del botón flotante
- **THEN** aparece un tooltip con un texto explicativo del tipo "Añade un nuevo local al directorio"
#### Scenario: Tooltip accesible sin ratón
- **WHEN** el botón flotante recibe el foco por teclado o se consulta con un lector de pantalla
- **THEN** el botón expone su propósito mediante `aria-label`, de modo que la función sea accesible sin depender del hover
### Requirement: El botón abre la pasarela en un modal
El botón flotante SHALL abrir, al pulsarlo, la pasarela de propuesta de local en un modal por encima de la página.
#### Scenario: Apertura de la pasarela
- **WHEN** el usuario pulsa el botón flotante
- **THEN** se abre un modal con la pasarela de propuesta, comenzando por la primera pantalla (Ubicación)
@@ -0,0 +1,96 @@
# Pasarela de propuesta de local
## Purpose
Experiencia guiada (wizard) en un modal para proponer un local con un solo campo por pantalla —empezando por la ubicación con autodetección del proveedor e inferencia de nombre/provincia—, sin campos de comentario ni puntuación, conservando las validaciones existentes (palabras prohibidas, duplicados, privacidad obligatoria).
## Requirements
### Requirement: Pasarela paso a paso en un modal
El sistema SHALL presentar la propuesta de un local como una pasarela (wizard) dentro de un modal, con un solo campo por pantalla, navegación atrás/siguiente e indicador de progreso.
#### Scenario: Una pantalla por campo
- **WHEN** la pasarela está abierta
- **THEN** cada pantalla muestra únicamente el campo correspondiente al paso actual, con botones para avanzar y retroceder
#### Scenario: Navegación hacia atrás
- **WHEN** el usuario pulsa "Atrás" en una pantalla intermedia
- **THEN** vuelve a la pantalla anterior conservando los datos ya introducidos
#### Scenario: Avance bloqueado sin dato válido
- **WHEN** el campo de la pantalla actual está vacío o es inválido
- **THEN** el botón "Siguiente" está deshabilitado y la pantalla no avanza
#### Scenario: Cierre del modal
- **WHEN** el usuario cierra el modal (botón ✕, botón Cancelar o tecla Escape)
- **THEN** la pasarela se cierra y se descarta el borrador en curso
### Requirement: Orden de las pantallas empezando por Ubicación
La pasarela SHALL presentar las pantallas en este orden: 1) Ubicación, 2) Nombre, 3) Provincia, 4) Categoría, 5) Tipo específico (opcional), 6) Revisión y envío.
#### Scenario: Primera pantalla
- **WHEN** se abre la pasarela
- **THEN** la primera pantalla que se muestra es la de Ubicación
#### Scenario: Tipo específico omitible
- **WHEN** el usuario está en la pantalla de Tipo específico
- **THEN** puede continuar sin seleccionar ninguna subcategoría
### Requirement: Inferencia de Nombre y Provincia desde la Ubicación
El sistema SHALL pre-rellenar los campos Nombre y Provincia a partir de la ubicación introducida en la primera pantalla (geocodificación inversa), permitiendo al usuario corregirlos.
#### Scenario: Nombre y provincia pre-rellenados
- **WHEN** el usuario introduce una ubicación válida en la primera pantalla y esta se resuelve con éxito
- **THEN** al llegar a las pantallas de Nombre y Provincia ambos campos aparecen pre-rellenados con los valores inferidos
#### Scenario: Valores corregibles
- **WHEN** los valores inferidos no son correctos
- **THEN** el usuario puede editarlos libremente antes de enviar
#### Scenario: Degradación sin inferencia
- **WHEN** la inferencia falla o devuelve campos vacíos
- **THEN** las pantallas de Nombre y Provincia aparecen vacías y el usuario los rellena manualmente, sin bloquear la pasarela
### Requirement: Sin campo de comentario ni puntuación
La pasarela SHALL NOT incluir el campo de comentario/descripción ni el campo de puntuación; la propuesta se envía sin esos datos.
#### Scenario: Envío sin comentario ni puntuación
- **WHEN** el usuario completa la pasarela y envía
- **THEN** la propuesta se registra con nombre, provincia, categoría, subcategoría, dirección/enlace y coordenadas, sin descripción y sin puntuación
### Requirement: Revisión y aceptación de privacidad antes de enviar
La última pantalla de la pasarela SHALL mostrar un resumen de los datos introducidos y el checkbox de aceptación de la política de privacidad, siendo este último obligatorio para enviar.
#### Scenario: Envío bloqueado sin aceptar privacidad
- **WHEN** el usuario llega a la pantalla final sin marcar la casilla de privacidad
- **THEN** el botón de envío está deshabilitado hasta que la marque
#### Scenario: Resumen antes de enviar
- **WHEN** el usuario llega a la pantalla final
- **THEN** ve un resumen legible de todos los datos introducidos con posibilidad de volver atrás a corregirlos
### Requirement: Validaciones existentes integradas en la pasarela
La pasarela SHALL conservar las validaciones actuales del formulario: filtro de palabras prohibidas y comprobación de duplicados, mostrando los avisos dentro del modal.
#### Scenario: Palabra prohibida detectada
- **WHEN** el texto introducido contiene una palabra no permitida
- **THEN** se muestra un aviso dentro del modal y la propuesta no se envía
#### Scenario: Posible duplicado detectado
- **WHEN** al enviar se detecta un local igual o muy cercano
- **THEN** se muestra el aviso de duplicado dentro del modal con las coincidencias y las opciones de enviar de todos modos o revisar los datos
#### Scenario: Envío correcto
- **WHEN** la propuesta se envía con éxito
- **THEN** el modal se cierra y aparece la confirmación de propuesta enviada en la página principal
### Requirement: Modal responsive accesible
El modal de la pasarela SHALL ser usable en PC (diálogo centrado) y móvil (ocupando prácticamente toda la pantalla), con foco contenido y cierre mediante Escape.
#### Scenario: Uso en móvil
- **WHEN** la pasarela se abre en una pantalla estrecha
- **THEN** el modal ocupa el ancho completo y es operativo con teclado y scroll táctil
#### Scenario: Foco contenido en el modal
- **WHEN** el modal está abierto
- **THEN** el foco de teclado permanece dentro del modal y la tecla Escape lo cierra
@@ -0,0 +1,77 @@
# Proveedores de ubicación (integraciones extensible)
## Purpose
Sistema extensible de integraciones con backends de ubicación (Google Maps, Waze, OpenStreetMap, coordenadas sueltas y geocodificación Nominatim) bajo una interfaz común (`id`/`detecta`/`extrae`), capaz de autodetectar el proveedor de una entrada libre, extraer coordenadas e inferir nombre y provincia, y ampliable a otras extracciones futuras sin modificar los proveedores existentes.
## Requirements
### Requirement: Interfaz común para proveedores de ubicación
Cada integración con un backend de ubicación SHALL implementarse como una clase que cumpla una misma interfaz: identificarse (`id`), indicar si reconoce una entrada (`detecta(entrada)`) y extraer la información disponible (`extrae(entrada)`).
#### Scenario: Contrato uniforme
- **WHEN** se consulta cualquier proveedor registrado
- **THEN** expone los métodos `detecta(entrada)` y `extrae(entrada)` con la misma firma y semántica, de modo que el consumidor no necesita conocer la clase concreta
### Requirement: Resultado de extracción extensible
El método `extrae(entrada)` SHALL devolver un objeto de resultado extensible que incluya como mínimo las coordenadas (`lat`, `lng`) y el identificador del proveedor que las extrajo, pudiendo incorporar campos adicionales en el futuro sin romper a los consumidores.
#### Scenario: Extracción mínima
- **WHEN** un proveedor extrae una ubicación válida
- **THEN** el resultado contiene `lat`, `lng` y el identificador del proveedor
#### Scenario: Campos adicionales sin ruptura
- **WHEN** en el futuro un proveedor añade nuevos campos al resultado (horarios, teléfono, fotos…)
- **THEN** los consumidores existentes siguen funcionando sin modificación, ignorando los campos que no conocen
### Requirement: Autodetección del proveedor a partir de la entrada
El sistema SHALL autodetectar automáticamente el proveedor adecuado para una entrada dada, recorriendo los proveedores registrados y usando el primero que la reconozca.
#### Scenario: Enlace de Google Maps
- **WHEN** el usuario pega un enlace de Google Maps (formatos `@lat,lng`, `!3d…!4d…` o `?q=lat,lng`)
- **THEN** el sistema lo resuelve con el proveedor de Google Maps y obtiene las coordenadas
#### Scenario: Enlace de Waze
- **WHEN** el usuario pega un enlace de Waze (`waze.com/ul`, parámetro `ll=lat,lng` o búsqueda `q=`)
- **THEN** el sistema lo resuelve con el proveedor de Waze y obtiene las coordenadas
#### Scenario: Enlace de OpenStreetMap
- **WHEN** el usuario pega un enlace de openstreetmap.org (parámetros `mlat`/`mlon` o fragmento `#map=zoom/lat/lng`)
- **THEN** el sistema lo resuelve con el proveedor de OpenStreetMap y obtiene las coordenadas
#### Scenario: Coordenadas geográficas sueltas
- **WHEN** el usuario escribe coordenadas directamente (por ejemplo `41.3851, 2.1734`)
- **THEN** el sistema lo resuelve con el proveedor de coordenadas y obtiene lat/lng
#### Scenario: Texto de dirección como fallback
- **WHEN** la entrada no coincide con ningún enlace soportado y parece una dirección en texto
- **THEN** el sistema la resuelve con el proveedor de geocodificación (Nominatim) realizando una búsqueda por texto
#### Scenario: Entrada no reconocida
- **WHEN** la entrada no es reconocida por ningún proveedor ni resuelta como dirección
- **THEN** el sistema informa de que no se pudo interpretar la ubicación y no avanza de pantalla
### Requirement: Inferencia de nombre y provincia
Los proveedores SHALL extraer, cuando estén disponibles, los campos inferibles `nombre` y `provincia` a partir de la entrada resuelta (geocodificación inversa cuando solo hay coordenadas), normalizando la provincia al listado canónico de provincias de España.
#### Scenario: Inferencia desde coordenadas
- **WHEN** la entrada se resuelve a coordenadas sin más contexto
- **THEN** una geocodificación inversa obtiene un nombre descriptivo y la provincia canónica (coincidencia insensible a acentos y mayúsculas con el listado de PROVINCIAS)
#### Scenario: Provincia fuera del listado
- **WHEN** la provincia inferida no coincide con ninguna provincia del listado canónico
- **THEN** el campo provincia queda vacío para que el usuario lo seleccione manualmente
### Requirement: Extensibilidad sin modificar proveedores existentes
Añadir un proveedor nuevo SHALL consistir en crear una nueva clase que implemente la interfaz y registrarla en el resolver, sin modificar las clases existentes ni la lógica de la pasarela (principio abierto/cerrado).
#### Scenario: Registro de un proveedor nuevo
- **WHEN** se añade una clase `MiProveedor` con `detecta`/`extrae` y se registra en la lista de proveedores
- **THEN** el campo universal empieza a aceptar las entradas que ese proveedor reconoce sin ningún otro cambio de código
### Requirement: Errores de red degradados
Los proveedores SHALL tratar sus errores de red o de servicio devolviendo un fallo controlado, sin romper la pasarela ni propagar excepciones al consumidor.
#### Scenario: Fallo del servicio de geocodificación
- **WHEN** Nominatim no responde o devuelve un error
- **THEN** la pantalla de Ubicación muestra un aviso de error y el usuario puede reintentar o continuar sin inferencia
+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 { CATEGORIAS, COLORES_MAPA, NOMBRES_CATEGORIAS, obtenerCategoria } from "./constants/categorias.js";
import { COORDS_PROVINCIAS, PROVINCIAS, CENTRO_ESPANA } from "./constants/provincias.js"; import { COORDS_PROVINCIAS, PROVINCIAS, CENTRO_ESPANA } from "./constants/provincias.js";
import { distanciaKm as calcularDistanciaKm, enlaceGoogleMapsDesdeLocal, enlaceWazeDesdeLocal } from "./utils/geo.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 LocalCard from "./components/LocalCard.jsx";
import BannerMovil from "./components/BannerMovil.jsx"; import BannerMovil from "./components/BannerMovil.jsx";
import ModalPrivacidad from "./components/ModalPrivacidad.jsx"; import ModalPrivacidad from "./components/ModalPrivacidad.jsx";
import BotonFlotante from "./components/BotonFlotante.jsx"; import BotonFlotante from "./components/BotonFlotante.jsx";
import BotonProponerArriba from "./components/BotonProponerArriba.jsx";
import ModalPasarela from "./components/ModalPasarela.jsx"; import ModalPasarela from "./components/ModalPasarela.jsx";
import useFavoritos from "./hooks/useFavoritos.js"; import useFavoritos from "./hooks/useFavoritos.js";
import useUbicacion from "./hooks/useUbicacion.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) : "—"; const mediaGlobal = locales.length ? (locales.reduce((s, l) => s + l.puntuacion, 0) / locales.length).toFixed(1) : "—";
return ( 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 */} {/* Header */}
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: "1.5rem", gap: 12, flexWrap: "wrap" }}> <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: "1.5rem", gap: 12, flexWrap: "wrap" }}>
<div> <div>
@@ -424,8 +425,9 @@ export default function App() {
</button> </button>
</p> </p>
{/* Botón flotante y pasarela de propuesta */} {/* Botones de propuesta (ambos abren la misma pasarela) y modal */}
<BotonFlotante onClick={abrirPasarela} /> <BotonFlotante onClick={abrirPasarela} />
<BotonProponerArriba onClick={abrirPasarela} />
{pasarelaAbierta && ( {pasarelaAbierta && (
<ModalPasarela <ModalPasarela
form={form} form={form}
+16 -33
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import usePwaInstall from "../hooks/usePwaInstall.js"; import usePwaInstall from "../hooks/usePwaInstall.js";
import useEsMovil from "../hooks/useEsMovil.js"; import useEsMovil from "../hooks/useEsMovil.js";
import { ANCHO_CUERPO } from "../styles/shared.js";
const CLAVE_VISITAS = "localesp_visitas"; const CLAVE_VISITAS = "localesp_visitas";
const CLAVE_CERRADO = "localesp_banner_cerrado_hasta"; const CLAVE_CERRADO = "localesp_banner_cerrado_hasta";
@@ -33,12 +34,12 @@ function estaCerradoRecientemente() {
// modo standalone (o el navegador confirma que está instalada), el aviso // modo standalone (o el navegador confirma que está instalada), el aviso
// se oculta porque ya no aporta nada mostrarlo. // se oculta porque ya no aporta nada mostrarlo.
// //
// Colocación (no debe tapar el FAB de "proponer local", abajo a la derecha): // Colocación: abajo a la IZQUIERDA en PC y móvil, alineada con el borde
// - Móvil: tarjeta completa abajo a la IZQUIERDA, respetando la barra // izquierdo del cuerpo central (columna de ANCHO_CUERPO px), flanqueando el
// inferior de la PWA (env(safe-area-inset-bottom)). // contenido junto al FAB de "proponer local" (abajo a la derecha): en
// - PC: botón compacto arriba a la izquierda; al pulsarlo instala // pantallas anchas acompaña a la columna en vez de pegarse al borde de la
// directamente o, si el navegador no soporta el diálogo nativo, // ventana; en estrechas queda a 16px. Respeta la barra inferior de la PWA
// despliega la tarjeta con las instrucciones. // (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 // 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 // usuario (contador en localStorage) y, si se cierra con ✕, no vuelve a
@@ -46,7 +47,6 @@ function estaCerradoRecientemente() {
export default function BannerMovil() { export default function BannerMovil() {
const [cerrado, setCerrado] = useState(estaCerradoRecientemente); const [cerrado, setCerrado] = useState(estaCerradoRecientemente);
const [visitasSuficientes, setVisitasSuficientes] = useState(false); 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 { isInstalled, canPromptInstall, promptInstall, isIos } = usePwaInstall();
const [instalando, setInstalando] = useState(false); const [instalando, setInstalando] = useState(false);
const [resultado, setResultado] = useState(null); // "accepted" | "dismissed" | null const [resultado, setResultado] = useState(null); // "accepted" | "dismissed" | null
@@ -70,11 +70,8 @@ export default function BannerMovil() {
const outcome = await promptInstall(); const outcome = await promptInstall();
setResultado(outcome); setResultado(outcome);
setInstalando(false); 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 = {}) => ( 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 }}> <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" <button onClick={onCerrarTarjeta} aria-label="Cerrar aviso de instalación"
@@ -113,30 +110,16 @@ export default function BannerMovil() {
</div> </div>
); );
// ── Móvil: tarjeta abajo a la izquierda (el FAB está abajo a la derecha) ── // ── Aviso abajo a la izquierda (PC y móvil), alineado con el borde del cuerpo ──
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 ──
return ( return (
<div style={{ position: "fixed", top: 20, left: 20, zIndex: 999 }}> <div style={{
<div style={{ display: "flex", alignItems: "stretch", gap: 6 }}> position: "fixed",
<button left: `max(16px, calc((100vw - ${ANCHO_CUERPO}px) / 2 + 16px))`,
onClick={canPromptInstall ? handleInstalar : () => setDesplegada((v) => !v)} bottom: 0,
disabled={instalando} zIndex: 999,
aria-expanded={desplegada} paddingBottom: "calc(16px + env(safe-area-inset-bottom))",
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"} {tarjeta(handleCerrar, { width: esMovil ? 280 : 320 })}
</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> </div>
); );
} }
+17 -10
View File
@@ -1,21 +1,28 @@
import { useState } from "react"; 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"; 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 // Botón de acción flotante (FAB) para proponer un local: fijo abajo a la
// inferior derecha, visible en PC y móvil, respetando la barra de la PWA con // derecha, visible en PC y móvil, alineado con el borde derecho del cuerpo
// env(safe-area-inset-bottom). Su z-index queda por debajo del modal de la // central (columna de ANCHO_CUERPO px) para que acompañe al contenido en
// pasarela (1000) y del toast (2000). Muestra un tooltip propio al hacer // pantallas anchas. En PC es más grande (64px) que en móvil (54px). Respeta
// hover o recibir foco (posicionado a la izquierda) y expone aria-label // la barra de la PWA con env(safe-area-inset-bottom). Muestra un tooltip
// para accesibilidad sin ratón. // 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 }) { export default function BotonFlotante({ onClick }) {
const [tooltipVisible, setTooltipVisible] = useState(false); const [tooltipVisible, setTooltipVisible] = useState(false);
const esMovil = useEsMovil();
const tamano = esMovil ? 54 : 64; // más grande en PC
return ( return (
<div <div
style={{ style={{
position: "fixed", 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, bottom: 0,
zIndex: 900, zIndex: 900,
paddingBottom: "env(safe-area-inset-bottom)", paddingBottom: "env(safe-area-inset-bottom)",
@@ -32,10 +39,10 @@ export default function BotonFlotante({ onClick }) {
onBlur={() => setTooltipVisible(false)} onBlur={() => setTooltipVisible(false)}
style={{ style={{
display: "flex", alignItems: "center", justifyContent: "center", display: "flex", alignItems: "center", justifyContent: "center",
width: 54, height: 54, borderRadius: "50%", width: tamano, height: tamano, borderRadius: "50%",
background: "#8B0000", color: "white", border: "none", background: "#8B0000", color: "white", border: "none",
boxShadow: "0 4px 14px rgba(139,0,0,0.4)", boxShadow: esMovil ? "0 4px 14px rgba(139,0,0,0.4)" : "0 6px 20px rgba(139,0,0,0.45)",
fontSize: 24, cursor: "pointer", fontSize: esMovil ? 24 : 28, cursor: "pointer",
}} }}
> >
<i className="ti ti-plus" aria-hidden="true"></i> <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, fontSize: 14,
outline: "none", 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: () => {}, 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(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
estadoHook.isInstalled = false; estadoHook.isInstalled = false;
@@ -35,72 +45,52 @@ afterEach(() => {
cleanup(); cleanup();
}); });
describe("BannerMovil en PC", () => { describe("BannerMovil (PC y móvil): abajo a la izquierda, alineado al cuerpo", () => {
beforeEach(() => vi.stubGlobal("matchMedia", matchMediaMock(false))); it("en PC: tarjeta abajo a la izquierda alineada con el borde del cuerpo central, más ancha", () => {
vi.stubGlobal("matchMedia", matchMediaMock(false));
it("muestra un botón compacto arriba a la izquierda, sin la tarjeta", () => {
render(<BannerMovil />); 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("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" })); const contenedor = contenedorFijo();
expect(screen.queryByText("App para móvil")).toBeNull(); expect(contenedor.style.bottom).toBe("0px");
// la tarjeta se cierra sin activar la regla de 14 días expect(contenedor.getAttribute("style")).toMatch(PATRON_ALINEADO_CUERPO);
expect(localStorage.getItem("localesp_banner_cerrado_hasta")).toBeNull(); 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; estadoHook.canPromptInstall = true;
render(<BannerMovil />); render(<BannerMovil />);
fireEvent.click(screen.getByRole("button", { name: /instalar app/i })); fireEvent.click(screen.getByRole("button", { name: /instalar app/i }));
expect(estadoHook.promptInstall).toHaveBeenCalledTimes(1); 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 />); render(<BannerMovil />);
fireEvent.click(screen.getByRole("button", { name: /ocultar aviso de instalación/i })); expect(screen.getByText(/Instálala desde el menú de tu navegador/i)).toBeTruthy();
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();
}); });
}); });
+40 -2
View File
@@ -1,9 +1,25 @@
// @vitest-environment jsdom // @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 { cleanup, fireEvent, render, screen } from "@testing-library/react";
import BotonFlotante from "../../src/components/BotonFlotante.jsx"; 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", () => { describe("BotonFlotante", () => {
it("expone su propósito con aria-label (accesible sin hover)", () => { it("expone su propósito con aria-label (accesible sin hover)", () => {
@@ -36,4 +52,26 @@ describe("BotonFlotante", () => {
fireEvent.click(screen.getByRole("button")); fireEvent.click(screen.getByRole("button"));
expect(onClick).toHaveBeenCalledTimes(1); 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();
});
});