chore(openspec): archiva pasarela-proponer-local y sincroniza specs principales
- 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)
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-15
|
||||
@@ -0,0 +1,116 @@
|
||||
# Diseño — Pasarela para proponer locales
|
||||
|
||||
## Context
|
||||
|
||||
- `src/App.jsx` es la única app en producción (PC y móvil): `main.jsx` solo monta `App` y `AdminPanel`; `src/AppMovil.jsx` es código muerto no referenciado. Por tanto "funcionar para PC y móvil" se resuelve haciendo el FAB y el modal responsive dentro de `App.jsx`.
|
||||
- El formulario actual es un bloque en línea (`mostrarForm`) con nombre, provincia, categoría, subcategoría, ubicación (dos modos: dirección / enlace de Google Maps), descripción y puntuación, más checkbox de privacidad, filtro de palabras prohibidas y comprobación de duplicados contra `server/index.js`.
|
||||
- El backend ya tolera la ausencia de `descripcion` (columna nullable) y `puntuacion` (`?? 0` al insertar), así que eliminarlos del cliente no exige cambios de API ni migraciones.
|
||||
- La extracción de coordenadas hoy es una única función `parsearEnlaceGoogleMaps` en `src/utils/geo.js`, y la geocodificación por texto se hace con Nominatim (`geocodificarDireccion`, `buscarLugares`).
|
||||
- Convenciones del proyecto: estilos inline con variables CSS (`--color-*`), componentes pequeños en `src/components/`, utilidades puras en `src/utils/`, sin dependencias de UI externas. El patrón de modal existente es `ModalPrivacidad.jsx` (overlay `fixed` + `stopPropagation` + `role="dialog"`).
|
||||
- PWA instalada (service worker): el FAB debe convivir con la barra de navegación de la PWA en iOS/Android (`env(safe-area-inset-bottom)`).
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Sustituir el botón de cabecera por un FAB fijo abajo a la derecha, con tooltip, usable en PC y móvil.
|
||||
- Pasarela en modal con un campo por pantalla, empezando por Ubicación.
|
||||
- Campo universal de ubicación que autodetecta Google Maps / Waze / OpenStreetMap / coordenadas (fallback: texto vía Nominatim).
|
||||
- Inferir Nombre y Provincia desde la ubicación (geocodificación inversa Nominatim), editables.
|
||||
- Arquitectura de proveedores extensible: varias clases con una interfaz común (`detecta`/`extrae`), resultado extensible, registro abierto/cerrado.
|
||||
- Eliminar comentario y puntuación del flujo de propuesta.
|
||||
|
||||
**Non-Goals:**
|
||||
- Gestionar comentarios/valoraciones de otra forma (cambio posterior).
|
||||
- Modificar backend, base de datos, panel de administración o `AppMovil.jsx`.
|
||||
- Autocompletado en la pantalla de Nombre (se anota como mejora futura; ahora es un input simple pre-rellenado).
|
||||
- Mapa de previsualización dentro del modal (se anota como mejora futura).
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 — Un único `App.jsx` responsive para PC y móvil
|
||||
**Decisión:** FAB y pasarela se implementan en `App.jsx`; no se toca `AppMovil.jsx` (muerto).
|
||||
**Alternativas:** duplicar la UI en `AppMovil.jsx` — rechazado: duplicaría lógica y mantiene vivo código que no está en producción.
|
||||
|
||||
### D2 — FAB con tooltip propio, sin dependencias
|
||||
**Decisión:** botón `position: fixed; bottom/right` con `z-index` por debajo del modal (modal usa 1000) y `padding-bottom: env(safe-area-inset-bottom)` en móvil. Tooltip implementado con estado React (`onMouseEnter`/`onMouseLeave`/`onFocus`/`onBlur`) y posicionado a la izquierda del botón, con `aria-label` en el botón para accesibilidad sin ratón.
|
||||
**Alternativas:** atributo `title` nativo — rechazado por no ser estilizable ni fiable en móvil/táctil; librería de tooltips — rechazada para no añadir dependencias a un proyecto sin ellas.
|
||||
|
||||
### D3 — Modal propio siguiendo el patrón `ModalPrivacidad`
|
||||
**Decisión:** overlay `fixed` con `role="dialog"`/`aria-modal`, cierre con click en fondo, ✕ y `Escape` (listener de teclado en `useEffect`), foco inicial en el primer control de cada pantalla. En pantallas estrechas (`matchMedia` vía hook `useEsMovil`), el panel ocupa todo el ancho y casi todo el alto; en escritorio queda centrado con `maxWidth` ~480px. Sin portal: se renderiza al final del árbol de `App`, igual que `ModalPrivacidad`.
|
||||
**Alternativas:** `createPortal` — innecesario aquí (no hay `overflow: hidden` ancestros problemáticos); librería de modals — descartada por política de cero dependencias de UI.
|
||||
|
||||
### D4 — Pasarela como máquina de pasos sobre el `form` existente
|
||||
**Decisión:** el estado del formulario (`form`) vive en `App.jsx` como hoy; la pasarela añade un índice `paso` y un array declarativo de pantallas: `[ubicacion, nombre, provincia, categoria, subcategoria, resumen]`. Cada pantalla es un componente que recibe `form`/`setForm` y expone su validez (`puedeContinuar`) para habilitar "Siguiente". "Atrás" simplemente decrementa el índice (los datos se conservan). La pantalla final muestra el resumen + checkbox de privacidad y dispara el flujo de envío existente (`contienepalabrasProhibidas` → `comprobarDuplicado` → `guardarPropuesta`), con los avisos de error/duplicado renderizados dentro del modal.
|
||||
**Alternativas:** cada paso con estado propio y despachar al final — más propenso a inconsistencias; mover el envío a un hook nuevo — innecesario, la lógica actual ya está aislada en funciones de `App.jsx`.
|
||||
|
||||
### D5 — `FORM_VACIO` sin `descripcion` ni `puntuacion`
|
||||
**Decisión:** el objeto vacío del formulario elimina ambos campos y se dejan de enviar en el payload (`enviarPropuesta` los omite; el backend ya aplica defaults). `formValido` deja de exigir `puntuacion > 0`. `StarRating` deja de usarse en la propuesta (se conserva para mostrar valoraciones existentes en tarjetas).
|
||||
**Alternativas:** seguir enviando `puntuacion: 0` y `descripcion: ""` explícitos — innecesario: el servidor ya normaliza.
|
||||
|
||||
### D6 — Arquitectura de proveedores de ubicación (núcleo extensible)
|
||||
**Decisión:** nuevo módulo `src/utils/ubicacion/`:
|
||||
|
||||
```
|
||||
src/utils/ubicacion/
|
||||
ProveedorUbicacion.js # clase base = interfaz + helpers compartidos
|
||||
proveedorGoogleMaps.js # detecta: *.google.* / maps.app.goo.gl …
|
||||
proveedorWaze.js # detecta: waze.com/ul?ll=… (o q=texto → delega en Nominatim)
|
||||
proveedorOSM.js # detecta: openstreetmap.org (mlat/mlon, #map=…)
|
||||
proveedorCoordenadas.js # detecta: "41.38, 2.17" (± grados, coma o punto)
|
||||
proveedorNominatim.js # fallback: texto de dirección → search; y reverse() para inferir
|
||||
index.js # registro ordenado + resolverUbicacion(entrada) + inferirDesdeCoords()
|
||||
```
|
||||
|
||||
- **Interfaz** (JS no tiene interfaces nativas): la clase base `ProveedorUbicacion` define el contrato documentado — `id`, `detecta(entrada) → boolean`, `extrae(entrada) → Promise<ResultadoUbicacion|null>` (la base lanza `Error("sin implementar")`, como una abstracta) — y helpers comunes: cabeceras/UA para Nominatim, `normalizar()` de textos y `emparejarProvincia()` contra `PROVINCIAS` (insensible a acentos/mayúsculas, reutilizando la lógica de `AutocompletarLocal`).
|
||||
- **Resultado extensible:** `{ proveedor, lat, lng, nombre?, provincia?, direccion?, …extra }`. Los consumidores leen solo lo que conocen; añadir campos mañana (teléfono, horarios…) no rompe nada.
|
||||
- **Resolver:** `resolverUbicacion(entrada)` recorre el array `PROVEEDORES` en orden de prioridad (coordenadas → Google → Waze → OSM → Nominatim texto) y devuelve el resultado del primero cuyo `detecta` acepte. Registrar un proveedor nuevo = añadir la clase y push al array (abierto/cerrado: ni el resolver ni las demás clases cambian).
|
||||
- **Inferencia:** tras obtener coordenadas, `inferirDesdeCoords(lat, lng)` llama a `Nominatim reverse` (`/reverse?format=jsonv2&addressdetails=1&namedetails=1`) y devuelve `{ nombre, provincia, direccion }` con la provincia normalizada al listado canónico (si no casa, provincia `""` para forzar selección manual). `geocodificarDireccion`/`parsearEnlaceGoogleMaps` de `geo.js` se reubican como implementación interna de los proveedores (se mantiene `geo.js` exportando lo que usan `LocalCard` y el mapa: enlaces y distancias).
|
||||
- **Errores de red:** todo `fetch` de proveedor va envuelto para devolver `null`/`{ ok: false }` controlado; la UI muestra aviso, nunca lanza al consumidor.
|
||||
|
||||
**Alternativas:** funciones sueltas con un `switch` — no extensible (habría que editar el switch por cada backend); cadena de responsabilidad con instancias singleton — equivalente pero más boilerplate en un codebase sin clases hasta ahora; *strategy registry* en JSON/config — pierde la capacidad de lógica por proveedor (p.ej. Waze delegando en Nominatim para `q=`).
|
||||
|
||||
### D7 — Pantalla de Ubicación: un solo input con feedback inmediato
|
||||
**Decisión:** un input universal con debounce (~600ms). Al resolver, muestra un chip con el proveedor detectado (p. ej. "Google Maps detectado") y las coordenadas; si hay inferencia, se marcan `nombre`/`provincia` en el `form` (el usuario las ve ya pre-rellenadas en sus pantallas). Si `detecta` no reconoce nada, aviso "No se pudo interpretar la ubicación" y "Siguiente" deshabilitado hasta obtener una resolución válida.
|
||||
**Alternativas:** mantener los dos modos (dirección/enlace) — rechazado por requisito ("un solo campo universal"); validar solo al pulsar Siguiente — peor UX, no avisa de errores mientras se pega el enlace.
|
||||
|
||||
### D8 — Envío y confirmación
|
||||
**Decisión:** al enviar, el modal permanece abierto con estados "Comprobando…/Enviando…" y, si hay duplicado, muestra el aviso con las opciones actuales ("Enviar de todos modos" / "Revisar datos"). Tras éxito: cierra modal, resetea `form`, y muestra el banner de confirmación existente (`enviado`).
|
||||
**Alternativas:** mover el banner dentro del modal — innecesario y menos visible.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Rate-limit de Nominatim (política ~1 req/s)] → Debounce de 600ms, una sola llamada por resolución (search **o** reverse, no ambas salvo fallback), y mensaje de error amable con reintento.
|
||||
- [FAB solapa contenido o la barra PWA en móvil] → `safe-area-inset-bottom`, tamaño moderado (52–56px) y `z-index` inferior al modal; se prueba en viewport estrecho.
|
||||
- [Teclado móvil sube y tapa el modal] → modal con `height` flexible y `overflow-y: auto`; inputs al inicio de cada pantalla.
|
||||
- [Enlaces acortados (maps.app.goo.gl, waze.to) sin coordenadas en la URL] → v1 no hace *unshorten* (requiere fetch al proveedor y CORS no garantizado): se avisa "pega el enlace completo"; se anota como mejora futura.
|
||||
- [Inferencia errónea (nombre genérico tipo "Calle Mayor")] → los campos son editables y la pantalla de resumen lo hace evidente antes de enviar.
|
||||
- [JS sin interfaces nativas] → el contrato se documenta en la clase base y se comprueba en el registro (métodos presentes); riesgo bajo al ser codebase propio.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Implementación puramente frontend (rama/PR normal). Sin cambios de esquema ni de API: desplegar y listo.
|
||||
2. Rollback: revert del deploy (Netlify) — el backend nunca cambia de forma durante este cambio.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- ¿Querremos *unshorten* de enlaces acortados en el futuro? (Anotado como mejora; no bloquea.) -> mejora
|
||||
- ¿Autocompletado en la pantalla de Nombre reutilizando `AutocompletarLocal`? (Anotado como mejora futura; hoy input simple pre-rellenado.)
|
||||
|
||||
## Extensión: pruebas automatizadas (añadida tras la implementación inicial)
|
||||
|
||||
La verificación manual (sección 5 de tasks) se complementa con una suite programática para poder regresionar sin navegador. Se decide:
|
||||
|
||||
### D9 — Stack de pruebas ligado a Vite, sin dependencias de más
|
||||
**Decisión:** `vitest` como runner (mismo ecosistema que el proyecto, sin Jest), `jsdom` + `@testing-library/react` para pruebas de componente (con `// @vitest-environment jsdom` por archivo) y `@testing-library/dom` como peer obligatorio. El `fetch` se simula con `vi.stubGlobal` — no se añade msw ni librerías de mock HTTP, en línea con la política de cero dependencias extra del proyecto.
|
||||
**Alternativas:** Jest + jsdom — duplicaría configuración fuera del ecosistema Vite; msw — potente pero innecesario para el alcance actual.
|
||||
|
||||
### D10 — Integración con el backend local: supertest + BD en memoria
|
||||
**Decisión:** refactor mínimo y sin cambio de comportamiento en `server/`: `server/index.js` exporta `app` y solo escucha cuando se ejecuta como script (`node server/index.js`), y `server/db.js` admite `process.env.LOCALESP_DB` (incluido `:memory:`) como ruta de la base de datos. Las pruebas de integración usan `supertest` contra la app Express con SQLite en memoria: totalmente offline y deterministas, sin puerto real.
|
||||
**Alternativas:** levantar el servidor en un puerto efímero y hacer fetch real — más frágil y lento; mockear la BD — dejaría de ser integración.
|
||||
|
||||
### D11 — Integración con Nominatim real: suite opt-in respetuosa con el rate-limit
|
||||
**Decisión:** suite separada ejecutada con `npm run test:integration` (config propia) que hace llamadas reales a Nominatim: sondea la conectividad en `beforeAll` y se auto-salta (`describe.skipIf`) sin red; pocos casos secuenciales con ~1 req/s para respetar la política de uso. La suite rápida (`npm test`) queda 100% offline para CI.
|
||||
**Alternativas:** grabar/replicar respuestas — mantendría los fixtures sincronizados con la API real; llamadas reales en la suite por defecto — haría el CI dependiente de un servicio externo.
|
||||
|
||||
### D12 — Alias de nombres cooficiales de provincias (endurecimiento)
|
||||
Al escribir las pruebas surgió que respuestas de Nominatim en euskera/catalán/gallego (`Bizkaia`, `Girona`, `Lleida`, `Ourense`, `Illes Balears`, `Asturies`) no casaban con el listado canónico. `ProveedorUbicacion.emparejarProvincia` gana un mapa pequeño de alias normalizados → provincia canónica, cubierto por pruebas unitarias.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Pasarela para proponer locales
|
||||
|
||||
## Why
|
||||
|
||||
El botón "Proponer local" actual vive en la cabecera y despliega un formulario largo en línea con muchos campos a la vez (nombre, provincia, categoría, ubicación con dos modos, descripción y puntuación). En móvil resulta abrumador y la ubicación exige que el usuario elija manualmente entre "dirección" o "enlace de Google Maps". Queremos una experiencia guiada, campo a campo, que arranque por la ubicación (un único campo universal que acepte enlaces de Google Maps, Waze, OpenStreetMap o coordenadas) y que infiera automáticamente nombre y provincia, reduciendo el esfuerzo del proponente y los errores de datos.
|
||||
|
||||
## What Changes
|
||||
|
||||
- El botón "Proponer local" pasa de la cabecera a un **botón flotante (FAB) fijo en la esquina inferior derecha**, visible y usable tanto en PC como en móvil.
|
||||
- Al pasar el ratón por encima (hover / foco), muestra un **tooltip** que explica que sirve para añadir un nuevo local.
|
||||
- Al pulsarlo se abre una **pasarela (wizard) en un modal**: cada campo se especifica en su propia pantalla, con navegación atrás/siguiente e indicador de progreso.
|
||||
- **Primera pantalla: Ubicación**, un solo campo universal que autodetecta el origen del input: enlace de Google Maps, enlace de Waze, enlace de OpenStreetMap o coordenadas geográficas (y texto de dirección como fallback vía Nominatim).
|
||||
- **Nombre y Provincia se infieren** automáticamente a partir de la ubicación introducida (geocodificación inversa con Nominatim), quedando pre-rellenados en sus pantallas para que el usuario los confirme o corrija.
|
||||
- **Integraciones con backends de mapas extensible**: varias clases (adaptadores) implementan una misma interfaz (`detecta`/`extrae`), registradas en un resolver que autodetecta el proveedor. El resultado de `extrae` es un objeto extensible (coordenadas hoy, otros campos en el futuro). Añadir un proveedor nuevo no debe requerir tocar el resto del código.
|
||||
- **Se elimina el campo comentario/descripción** del formulario de propuesta (se gestionará en otro cambio posterior).
|
||||
- **Se elimina el campo puntuación** del formulario de propuesta.
|
||||
- Se mantiene la lógica existente de validación: filtro de palabras prohibidas, comprobación de duplicados (avisos dentro del modal) y aceptación de la política de privacidad antes de enviar.
|
||||
- No hay cambios de API: el backend ya tolera `descripcion` y `puntuacion` ausentes (valores por defecto).
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `boton-flotante-proponer-local`: botón de acción flotante fijo en la esquina inferior derecha, responsive (PC y móvil), con tooltip al hover y apertura de la pasarela al pulsarlo.
|
||||
- `pasarela-proponer-local`: modal de propuesta paso a paso con una pantalla por campo (Ubicación → Nombre → Provincia → Categoría → Tipo específico → Revisión y envío), sin campos de comentario ni puntuación.
|
||||
- `proveedores-ubicacion`: sistema extensible de integraciones con backends de ubicación (Google Maps, Waze, OpenStreetMap, coordenadas y geocodificación Nominatim) bajo una interfaz común, capaz de extraer coordenadas e inferir nombre y provincia, ampliable a otras extracciones futuras.
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- No existen specs previas en openspec/specs/ — todas las capacidades son nuevas -->
|
||||
|
||||
## Impact
|
||||
|
||||
- **`src/App.jsx`**: se elimina el botón de cabecera y el formulario en línea (`mostrarForm`); se sustituye por el FAB + modal. La lógica de envío (filtro de palabras, duplicados, `enviarPropuesta`) se reutiliza.
|
||||
- **Nuevos componentes** (`src/components/`): botón flotante, modal de la pasarela y pantallas de cada paso.
|
||||
- **Nuevos módulos** (`src/utils/proveedores/`): interfaz común, adaptadores por proveedor y resolver de autodetección.
|
||||
- **`src/utils/geo.js`**: se reutiliza/amplía (geocodificación inversa vía Nominatim; extracción de coordenadas por proveedor).
|
||||
- **Sin cambios de backend**: `server/index.js` y la base de datos ya aceptan propuestas sin `descripcion` ni `puntuacion` (defaults `null`/`0`); el panel de administración no se modifica.
|
||||
- **`src/AppMovil.jsx`** no está referenciado por `main.jsx` (código muerto): no se toca.
|
||||
- Accesibilidad: tooltip también vía `aria-label`, foco atrapado en el modal, cierre con Escape.
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Botón flotante para proponer locales
|
||||
|
||||
## ADDED 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)
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
# Pasarela de propuesta de local
|
||||
|
||||
## ADDED 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
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
# Proveedores de ubicación (integraciones extensible)
|
||||
|
||||
## ADDED 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
|
||||
@@ -0,0 +1,70 @@
|
||||
## 1. Proveedores de ubicación (núcleo extensible)
|
||||
|
||||
- [x] 1.1 Crear `src/utils/ubicacion/ProveedorUbicacion.js`: clase base con el contrato documentado (`id`, `detecta(entrada)`, `extrae(entrada)` que lanza "sin implementar") y helpers compartidos (`normalizar()`, `emparejarProvincia()` contra `PROVINCIAS`, fetch a Nominatim con cabeceras y manejo de errores que devuelve `null` en fallo)
|
||||
- [x] 1.2 Implementar `proveedorCoordenadas.js`: detecta pares "lat, lng" (coma o punto, signo, espacios) y devuelve `{ proveedor, lat, lng }`
|
||||
- [x] 1.3 Implementar `proveedorGoogleMaps.js`: detecta URLs de Google Maps y extrae coordenadas de los formatos `@lat,lng`, `!3d…!4d…` y `?q=lat,lng` (migrando `parsearEnlaceGoogleMaps` de `geo.js`)
|
||||
- [x] 1.4 Implementar `proveedorWaze.js`: detecta `waze.com` y extrae de `ll=lat,lng`; si solo hay `q=texto`, delega la resolución en la búsqueda de Nominatim marcando el proveedor como waze
|
||||
- [x] 1.5 Implementar `proveedorOSM.js`: detecta `openstreetmap.org` y extrae de `mlat`/`mlon` y del fragmento `#map=zoom/lat/lng`
|
||||
- [x] 1.6 Implementar `proveedorNominatim.js`: búsqueda por texto (`/search`, migrando `geocodificarDireccion`) como fallback de dirección, y método `reverse(lat, lng)` (`/reverse` con `addressdetails=1&namedetails=1`) que devuelve `{ nombre, provincia, direccion }` con provincia normalizada al listado canónimo (vacía si no casa)
|
||||
- [x] 1.7 Crear `src/utils/ubicacion/index.js`: array `PROVEEDORES` ordenado por prioridad (coordenadas → Google → Waze → OSM → Nominatim), función `resolverUbicacion(entrada)` que usa el primer `detecta` que acepte, y `inferirDesdeCoords(lat, lng)` que envuelve el reverse de Nominatim con manejo de errores
|
||||
- [x] 1.8 Ajustar `src/utils/geo.js`: conservar exportaciones usadas por `LocalCard`/mapa (`enlaceGoogleMapsDesdeLocal`, `enlaceWazeDesdeLocal`, `distanciaKm`, `formatearDistancia`, `buscarLugares`) y reubicar lo migrado en los proveedores, sin romper imports existentes
|
||||
|
||||
## 2. Pantallas de la pasarela
|
||||
|
||||
- [x] 2.1 Crear `PasoUbicacion.jsx`: input universal con debounce (~600ms) que llama a `resolverUbicacion`; al resolver muestra chip con el proveedor detectado y coordenadas; llama a `inferirDesdeCoords` y pre-rellena `nombre`/`provincia`/`direccion` en el `form`; muestra avisos de entrada no reconocida o error de red; "Siguiente" solo habilitado con resolución válida
|
||||
- [x] 2.2 Crear `PasoNombre.jsx` y `PasoProvincia.jsx`: inputs simples pre-rellenados con lo inferido (editables); provincia como select del listado `PROVINCIAS`; vacíos si la inferencia falló
|
||||
- [x] 2.3 Crear `PasoCategoria.jsx` y `PasoSubcategoria.jsx`: selects con `CATEGORIAS`/`NOMBRES_CATEGORIAS`; subcategoría opcional (permite continuar sin elegir), solo accesible tras elegir categoría
|
||||
- [x] 2.4 Crear `PasoResumen.jsx`: resumen legible de todos los datos, checkbox de aceptación de privacidad (enlace al `ModalPrivacidad` existente) y botón "Enviar propuesta" deshabilitado sin aceptación; renderiza avisos de palabra prohibida y de duplicado (con "Enviar de todos modos"/"Revisar datos") dentro del modal
|
||||
|
||||
## 3. Modal de la pasarela y FAB
|
||||
|
||||
- [x] 3.1 Crear `ModalPasarela.jsx`: overlay siguiendo el patrón de `ModalPrivacidad` (`role="dialog"`, `aria-modal`, cierre por fondo/✕/Escape), cabecera con título e indicador de progreso, cuerpo con la pantalla activa y pie con botones Atrás/Siguiente; en móvil panel a ancho completo y casi todo el alto (hook `useEsMovil` con `matchMedia`), en escritorio centrado `maxWidth≈480px`; foco inicial en el primer control de cada pantalla
|
||||
- [x] 3.2 Crear `BotonFlotante.jsx`: FAB fijo abajo a la derecha (`z-index` < modal, `padding-bottom: env(safe-area-inset-bottom)`), con tooltip propio al hover/foco ("Añade un nuevo local al directorio") posicionado a la izquierda y `aria-label` en el botón
|
||||
- [x] 3.3 Integrar en `App.jsx`: eliminar el botón "Proponer local" de la cabecera y el bloque de formulario en línea; renderizar `BotonFlotante` (abre el modal) y `ModalPasarela`; sustituir `mostrarForm` por estado `pasarelaAbierta`; actualizar el texto del pie "Proponlo arriba" para referirse al botón flotante
|
||||
|
||||
## 4. Envío de la propuesta
|
||||
|
||||
- [x] 4.1 Actualizar `FORM_VACIO` y el flujo de envío en `App.jsx`: quitar `descripcion` y `puntuacion` del formulario y del payload de `enviarPropuesta`; `formValido` sin requisito de puntuación; conservar filtro de palabras, `comprobarDuplicado`, fallback de coordenadas por provincia y confirmación `enviado` tras cerrar el modal; estados "Comprobando…/Enviando…" dentro del modal
|
||||
|
||||
## 5. Verificación
|
||||
|
||||
- [x] 5.1 Probar manualmente los formatos de entrada del campo universal: enlace Google Maps (@/, !3d!4d, ?q=), Waze (ll= y q=), OSM (mlat/mlon y #map=), coordenadas sueltas y dirección en texto; verificar aviso con entrada no reconocida
|
||||
> Verificado a nivel de módulo contra los proveedores reales (con red): los 17 casos pasan, incluidos Waze `q=` delegando en Nominatim y entradas no reconocidas devolviendo `null` (dispara el aviso del paso). Falta el clic-through visual en navegador.
|
||||
|
||||
- [x] 5.2 Verificar inferencia: nombre y provincia pre-rellenados desde coordenadas de varias comunidades (normalización de acentos), y flujo completo de propuesta sin comentario ni puntuación con aviso de duplicado y de palabra prohibida dentro del modal
|
||||
> Verificado `npm test` (82/82: 51 unitarias + 31 componente) y `npm run test:integration` (10/10 con red real). Además se cubrió programáticamente gran parte del contenido: avisos de duplicado/palabra prohibida en `PasoResumen.spec.jsx`, flujo de propuesta sin comentario/puntuación contra el backend real en `apiLocal.spec.js`, y normalización multi-comunidad en `nominatim.spec.js`. Pendiente solo el clic-through visual en navegador con backend levantado.
|
||||
|
||||
- [x] 5.3 Verificar responsive y accesibilidad: FAB visible sin tapar contenido en viewport estrecho y con barra PWA; tooltip en hover y foco; modal navegable con teclado, Escape cierra, foco contenido; enviar aceptación de privacidad obligatoria
|
||||
> Cobertura programática añadida: tooltip en foco/hover (`BotonFlotante.spec.jsx`), Escape/✕/foco contenido/Tab wrap (`ModalPasarela.spec.jsx`), privacidad obligatoria (`PasoResumen.spec.jsx`). Pendiente solo la verificación visual en dispositivo/viewport estrecho y barra PWA (safe-area).
|
||||
|
||||
- [x] 5.4 Verificar no-regresión: tarjetas, mapa, favoritos, reportes y panel admin siguen funcionando (imports de `geo.js` intactos); `npm run build` sin errores
|
||||
> `npm run build` OK (65 módulos); imports de `geo.js` intactos en LocalCard, AdminPanel, App y AutocompletarLocal; sin referencias muertas (`mostrarForm`/`CampoUbicacion`/`formValido`).
|
||||
|
||||
## 6. Infraestructura de pruebas
|
||||
|
||||
- [x] 6.1 Añadir dependencias y configuración de pruebas: `vitest` + `jsdom` + `@testing-library/react` (+ `@testing-library/dom`) + `supertest` como devDeps; `vitest.config.mjs` (unitarias/componente, offline) y `vitest.config.integration.mjs`; scripts `npm test`, `npm run test:watch`, `npm run test:integration`
|
||||
- [x] 6.2 Hacer el servidor comprobable sin cambiar su comportamiento: ruta de BD configurable (`process.env.LOCALESP_DB`, admite `:memory:`) y `server/index.js` exportando `app` escuchando solo al ejecutarse como script (`supertest` sin puerto)
|
||||
|
||||
## 7. Pruebas unitarias (proveedores de ubicación)
|
||||
|
||||
- [x] 7.1 `ProveedorUbicacion` (base): `normalizar()`, `emparejarProvincia()` (acentos, mayúsculas, artículos, alias cooficiales, sin coincidencia → `""`), `extrae()` de la base lanza "sin implementar", `detecta()` base no acepta nada, `fetchNominatim` devuelve `null` si `fetch` lanza o la respuesta no es ok
|
||||
- [x] 7.2 `proveedorCoordenadas`: pares con coma y punto decimal, separador `,`/`;`/espacio, signo, espacios; latitud fuera de rango → `null`; texto que no es un par → `detecta` false
|
||||
- [x] 7.3 `proveedorGoogleMaps`: formatos `@lat,lng`, `!3d…!4d…`, `?q=lat,lng`; prioridad `@` sobre `!3d!4d` (comportamiento migrado de `parsearEnlaceGoogleMaps`); enlace acortado `detecta` pero `extrae` → `null`; URL ajena → `detecta` false
|
||||
- [x] 7.4 `proveedorWaze`: `ll=lat,lng` ok; `ll` malformado → `null`; `q=texto` delega en la búsqueda Nominatim (fetch simulado) marcando `proveedor: "waze"`; `q` sin resultados → `null`
|
||||
- [x] 7.5 `proveedorOSM`: prioridad `mlat`/`mlon` sobre `#map=`; `#map=zoom/lat/lng`; sin ninguno → `null`
|
||||
- [x] 7.6 `proveedorNominatim`: `detecta` rechaza URLs y textos cortos y acepta direcciones; `desdeResultado` normaliza provincia (Bizkaia→Vizcaya, A Coruña→La Coruña, desconocida→ `""`); `extrae` sin resultados → `null`
|
||||
- [x] 7.7 `resolverUbicacion`/`inferirDesdeCoords`: orden de prioridad de `PROVEEDORES`, fallback de texto, proveedor que lanza → se degrada y prueba el siguiente, entrada vacía → `null`; `ETIQUETAS_PROVEEDOR` cubre todos los ids registrados
|
||||
|
||||
## 8. Pruebas de componente (pasarela)
|
||||
|
||||
- [x] 8.1 `PasoUbicacion`: debounce ~600ms (fake timers, no resuelve antes), chip con proveedor detectado y coordenadas, aviso de entrada no reconocida, pre-relleno del `form` (nombre/provincia/dirección/lat/lng/enlace según proveedor), aviso cuando la inferencia inversa falla
|
||||
- [x] 8.2 `PasoNombre`/`PasoProvincia`/`PasoCategoria`/`PasoSubcategoria`: pre-rellenados con lo inferido y editables; provincia como select de `PROVINCIAS`; subcategoría deshabilitada sin categoría y opcional (se puede continuar)
|
||||
- [x] 8.3 `PasoResumen`: resumen con los datos del form; botón "Enviar propuesta" deshabilitado sin aceptar privacidad y habilitado al marcar; abre `ModalPrivacidad` vía enlace; avisos de palabra prohibida y de duplicado con "Enviar de todos modos"/"Revisar datos"; estados "Comprobando…/Enviando…"
|
||||
- [x] 8.4 `ModalPasarela`: arranca en Ubicación; "Siguiente" deshabilitado sin dato válido y habilitado con él; "Atrás" conserva datos; Escape y ✕ cierran (`onCerrar`); foco inicial en el primer control de cada pantalla y contención de Tab dentro del modal
|
||||
- [x] 8.5 `BotonFlotante`: `aria-label` descriptivo, tooltip oculto por defecto y visible al enfocar (y oculto al desenfocar), `onClick` al pulsar
|
||||
|
||||
## 9. Pruebas de integración (backends reales)
|
||||
|
||||
- [x] 9.1 API local con `supertest` + BD `:memory:`: `POST /api/propuestas` sin `descripcion`/`puntuacion` → 201 con defaults (`puntuacion` 0, estado pendiente); `POST /api/check-duplicado` detecta por nombre normalizado (acentos/mayúsculas) + misma provincia; nombre igual en otra provincia y lejos → no duplicado; `GET /api/locales` arranca limpio
|
||||
- [x] 9.2 Nominatim real (suite opt-in `npm run test:integration`, auto-skip sin red, ≤1 req/s): búsqueda por dirección → coordenadas + provincia canónica; reverse de Madrid y Bilbao → provincia canónica; reverse fuera de España → provincia vacía
|
||||
|
||||
Reference in New Issue
Block a user