feat(propuesta): pasarela paso a paso con autodetección de ubicación y FAB
deploy-branch / deploy (push) Failing after 6s
deploy-branch / teardown (push) Skipped

- Campo universal de ubicación (Google Maps/Waze/OSM/coordenadas/dirección)
  sobre proveedores extensibles (src/utils/ubicacion) con resolver e inferencia
  inversa Nominatim (nombre/provincia pre-rellenados, alias cooficiales)
- FAB flotante abajo a la derecha que abre la pasarela en un modal accesible
  (Escape, foco contenido, responsive); sin descripción ni puntuación;
  avisos de duplicado y filtro de palabras dentro del modal
- Aviso PWA reposicionado: tarjeta abajo a la izquierda en móvil,
  botón compacto arriba a la izquierda en PC (no tapa el FAB)
- Suite de pruebas: 88 unitarias/componente (vitest + RTL, offline) y
  10 de integración (supertest + SQLite :memory: y Nominatim real opt-in)
- CI: workflow de Gitea Actions con npm test + build en push/PR e
  integración manual
This commit is contained in:
2026-08-16 01:35:02 +02:00
parent d48b74cbd4
commit 003b1e209f
53 changed files with 4896 additions and 253 deletions
@@ -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 (5256px) 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.
@@ -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)
@@ -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
@@ -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
+20
View File
@@ -0,0 +1,20 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours