Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bb3ea22da | ||
|
|
3ce84531d1 |
@@ -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
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
// ── Aviso abajo a la izquierda (PC y móvil), alineado con el borde del cuerpo ──
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user