Prototipo YB01 #1
@@ -0,0 +1,49 @@
|
||||
# CI: ejecuta la suite rápida de pruebas (offline) y el build en cada push y PR.
|
||||
# La suite de integración con Nominatim (red real) es opt-in: se lanza a mano
|
||||
# desde la pestaña Actions del repositorio, y un fallo de red no rompe el CI.
|
||||
name: tests
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Unitarias + componente + build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Instalar dependencias
|
||||
run: npm ci
|
||||
|
||||
- name: Pruebas unitarias y de componente (offline)
|
||||
run: npm test
|
||||
|
||||
- name: Build de producción
|
||||
run: npm run build
|
||||
|
||||
integracion:
|
||||
name: Integración (Nominatim real, opt-in)
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
continue-on-error: true # depende de un servicio externo: informativa
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Instalar dependencias
|
||||
run: npm ci
|
||||
|
||||
- name: Pruebas de integración (API en memoria + Nominatim)
|
||||
run: npm run test:integration
|
||||
@@ -2,3 +2,7 @@ node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
localesp.db
|
||||
.claude/
|
||||
.codex/
|
||||
.pi/
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Generated
+2009
-8
File diff suppressed because it is too large
Load Diff
+13
-5
@@ -8,17 +8,25 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"server": "node server/index.js",
|
||||
"dev:all": "concurrently -k -n vite,api -c cyan,green \"npm run dev\" \"npm run server\""
|
||||
"dev:all": "concurrently -k -n vite,api -c cyan,green \"npm run dev\" \"npm run server\"",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:integration": "vitest run --config vitest.config.integration.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"better-sqlite3": "^12.0.0",
|
||||
"express": "^5.0.0"
|
||||
"express": "^5.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"concurrently": "^8.0.0",
|
||||
"jsdom": "^30.0.1",
|
||||
"supertest": "^7.2.2",
|
||||
"vite": "^5.2.0",
|
||||
"concurrently": "^8.0.0"
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -3,7 +3,9 @@ import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const dbPath = join(__dirname, "../localesp.db");
|
||||
// Ruta de la BD sobreescribible para pruebas (admite ":memory:"); en
|
||||
// producción se usa el archivo del repo.
|
||||
const dbPath = process.env.LOCALESP_DB || join(__dirname, "../localesp.db");
|
||||
const db = new Database(dbPath);
|
||||
|
||||
db.exec(`
|
||||
|
||||
+9
-2
@@ -1,5 +1,5 @@
|
||||
import express from "express";
|
||||
import { fileURLToPath } from "url";
|
||||
import { fileURLToPath, pathToFileURL } from "url";
|
||||
import { dirname, join } from "path";
|
||||
import db from "./db.js";
|
||||
import { randomUUID } from "crypto";
|
||||
@@ -169,5 +169,12 @@ app.get(/^\/(?!api\/).*/, (_req, res) => {
|
||||
res.sendFile(join(__dirname, "../dist/index.html"));
|
||||
});
|
||||
|
||||
// Escucha solo cuando se ejecuta como script (`node server/index.js`); al ser
|
||||
// importado (p. ej. por las pruebas con supertest) se exporta la app sin puerto.
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => console.log(`Server listening on http://localhost:${PORT}`));
|
||||
const esPuntoDeEntrada = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
if (esPuntoDeEntrada) {
|
||||
app.listen(PORT, () => console.log(`Server listening on http://localhost:${PORT}`));
|
||||
}
|
||||
|
||||
export default app;
|
||||
|
||||
+43
-106
@@ -4,26 +4,25 @@ import { CATEGORIAS, COLORES_MAPA, NOMBRES_CATEGORIAS, obtenerCategoria } from "
|
||||
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 StarRating from "./components/StarRating.jsx";
|
||||
import LocalCard from "./components/LocalCard.jsx";
|
||||
import CampoUbicacion from "./components/CampoUbicacion.jsx";
|
||||
import AutocompletarLocal from "./components/AutocompletarLocal.jsx";
|
||||
import BannerMovil from "./components/BannerMovil.jsx";
|
||||
import ModalPrivacidad from "./components/ModalPrivacidad.jsx";
|
||||
import BotonFlotante from "./components/BotonFlotante.jsx";
|
||||
import ModalPasarela from "./components/ModalPasarela.jsx";
|
||||
import useFavoritos from "./hooks/useFavoritos.js";
|
||||
import useUbicacion from "./hooks/useUbicacion.js";
|
||||
|
||||
const FORM_VACIO = {
|
||||
nombre: "", provincia: "", categoria: "", subcategoria: "",
|
||||
direccion: "", enlaceGoogleMaps: "", lat: null, lng: null,
|
||||
puntuacion: 0, descripcion: "",
|
||||
entradaUbicacion: "", proveedorUbicacion: "", // estado interno del paso de ubicación, no se envía
|
||||
};
|
||||
|
||||
// ── App principal ─────────────────────────────────────────────────────────────
|
||||
export default function App() {
|
||||
const [locales, setLocales] = useState([]);
|
||||
const [vista, setVista] = useState("lista");
|
||||
const [mostrarForm, setMostrarForm] = useState(false);
|
||||
const [pasarelaAbierta, setPasarelaAbierta] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [enviado, setEnviado] = useState(false); // confirmación de propuesta enviada
|
||||
@@ -83,6 +82,21 @@ export default function App() {
|
||||
|
||||
const resetForm = () => setForm(FORM_VACIO);
|
||||
|
||||
const abrirPasarela = () => {
|
||||
setPasarelaAbierta(true);
|
||||
setErrorFiltro("");
|
||||
setPosibleDuplicado(null);
|
||||
};
|
||||
|
||||
// Cerrar la pasarela descarta el borrador en curso
|
||||
const cerrarPasarela = () => {
|
||||
setPasarelaAbierta(false);
|
||||
resetForm();
|
||||
setAceptaPrivacidad(false);
|
||||
setErrorFiltro("");
|
||||
setPosibleDuplicado(null);
|
||||
};
|
||||
|
||||
const guardarPropuesta = async (lat, lng) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -90,13 +104,12 @@ export default function App() {
|
||||
nombre: form.nombre, provincia: form.provincia,
|
||||
categoria: form.categoria, subcategoria: form.subcategoria,
|
||||
direccion: form.direccion, enlaceGoogleMaps: form.enlaceGoogleMaps,
|
||||
puntuacion: form.puntuacion, descripcion: form.descripcion,
|
||||
lat, lng,
|
||||
});
|
||||
setEnviado(true);
|
||||
resetForm();
|
||||
setAceptaPrivacidad(false);
|
||||
setMostrarForm(false);
|
||||
setPasarelaAbierta(false);
|
||||
setPosibleDuplicado(null);
|
||||
setTimeout(() => setEnviado(false), 5000);
|
||||
} catch (e) {
|
||||
@@ -109,9 +122,9 @@ export default function App() {
|
||||
setErrorFiltro("");
|
||||
setPosibleDuplicado(null);
|
||||
// Comprobar filtro de palabras
|
||||
const textoCompleto = [form.nombre, form.descripcion, form.direccion].join(" ");
|
||||
const textoCompleto = [form.nombre, form.direccion].join(" ");
|
||||
if (contienepalabrasProhibidas(textoCompleto, palabrasProhibidas)) {
|
||||
setErrorFiltro("Tu propuesta contiene palabras no permitidas. Por favor, revisa el nombre, dirección o descripción.");
|
||||
setErrorFiltro("Tu propuesta contiene palabras no permitidas. Por favor, revisa el nombre o la dirección.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -240,9 +253,6 @@ export default function App() {
|
||||
const provincias = [...new Set(locales.map((l) => l.provincia))].sort();
|
||||
const mediaGlobal = locales.length ? (locales.reduce((s, l) => s + l.puntuacion, 0) / locales.length).toFixed(1) : "—";
|
||||
|
||||
const tieneUbicacion = !!(form.direccion || form.enlaceGoogleMaps);
|
||||
const formValido = form.nombre && form.provincia && form.categoria && form.puntuacion > 0 && tieneUbicacion && aceptaPrivacidad;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720, margin: "0 auto", padding: "1.5rem 1rem", fontFamily: "var(--font-sans)" }}>
|
||||
{/* Header */}
|
||||
@@ -260,10 +270,6 @@ export default function App() {
|
||||
</h1>
|
||||
<p style={{ margin: "6px 0 0", fontSize: 13, color: "var(--color-text-secondary)" }}>Directorio colaborativo de negocios por toda España</p>
|
||||
</div>
|
||||
<button onClick={() => { setMostrarForm(!mostrarForm); setErrorFiltro(""); }}
|
||||
style={{ background: "#8B0000", color: "white", border: "none", borderRadius: "var(--border-radius-md)", padding: "8px 16px", fontSize: 14, fontWeight: 500, cursor: "pointer", display: "flex", alignItems: "center", gap: 6, whiteSpace: "nowrap" }}>
|
||||
<i className="ti ti-plus" aria-hidden="true"></i> Proponer local
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Confirmación enviada */}
|
||||
@@ -304,95 +310,6 @@ export default function App() {
|
||||
{filtroCategoria && <button onClick={() => { setFiltroCategoria(""); setFiltroSubcat(""); }} style={{ background: "none", border: "0.5px solid var(--color-border-secondary)", borderRadius: 20, padding: "5px 10px", fontSize: 12, cursor: "pointer", color: "var(--color-text-tertiary)" }}>✕ limpiar</button>}
|
||||
</div>
|
||||
|
||||
{/* Formulario de propuesta */}
|
||||
{mostrarForm && (
|
||||
<div style={{ background: "var(--color-background-primary)", border: "0.5px solid var(--color-border-secondary)", borderRadius: "var(--border-radius-lg)", padding: "1.25rem", marginBottom: "1.5rem" }}>
|
||||
<p style={{ margin: "0 0 4px", fontWeight: 500, fontSize: 16 }}>Proponer un local</p>
|
||||
<p style={{ margin: "0 0 1rem", fontSize: 13, color: "var(--color-text-secondary)" }}>El administrador revisará tu propuesta antes de publicarla.</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
||||
<AutocompletarLocal form={form} setForm={setForm} />
|
||||
<div>
|
||||
<label htmlFor="campo-provincia" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Provincia *</label>
|
||||
<select id="campo-provincia" style={inputStyle} value={form.provincia} onChange={(e) => setForm((f) => ({ ...f, provincia: e.target.value }))}>
|
||||
<option value="">Selecciona provincia...</option>
|
||||
{PROVINCIAS.map((p) => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="campo-categoria" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Categoría *</label>
|
||||
<select id="campo-categoria" style={inputStyle} value={form.categoria} onChange={(e) => setForm((f) => ({ ...f, categoria: e.target.value, subcategoria: "" }))}>
|
||||
<option value="">Selecciona categoría...</option>
|
||||
{NOMBRES_CATEGORIAS.map((c) => <option key={c} value={c}>{CATEGORIAS[c].emoji} {c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="campo-subcategoria" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Tipo específico</label>
|
||||
<select id="campo-subcategoria" style={{ ...inputStyle, opacity: form.categoria ? 1 : 0.5 }} value={form.subcategoria} onChange={(e) => setForm((f) => ({ ...f, subcategoria: e.target.value }))} disabled={!form.categoria}>
|
||||
<option value="">{form.categoria ? "Selecciona tipo..." : "Elige categoría primero"}</option>
|
||||
{form.categoria && CATEGORIAS[form.categoria]?.subcategorias.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<CampoUbicacion form={form} setForm={setForm} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<label htmlFor="campo-descripcion" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Descripción / comentario</label>
|
||||
<textarea id="campo-descripcion" style={{ ...inputStyle, resize: "vertical", minHeight: 60 }} placeholder="¿Qué lo hace especial? Horarios, servicios..." value={form.descripcion} onChange={(e) => setForm((f) => ({ ...f, descripcion: e.target.value }))} />
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 6 }}>Puntuación *</span>
|
||||
<StarRating value={form.puntuacion} onChange={(v) => setForm((f) => ({ ...f, puntuacion: v }))} />
|
||||
</div>
|
||||
|
||||
<label style={{ display: "flex", alignItems: "flex-start", gap: 8, marginTop: 12, fontSize: 12, color: "var(--color-text-secondary)", cursor: "pointer" }}>
|
||||
<input type="checkbox" checked={aceptaPrivacidad} onChange={(e) => setAceptaPrivacidad(e.target.checked)} style={{ marginTop: 2 }} />
|
||||
<span>
|
||||
He leído y acepto la{" "}
|
||||
<button type="button" onClick={() => setMostrarPrivacidad(true)} style={{ background: "none", border: "none", padding: 0, color: "#8B0000", textDecoration: "underline", cursor: "pointer", fontSize: 12 }}>
|
||||
política de privacidad
|
||||
</button>
|
||||
{" "}sobre cómo se usan estos datos.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Error filtro palabras */}
|
||||
{errorFiltro && (
|
||||
<div role="alert" style={{ marginTop: 12, background: "#FDEDEC", border: "1px solid #F5B7B1", borderRadius: "var(--border-radius-md)", padding: "10px 14px", fontSize: 13, color: "#922B21", display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<i className="ti ti-ban" aria-hidden="true"></i> {errorFiltro}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aviso de posible duplicado */}
|
||||
{posibleDuplicado && (
|
||||
<div role="alert" style={{ marginTop: 12, background: "#FDF2E9", border: "1px solid #F5CBA7", borderRadius: "var(--border-radius-md)", padding: "10px 14px", fontSize: 13, color: "#784212" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: 500 }}>
|
||||
<i className="ti ti-alert-triangle" aria-hidden="true"></i> Ya existe un local parecido
|
||||
</div>
|
||||
<ul style={{ margin: "6px 0", paddingLeft: 20 }}>
|
||||
{posibleDuplicado.coincidencias.map((c) => (
|
||||
<li key={c.id}>{c.nombre} · {c.provincia}{c.direccion ? ` · ${c.direccion}` : ""} ({c.estado === "pendiente" ? "pendiente de revisión" : "ya publicado"})</li>
|
||||
))}
|
||||
</ul>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 4 }}>
|
||||
<button onClick={confirmarPeseADuplicado} disabled={saving}
|
||||
style={{ background: "#8B0000", color: "white", border: "none", borderRadius: "var(--border-radius-md)", padding: "6px 14px", fontSize: 12, fontWeight: 500, cursor: "pointer" }}>
|
||||
{saving ? "Enviando..." : "Enviar de todos modos"}
|
||||
</button>
|
||||
<button onClick={() => setPosibleDuplicado(null)} style={{ background: "none", border: "0.5px solid var(--color-border-secondary)", borderRadius: "var(--border-radius-md)", padding: "6px 14px", fontSize: 12, cursor: "pointer", color: "var(--color-text-secondary)" }}>Revisar datos</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: 8, marginTop: "1rem" }}>
|
||||
<button onClick={enviarLocal} disabled={!formValido || saving || comprobandoDuplicado}
|
||||
style={{ background: formValido ? "#8B0000" : "var(--color-background-secondary)", color: formValido ? "white" : "var(--color-text-tertiary)", border: "none", borderRadius: "var(--border-radius-md)", padding: "8px 20px", fontSize: 14, fontWeight: 500, cursor: formValido ? "pointer" : "not-allowed" }}>
|
||||
{comprobandoDuplicado ? "Comprobando..." : saving ? "Enviando..." : "📨 Enviar propuesta"}
|
||||
</button>
|
||||
<button onClick={() => { setMostrarForm(false); setErrorFiltro(""); setPosibleDuplicado(null); }} style={{ background: "none", border: "0.5px solid var(--color-border-secondary)", borderRadius: "var(--border-radius-md)", padding: "8px 16px", fontSize: 14, cursor: "pointer", color: "var(--color-text-secondary)" }}>Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div role="tablist" aria-label="Vista de locales" style={{ display: "flex", gap: 6, marginBottom: "1rem", borderBottom: "0.5px solid var(--color-border-tertiary)", paddingBottom: 8 }}>
|
||||
@@ -500,13 +417,33 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", textAlign: "center", marginTop: "1.5rem" }}>
|
||||
Los locales pasan por revisión antes de publicarse · ¿Tienes un negocio? Proponlo arriba
|
||||
Los locales pasan por revisión antes de publicarse · ¿Tienes un negocio? Proponlo con el botón flotante ➕
|
||||
{" · "}
|
||||
<button type="button" onClick={() => setMostrarPrivacidad(true)} style={{ background: "none", border: "none", padding: 0, fontSize: 11, color: "var(--color-text-tertiary)", textDecoration: "underline", cursor: "pointer" }}>
|
||||
Política de privacidad
|
||||
</button>
|
||||
</p>
|
||||
|
||||
{/* Botón flotante y pasarela de propuesta */}
|
||||
<BotonFlotante onClick={abrirPasarela} />
|
||||
{pasarelaAbierta && (
|
||||
<ModalPasarela
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
onCerrar={cerrarPasarela}
|
||||
onEnviar={enviarLocal}
|
||||
onConfirmarDuplicado={confirmarPeseADuplicado}
|
||||
onDescartarDuplicado={() => setPosibleDuplicado(null)}
|
||||
aceptaPrivacidad={aceptaPrivacidad}
|
||||
setAceptaPrivacidad={setAceptaPrivacidad}
|
||||
onAbrirPrivacidad={() => setMostrarPrivacidad(true)}
|
||||
errorFiltro={errorFiltro}
|
||||
posibleDuplicado={posibleDuplicado}
|
||||
comprobandoDuplicado={comprobandoDuplicado}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mostrarPrivacidad && <ModalPrivacidad onCerrar={() => setMostrarPrivacidad(false)} />}
|
||||
|
||||
<BannerMovil />
|
||||
|
||||
+11
-2
@@ -141,8 +141,17 @@ let currentUser = null;
|
||||
|
||||
export function escucharAuth(cb) {
|
||||
// Simple localStorage-based auth
|
||||
const user = localStorage.getItem("user");
|
||||
currentUser = user ? JSON.parse(user) : null;
|
||||
// Robusto ante valores corruptos/legados: si el JSON no parsea, se limpia y
|
||||
// se trata como sesión cerrada (antes esto rompía /admin en blanco).
|
||||
let user = null;
|
||||
try {
|
||||
const raw = localStorage.getItem("user");
|
||||
user = raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
try { localStorage.removeItem("user"); } catch { /* almacenamiento no disponible */ }
|
||||
user = null;
|
||||
}
|
||||
currentUser = user;
|
||||
cb(currentUser);
|
||||
|
||||
// Listen for storage changes
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import usePwaInstall from "../hooks/usePwaInstall.js";
|
||||
import useEsMovil from "../hooks/useEsMovil.js";
|
||||
|
||||
const CLAVE_VISITAS = "localesp_visitas";
|
||||
const CLAVE_CERRADO = "localesp_banner_cerrado_hasta";
|
||||
@@ -25,22 +26,31 @@ function estaCerradoRecientemente() {
|
||||
}
|
||||
}
|
||||
|
||||
// Banner flotante para instalar/abrir la PWA. Usa el service worker +
|
||||
// Aviso flotante para instalar/abrir la PWA. Usa el service worker +
|
||||
// manifest.webmanifest ya registrados en main.jsx/index.html: si el
|
||||
// navegador soporta la instalación nativa (evento beforeinstallprompt),
|
||||
// el botón la dispara directamente. Si la app ya se está ejecutando en
|
||||
// modo standalone (o el navegador confirma que está instalada), el banner
|
||||
// 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.
|
||||
//
|
||||
// Para no ser intrusivo, solo se muestra a partir de la 2ª visita del
|
||||
// usuario (contador en localStorage) y, si lo cierra, no vuelve a
|
||||
// usuario (contador en localStorage) y, si se cierra con ✕, no vuelve a
|
||||
// aparecer durante 14 días.
|
||||
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
|
||||
const esMovil = useEsMovil();
|
||||
|
||||
useEffect(() => {
|
||||
setVisitasSuficientes(registrarVisitaYContar() >= VISITAS_MINIMAS);
|
||||
@@ -60,11 +70,14 @@ export default function BannerMovil() {
|
||||
const outcome = await promptInstall();
|
||||
setResultado(outcome);
|
||||
setInstalando(false);
|
||||
if (outcome === "dismissed") setDesplegada(true); // se muestra el mensaje "más tarde"
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: "fixed", bottom: 20, right: 20, zIndex: 999, background: "white", border: "1px solid #E5DDD5", borderRadius: 14, padding: "14px 18px", boxShadow: "0 6px 24px rgba(0,0,0,0.10)", maxWidth: 280, fontFamily: "var(--font-sans)" }}>
|
||||
<button onClick={handleCerrar} aria-label="Cerrar aviso de instalación"
|
||||
// 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"
|
||||
style={{ position: "absolute", top: 8, right: 10, background: "none", border: "none", cursor: "pointer", color: "#CCC", fontSize: 16, padding: 0 }}>✕</button>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 10 }}>
|
||||
@@ -99,4 +112,31 @@ export default function BannerMovil() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Móvil: tarjeta abajo a la izquierda (el FAB está abajo a la derecha) ──
|
||||
if (esMovil) {
|
||||
return (
|
||||
<div style={{ position: "fixed", left: 16, bottom: 0, zIndex: 999, paddingBottom: "env(safe-area-inset-bottom)" }}>
|
||||
{tarjeta(handleCerrar, { marginTop: 16 })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── PC: botón compacto arriba a la izquierda ──
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState } from "react";
|
||||
|
||||
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.
|
||||
export default function BotonFlotante({ onClick }) {
|
||||
const [tooltipVisible, setTooltipVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
right: 16,
|
||||
bottom: 0,
|
||||
zIndex: 900,
|
||||
paddingBottom: "env(safe-area-inset-bottom)",
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "relative", marginBottom: 16 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={TEXTO_TOOLTIP}
|
||||
onMouseEnter={() => setTooltipVisible(true)}
|
||||
onMouseLeave={() => setTooltipVisible(false)}
|
||||
onFocus={() => setTooltipVisible(true)}
|
||||
onBlur={() => setTooltipVisible(false)}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
width: 54, height: 54, borderRadius: "50%",
|
||||
background: "#8B0000", color: "white", border: "none",
|
||||
boxShadow: "0 4px 14px rgba(139,0,0,0.4)",
|
||||
fontSize: 24, cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<i className="ti ti-plus" aria-hidden="true"></i>
|
||||
</button>
|
||||
{tooltipVisible && (
|
||||
<span
|
||||
role="tooltip"
|
||||
style={{
|
||||
position: "absolute", right: "calc(100% + 10px)", top: "50%", transform: "translateY(-50%)",
|
||||
background: "var(--color-text-primary)", color: "var(--color-background-primary)",
|
||||
padding: "6px 10px", borderRadius: "var(--border-radius-md)",
|
||||
fontSize: 12, fontWeight: 500, whiteSpace: "nowrap", pointerEvents: "none",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.25)",
|
||||
}}
|
||||
>
|
||||
{TEXTO_TOOLTIP}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { geocodificarDireccion, parsearEnlaceGoogleMaps } from "../utils/geo.js";
|
||||
|
||||
const inputStyle = {
|
||||
width: "100%", boxSizing: "border-box", padding: "8px 12px",
|
||||
borderRadius: "var(--border-radius-md)", border: "0.5px solid var(--color-border-secondary)",
|
||||
background: "var(--color-background-primary)", color: "var(--color-text-primary)", fontSize: 14, outline: "none",
|
||||
};
|
||||
|
||||
export default function CampoUbicacion({ form, setForm }) {
|
||||
const [modoInput, setModoInput] = useState("direccion");
|
||||
const [geocodificando, setGeocodificando] = useState(false);
|
||||
const [resultadoGeo, setResultadoGeo] = useState(null);
|
||||
const debounceRef = useRef(null);
|
||||
|
||||
const handleDireccionChange = (val) => {
|
||||
setForm((f) => ({ ...f, direccion: val, lat: null, lng: null, enlaceGoogleMaps: "" }));
|
||||
setResultadoGeo(null);
|
||||
clearTimeout(debounceRef.current);
|
||||
if (val.trim().length < 8) return;
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setGeocodificando(true);
|
||||
try {
|
||||
const geo = await geocodificarDireccion(val);
|
||||
if (geo) {
|
||||
setForm((f) => ({ ...f, lat: geo.lat, lng: geo.lng }));
|
||||
setResultadoGeo({ ok: true, texto: geo.displayName });
|
||||
} else {
|
||||
setResultadoGeo({ ok: false, texto: "No se encontró la dirección. Se usará el centro de la provincia." });
|
||||
}
|
||||
} catch {
|
||||
setResultadoGeo({ ok: false, texto: "Error al geocodificar. Se usará el centro de la provincia." });
|
||||
}
|
||||
setGeocodificando(false);
|
||||
}, 900);
|
||||
};
|
||||
|
||||
const handleEnlaceChange = (val) => {
|
||||
setForm((f) => ({ ...f, enlaceGoogleMaps: val, lat: null, lng: null, direccion: "" }));
|
||||
setResultadoGeo(null);
|
||||
if (!val.trim()) return;
|
||||
const coords = parsearEnlaceGoogleMaps(val);
|
||||
if (coords) {
|
||||
setForm((f) => ({ ...f, lat: coords.lat, lng: coords.lng }));
|
||||
setResultadoGeo({ ok: true, texto: `Coordenadas detectadas: ${coords.lat.toFixed(5)}, ${coords.lng.toFixed(5)}` });
|
||||
} else {
|
||||
setResultadoGeo({ ok: false, texto: "No se pudieron extraer coordenadas. Copia la URL completa desde Google Maps." });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ gridColumn: "1 / -1", marginTop: 4 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 6 }}>
|
||||
<label style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
|
||||
Ubicación <span style={{ color: "#8B0000" }}>*</span>
|
||||
</label>
|
||||
<div style={{ display: "flex", border: "0.5px solid var(--color-border-secondary)", borderRadius: "var(--border-radius-md)", overflow: "hidden" }}>
|
||||
{[{ id: "direccion", label: "📍 Dirección" }, { id: "enlace", label: "🔗 Google Maps" }].map((opt) => (
|
||||
<button key={opt.id} type="button"
|
||||
onClick={() => { setModoInput(opt.id); setResultadoGeo(null); setForm((f) => ({ ...f, lat: null, lng: null, direccion: "", enlaceGoogleMaps: "" })); }}
|
||||
style={{ background: modoInput === opt.id ? "var(--color-background-secondary)" : "none", border: "none", padding: "4px 10px", fontSize: 11, cursor: "pointer", fontWeight: modoInput === opt.id ? 500 : 400, color: modoInput === opt.id ? "var(--color-text-primary)" : "var(--color-text-secondary)" }}>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modoInput === "direccion" ? (
|
||||
<input style={inputStyle} value={form.direccion || ""} placeholder="Ej: Calle Mayor 5, Alcalá de Henares, Madrid"
|
||||
onChange={(e) => handleDireccionChange(e.target.value)} />
|
||||
) : (
|
||||
<input style={inputStyle} value={form.enlaceGoogleMaps || ""} placeholder="Pega aquí el enlace de Google Maps..."
|
||||
onChange={(e) => handleEnlaceChange(e.target.value)} />
|
||||
)}
|
||||
|
||||
{geocodificando && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 6, fontSize: 12, color: "var(--color-text-secondary)" }}>
|
||||
<i className="ti ti-loader" aria-hidden="true" style={{ fontSize: 14 }}></i> Buscando dirección...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resultadoGeo && !geocodificando && (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "flex-start", gap: 6, marginTop: 6, fontSize: 12,
|
||||
color: resultadoGeo.ok ? "#1E8449" : "#784212",
|
||||
background: resultadoGeo.ok ? "#EAFAF1" : "#FDF2E9",
|
||||
padding: "6px 10px", borderRadius: "var(--border-radius-md)",
|
||||
}}>
|
||||
<i className={`ti ${resultadoGeo.ok ? "ti-circle-check" : "ti-alert-triangle"}`} aria-hidden="true" style={{ fontSize: 14, flexShrink: 0, marginTop: 1 }}></i>
|
||||
<span style={{ lineHeight: 1.4 }}>{resultadoGeo.texto}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import useEsMovil from "../hooks/useEsMovil.js";
|
||||
import PasoUbicacion from "./PasoUbicacion.jsx";
|
||||
import PasoNombre from "./PasoNombre.jsx";
|
||||
import PasoProvincia from "./PasoProvincia.jsx";
|
||||
import PasoCategoria from "./PasoCategoria.jsx";
|
||||
import PasoSubcategoria from "./PasoSubcategoria.jsx";
|
||||
import PasoResumen from "./PasoResumen.jsx";
|
||||
|
||||
// Definición declarativa de las pantallas de la pasarela: orden, título y
|
||||
// validez de cada paso (habilita o deshabilita "Siguiente"). El paso final
|
||||
// gestiona su propio botón de envío en PasoResumen.
|
||||
const PASOS = [
|
||||
{ id: "ubicacion", titulo: "Ubicación", Componente: PasoUbicacion, puedeContinuar: (f) => f.lat != null && f.lng != null },
|
||||
{ id: "nombre", titulo: "Nombre", Componente: PasoNombre, puedeContinuar: (f) => (f.nombre || "").trim().length >= 2 },
|
||||
{ id: "provincia", titulo: "Provincia", Componente: PasoProvincia, puedeContinuar: (f) => !!f.provincia },
|
||||
{ id: "categoria", titulo: "Categoría", Componente: PasoCategoria, puedeContinuar: (f) => !!f.categoria },
|
||||
{ id: "subcategoria", titulo: "Tipo específico", Componente: PasoSubcategoria, puedeContinuar: () => true },
|
||||
{ id: "resumen", titulo: "Revisión y envío", Componente: PasoResumen, puedeContinuar: () => true },
|
||||
];
|
||||
|
||||
// Modal de la pasarela para proponer un local: una pantalla por campo con
|
||||
// navegación atrás/siguiente e indicador de progreso. Sigue el patrón de
|
||||
// ModalPrivacidad (overlay + role="dialog" + cierre por fondo/✕/Escape) y
|
||||
// añade contención del foco de teclado. Responsive: en móvil el panel ocupa
|
||||
// todo el ancho y casi todo el alto; en escritorio queda centrado (~480px).
|
||||
export default function ModalPasarela({
|
||||
form, setForm,
|
||||
onCerrar, onEnviar, onConfirmarDuplicado, onDescartarDuplicado,
|
||||
aceptaPrivacidad, setAceptaPrivacidad, onAbrirPrivacidad,
|
||||
errorFiltro, posibleDuplicado, comprobandoDuplicado, saving,
|
||||
}) {
|
||||
const [paso, setPaso] = useState(0);
|
||||
const esMovil = useEsMovil();
|
||||
const panelRef = useRef(null);
|
||||
const cuerpoRef = useRef(null);
|
||||
|
||||
const pasoActual = PASOS[paso];
|
||||
const esUltimo = paso === PASOS.length - 1;
|
||||
const puedeContinuar = pasoActual.puedeContinuar(form);
|
||||
const enviando = comprobandoDuplicado || saving;
|
||||
|
||||
// Foco inicial en el primer control de la pantalla activa (no del panel
|
||||
// completo: el ✕ de la cabecera precede al cuerpo en el DOM). La
|
||||
// contención de Tab evita que el foco se escape del modal.
|
||||
useEffect(() => {
|
||||
const cuerpo = cuerpoRef.current;
|
||||
if (!cuerpo) return;
|
||||
const primerControl = cuerpo.querySelector("input, select, textarea, button:not([disabled])");
|
||||
primerControl?.focus();
|
||||
}, [paso]);
|
||||
|
||||
const alTeclear = (e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onCerrar();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab") return;
|
||||
const focos = panelRef.current?.querySelectorAll(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
if (!focos?.length) return;
|
||||
const primero = focos[0];
|
||||
const ultimo = focos[focos.length - 1];
|
||||
if (e.shiftKey && document.activeElement === primero) {
|
||||
e.preventDefault();
|
||||
ultimo.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === ultimo) {
|
||||
e.preventDefault();
|
||||
primero.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const { Componente } = pasoActual;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Proponer local: ${pasoActual.titulo}`}
|
||||
style={{
|
||||
position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", zIndex: 1000,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
padding: esMovil ? 8 : 16,
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onCerrar(); }}
|
||||
onKeyDown={alTeclear}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: "var(--color-background-primary)",
|
||||
borderRadius: "var(--border-radius-lg)",
|
||||
width: "100%",
|
||||
maxWidth: esMovil ? "none" : 480,
|
||||
height: esMovil ? "92dvh" : undefined,
|
||||
maxHeight: "92vh",
|
||||
display: "flex", flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{/* Cabecera: título, progreso y cierre */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10, padding: "14px 16px 10px", borderBottom: "0.5px solid var(--color-border-tertiary)" }}>
|
||||
<div>
|
||||
<p style={{ margin: 0, fontWeight: 600, fontSize: 16 }}>Proponer un local</p>
|
||||
<p style={{ margin: "2px 0 0", fontSize: 12, color: "var(--color-text-secondary)" }}>
|
||||
{pasoActual.titulo} · Paso {paso + 1} de {PASOS.length}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ display: "flex", gap: 4 }} aria-hidden="true">
|
||||
{PASOS.map((p, i) => (
|
||||
<span key={p.id} style={{ width: 16, height: 4, borderRadius: 2, background: i <= paso ? "#8B0000" : "var(--color-border-tertiary)", transition: "background 0.15s" }} />
|
||||
))}
|
||||
</div>
|
||||
<button onClick={onCerrar} aria-label="Cerrar" disabled={enviando} style={{ background: "none", border: "none", cursor: enviando ? "not-allowed" : "pointer", fontSize: 18, color: "var(--color-text-tertiary)" }}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cuerpo: la pantalla activa (con scroll si no cabe) */}
|
||||
<div ref={cuerpoRef} style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: "14px 16px" }}>
|
||||
<Componente
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
{...(pasoActual.id === "resumen" ? {
|
||||
aceptaPrivacidad, setAceptaPrivacidad, onAbrirPrivacidad,
|
||||
onEnviar, onConfirmarDuplicado, onDescartarDuplicado,
|
||||
errorFiltro, posibleDuplicado, comprobandoDuplicado, saving,
|
||||
} : {})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pie: navegación atrás/siguiente */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, padding: "12px 16px", borderTop: "0.5px solid var(--color-border-tertiary)" }}>
|
||||
<button
|
||||
onClick={paso === 0 ? onCerrar : () => setPaso((p) => p - 1)}
|
||||
disabled={enviando}
|
||||
style={{
|
||||
background: "none", border: "0.5px solid var(--color-border-secondary)",
|
||||
borderRadius: "var(--border-radius-md)", padding: "8px 16px",
|
||||
fontSize: 14, cursor: enviando ? "not-allowed" : "pointer",
|
||||
color: "var(--color-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{paso === 0 ? "Cancelar" : "← Atrás"}
|
||||
</button>
|
||||
{!esUltimo && (
|
||||
<button
|
||||
onClick={() => setPaso((p) => p + 1)}
|
||||
disabled={!puedeContinuar}
|
||||
style={{
|
||||
background: puedeContinuar ? "#8B0000" : "var(--color-background-secondary)",
|
||||
color: puedeContinuar ? "white" : "var(--color-text-tertiary)",
|
||||
border: "none", borderRadius: "var(--border-radius-md)",
|
||||
padding: "8px 20px", fontSize: 14, fontWeight: 500,
|
||||
cursor: puedeContinuar ? "pointer" : "not-allowed",
|
||||
}}
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { inputStyle } from "../styles/shared.js";
|
||||
import { CATEGORIAS, NOMBRES_CATEGORIAS } from "../constants/categorias.js";
|
||||
|
||||
// Paso 4 de la pasarela: categoría principal del negocio.
|
||||
export default function PasoCategoria({ form, setForm }) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="paso-categoria" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>
|
||||
Categoría <span style={{ color: "#8B0000" }}>*</span>
|
||||
</label>
|
||||
<select
|
||||
id="paso-categoria"
|
||||
style={inputStyle}
|
||||
value={form.categoria}
|
||||
onChange={(e) => setForm((f) => ({ ...f, categoria: e.target.value, subcategoria: "" }))}
|
||||
>
|
||||
<option value="">Selecciona categoría...</option>
|
||||
{NOMBRES_CATEGORIAS.map((c) => <option key={c} value={c}>{CATEGORIAS[c].emoji} {c}</option>)}
|
||||
</select>
|
||||
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: "6px 0 0" }}>
|
||||
Elige el grupo general al que pertenece el negocio.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { inputStyle } from "../styles/shared.js";
|
||||
|
||||
// Paso 2 de la pasarela: nombre del local, pre-rellenado con lo inferido
|
||||
// desde la ubicación (editable). Si la inferencia falló, llega vacío.
|
||||
export default function PasoNombre({ form, setForm }) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="paso-nombre" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>
|
||||
Nombre del local <span style={{ color: "#8B0000" }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="paso-nombre"
|
||||
style={inputStyle}
|
||||
autoComplete="off"
|
||||
placeholder="Bar El Olivo, Clínica San José..."
|
||||
value={form.nombre}
|
||||
onChange={(e) => setForm((f) => ({ ...f, nombre: e.target.value }))}
|
||||
/>
|
||||
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: "6px 0 0" }}>
|
||||
{form.nombre
|
||||
? "Si el nombre se rellenó automáticamente desde la ubicación, comprueba que es correcto antes de continuar."
|
||||
: "El nombre con el que la gente conoce el negocio."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { inputStyle } from "../styles/shared.js";
|
||||
import { PROVINCIAS } from "../constants/provincias.js";
|
||||
|
||||
// Paso 3 de la pasarela: provincia, pre-rellenada con lo inferido desde la
|
||||
// ubicación (si la inferencia falló o no casó con el listado, llega vacía).
|
||||
export default function PasoProvincia({ form, setForm }) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="paso-provincia" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>
|
||||
Provincia <span style={{ color: "#8B0000" }}>*</span>
|
||||
</label>
|
||||
<select
|
||||
id="paso-provincia"
|
||||
style={inputStyle}
|
||||
value={form.provincia}
|
||||
onChange={(e) => setForm((f) => ({ ...f, provincia: e.target.value }))}
|
||||
>
|
||||
<option value="">Selecciona provincia...</option>
|
||||
{PROVINCIAS.map((p) => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: "6px 0 0" }}>
|
||||
Si se rellenó automáticamente desde la ubicación, verifica que es correcta.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { CATEGORIAS } from "../constants/categorias.js";
|
||||
import { ETIQUETAS_PROVEEDOR } from "../utils/ubicacion/index.js";
|
||||
|
||||
const fila = { display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 10, padding: "6px 0", borderBottom: "0.5px solid var(--color-border-tertiary)", fontSize: 13 };
|
||||
const clave = { color: "var(--color-text-secondary)", flexShrink: 0 };
|
||||
const valor = { textAlign: "right", fontWeight: 500, wordBreak: "break-word" };
|
||||
|
||||
// Paso 6 (final) de la pasarela: resumen legible de todo lo introducido,
|
||||
// aceptación obligatoria de la política de privacidad y envío de la
|
||||
// propuesta. Los avisos de palabra prohibida y de posible duplicado se
|
||||
// muestran dentro del propio modal.
|
||||
export default function PasoResumen({
|
||||
form, aceptaPrivacidad, setAceptaPrivacidad, onAbrirPrivacidad,
|
||||
onEnviar, onConfirmarDuplicado, onDescartarDuplicado,
|
||||
errorFiltro, posibleDuplicado, comprobandoDuplicado, saving,
|
||||
}) {
|
||||
const categoria = form.categoria ? CATEGORIAS[form.categoria] : null;
|
||||
const enviando = comprobandoDuplicado || saving;
|
||||
const etiquetaProveedor = form.proveedorUbicacion ? ETIQUETAS_PROVEEDOR[form.proveedorUbicacion] || form.proveedorUbicacion : "";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p style={{ margin: "0 0 8px", fontSize: 13, color: "var(--color-text-secondary)" }}>
|
||||
Comprueba los datos antes de enviar. El administrador revisará la propuesta antes de publicarla.
|
||||
</p>
|
||||
|
||||
<div style={{ background: "var(--color-background-secondary)", borderRadius: "var(--border-radius-md)", padding: "4px 12px", marginBottom: 12 }}>
|
||||
<div style={fila}>
|
||||
<span style={clave}>📍 Ubicación</span>
|
||||
<span style={valor}>
|
||||
{etiquetaProveedor && <>{etiquetaProveedor}<br /></>}
|
||||
{form.lat != null && form.lng != null && <span style={{ fontWeight: 400 }}>{form.lat.toFixed(4)}, {form.lng.toFixed(4)}</span>}
|
||||
{form.direccion && <span style={{ fontWeight: 400, display: "block" }}>{form.direccion}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div style={fila}>
|
||||
<span style={clave}>🏷️ Nombre</span>
|
||||
<span style={valor}>{form.nombre}</span>
|
||||
</div>
|
||||
<div style={fila}>
|
||||
<span style={clave}>🗺️ Provincia</span>
|
||||
<span style={valor}>{form.provincia}</span>
|
||||
</div>
|
||||
<div style={fila}>
|
||||
<span style={clave}>📂 Categoría</span>
|
||||
<span style={valor}>{categoria ? `${categoria.emoji} ${form.categoria}` : ""}</span>
|
||||
</div>
|
||||
<div style={{ ...fila, borderBottom: "none" }}>
|
||||
<span style={clave}>🔖 Tipo específico</span>
|
||||
<span style={{ ...valor, fontWeight: form.subcategoria ? 500 : 400, color: form.subcategoria ? "inherit" : "var(--color-text-tertiary)" }}>
|
||||
{form.subcategoria || "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label style={{ display: "flex", alignItems: "flex-start", gap: 8, marginBottom: 12, fontSize: 12, color: "var(--color-text-secondary)", cursor: "pointer" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aceptaPrivacidad}
|
||||
onChange={(e) => setAceptaPrivacidad(e.target.checked)}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<span>
|
||||
He leído y acepto la{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.preventDefault(); onAbrirPrivacidad(); }}
|
||||
style={{ background: "none", border: "none", padding: 0, color: "#8B0000", textDecoration: "underline", cursor: "pointer", fontSize: 12 }}
|
||||
>
|
||||
política de privacidad
|
||||
</button>
|
||||
{" "}sobre cómo se usan estos datos. <span style={{ color: "#8B0000" }}>*</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Aviso de palabra prohibida */}
|
||||
{errorFiltro && (
|
||||
<div role="alert" style={{ marginBottom: 12, background: "#FDEDEC", border: "1px solid #F5B7B1", borderRadius: "var(--border-radius-md)", padding: "10px 14px", fontSize: 13, color: "#922B21", display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<i className="ti ti-ban" aria-hidden="true"></i> {errorFiltro}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aviso de posible duplicado */}
|
||||
{posibleDuplicado && (
|
||||
<div role="alert" style={{ marginBottom: 12, background: "#FDF2E9", border: "1px solid #F5CBA7", borderRadius: "var(--border-radius-md)", padding: "10px 14px", fontSize: 13, color: "#784212" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: 500 }}>
|
||||
<i className="ti ti-alert-triangle" aria-hidden="true"></i> Ya existe un local parecido
|
||||
</div>
|
||||
<ul style={{ margin: "6px 0", paddingLeft: 20 }}>
|
||||
{posibleDuplicado.coincidencias.map((c) => (
|
||||
<li key={c.id}>{c.nombre} · {c.provincia}{c.direccion ? ` · ${c.direccion}` : ""} ({c.estado === "pendiente" ? "pendiente de revisión" : "ya publicado"})</li>
|
||||
))}
|
||||
</ul>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 4 }}>
|
||||
<button onClick={onConfirmarDuplicado} disabled={saving}
|
||||
style={{ background: "#8B0000", color: "white", border: "none", borderRadius: "var(--border-radius-md)", padding: "6px 14px", fontSize: 12, fontWeight: 500, cursor: "pointer" }}>
|
||||
{saving ? "Enviando..." : "Enviar de todos modos"}
|
||||
</button>
|
||||
<button onClick={onDescartarDuplicado} style={{ background: "none", border: "0.5px solid var(--color-border-secondary)", borderRadius: "var(--border-radius-md)", padding: "6px 14px", fontSize: 12, cursor: "pointer", color: "var(--color-text-secondary)" }}>Revisar datos</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onEnviar}
|
||||
disabled={!aceptaPrivacidad || enviando}
|
||||
style={{
|
||||
background: aceptaPrivacidad ? "#8B0000" : "var(--color-background-secondary)",
|
||||
color: aceptaPrivacidad ? "white" : "var(--color-text-tertiary)",
|
||||
border: "none", borderRadius: "var(--border-radius-md)",
|
||||
padding: "10px 20px", fontSize: 14, fontWeight: 500, width: "100%",
|
||||
cursor: aceptaPrivacidad && !enviando ? "pointer" : "not-allowed",
|
||||
}}
|
||||
>
|
||||
{comprobandoDuplicado ? "Comprobando..." : saving ? "Enviando..." : "📨 Enviar propuesta"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { inputStyle } from "../styles/shared.js";
|
||||
import { CATEGORIAS } from "../constants/categorias.js";
|
||||
|
||||
// Paso 5 de la pasarela: tipo específico (subcategoría), opcional. Solo está
|
||||
// accesible tras elegir categoría (el paso anterior lo exige), y se puede
|
||||
// continuar sin seleccionar ninguna.
|
||||
export default function PasoSubcategoria({ form, setForm }) {
|
||||
const subcategorias = form.categoria ? CATEGORIAS[form.categoria]?.subcategorias || [] : [];
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="paso-subcategoria" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>
|
||||
Tipo específico
|
||||
</label>
|
||||
<select
|
||||
id="paso-subcategoria"
|
||||
style={{ ...inputStyle, opacity: form.categoria ? 1 : 0.5 }}
|
||||
value={form.subcategoria}
|
||||
onChange={(e) => setForm((f) => ({ ...f, subcategoria: e.target.value }))}
|
||||
disabled={!form.categoria}
|
||||
>
|
||||
<option value="">Sin tipo específico</option>
|
||||
{subcategorias.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: "6px 0 0" }}>
|
||||
Paso opcional: puedes continuar sin elegir un tipo específico.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ETIQUETAS_PROVEEDOR, inferirDesdeCoords, resolverUbicacion } from "../utils/ubicacion/index.js";
|
||||
import { inputStyle } from "../styles/shared.js";
|
||||
|
||||
const DEBOUNCE_MS = 600;
|
||||
// Proveedores cuya entrada es un enlace (se guarda como enlace del local)
|
||||
const PROVEEDORES_ENLACE = new Set(["google-maps", "waze", "osm"]);
|
||||
|
||||
// Paso 1 de la pasarela: un único campo universal de ubicación que acepta
|
||||
// enlaces de Google Maps / Waze / OpenStreetMap, coordenadas sueltas o una
|
||||
// dirección en texto. Con debounce resuelve la entrada con el primer
|
||||
// proveedor que la reconozca y, si solo hay coordenadas, lanza la
|
||||
// geocodificación inversa para pre-rellenar nombre/provincia/dirección.
|
||||
export default function PasoUbicacion({ form, setForm }) {
|
||||
const [texto, setTexto] = useState(form.entradaUbicacion || "");
|
||||
// fase: inactivo | resolviendo | resuelto | noreconocido
|
||||
const [estado, setEstado] = useState(() =>
|
||||
form.lat != null && form.lng != null
|
||||
? { fase: "resuelto", resolucion: { proveedor: form.proveedorUbicacion, lat: form.lat, lng: form.lng }, aviso: "" }
|
||||
: { fase: "inactivo", resolucion: null, aviso: "" },
|
||||
);
|
||||
const debounceRef = useRef(null);
|
||||
// Identificador de resolución en curso: descarta respuestas asíncronas
|
||||
// obsoletas si el usuario sigue escribiendo.
|
||||
const idResolucionRef = useRef(0);
|
||||
|
||||
useEffect(() => () => clearTimeout(debounceRef.current), []);
|
||||
|
||||
const resolver = async (entrada, id) => {
|
||||
if (id !== idResolucionRef.current) return;
|
||||
setEstado({ fase: "resolviendo", resolucion: null, aviso: "" });
|
||||
let resultado = null;
|
||||
try {
|
||||
resultado = await resolverUbicacion(entrada);
|
||||
} catch {
|
||||
resultado = null; // fallo controlado: la pasarela nunca se rompe
|
||||
}
|
||||
if (id !== idResolucionRef.current) return;
|
||||
|
||||
if (!resultado) {
|
||||
setEstado({
|
||||
fase: "noreconocido",
|
||||
resolucion: null,
|
||||
aviso: "No se pudo interpretar la ubicación. Si pegaste un enlace acortado, copia la URL completa; si escribiste una dirección, sé más específico.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const esEnlace = PROVEEDORES_ENLACE.has(resultado.proveedor);
|
||||
setEstado({ fase: "resuelto", resolucion: resultado, aviso: "" });
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
entradaUbicacion: entrada,
|
||||
proveedorUbicacion: resultado.proveedor,
|
||||
lat: resultado.lat,
|
||||
lng: resultado.lng,
|
||||
enlaceGoogleMaps: esEnlace ? entrada : "",
|
||||
direccion: resultado.direccion || "",
|
||||
nombre: resultado.nombre || "",
|
||||
provincia: resultado.provincia || "",
|
||||
}));
|
||||
|
||||
// Inferencia por geocodificación inversa solo si el resultado no trajo ya
|
||||
// nombre y provincia (una sola llamada a Nominatim por resolución).
|
||||
if (!resultado.nombre || !resultado.provincia) {
|
||||
const inferido = await inferirDesdeCoords(resultado.lat, resultado.lng);
|
||||
if (id !== idResolucionRef.current) return;
|
||||
if (inferido) {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
nombre: inferido.nombre || f.nombre,
|
||||
provincia: inferido.provincia || f.provincia,
|
||||
direccion: f.direccion || inferido.direccion || "",
|
||||
}));
|
||||
} else {
|
||||
setEstado((e) => ({
|
||||
...e,
|
||||
aviso: "No se pudieron inferir el nombre y la provincia automáticamente (posible fallo de red). Podrás rellenarlos a mano en los pasos siguientes.",
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCambio = (val) => {
|
||||
setTexto(val);
|
||||
clearTimeout(debounceRef.current);
|
||||
const id = ++idResolucionRef.current;
|
||||
const limpio = val.trim();
|
||||
// En cuanto cambia el texto, la resolución anterior deja de ser válida:
|
||||
// se limpia el chip y las coordenadas del form hasta la nueva resolución
|
||||
setEstado({ fase: "inactivo", resolucion: null, aviso: "" });
|
||||
setForm((f) => ({ ...f, entradaUbicacion: val, proveedorUbicacion: "", lat: null, lng: null }));
|
||||
if (!limpio) return;
|
||||
debounceRef.current = setTimeout(() => resolver(limpio, id), DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const { fase, resolucion, aviso } = estado;
|
||||
const etiqueta = resolucion ? ETIQUETAS_PROVEEDOR[resolucion.proveedor] || resolucion.proveedor : "";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="paso-ubicacion" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>
|
||||
Ubicación del local <span style={{ color: "#8B0000" }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="paso-ubicacion"
|
||||
style={inputStyle}
|
||||
autoComplete="off"
|
||||
placeholder="Enlace de Google Maps / Waze / OpenStreetMap, coordenadas o dirección"
|
||||
value={texto}
|
||||
onChange={(e) => handleCambio(e.target.value)}
|
||||
aria-describedby="paso-ubicacion-ayuda"
|
||||
/>
|
||||
<p id="paso-ubicacion-ayuda" style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: "6px 0 0" }}>
|
||||
Pega el enlace del local, escribe sus coordenadas (p. ej. 41.3851, 2.1734) o su dirección.
|
||||
</p>
|
||||
|
||||
{fase === "resolviendo" && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 10, fontSize: 12, color: "var(--color-text-secondary)" }}>
|
||||
<i className="ti ti-loader" aria-hidden="true" style={{ fontSize: 14 }}></i> Resolviendo ubicación...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fase === "resuelto" && resolucion && (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "flex-start", gap: 6, marginTop: 10,
|
||||
background: "#EAFAF1", color: "#1E8449",
|
||||
padding: "6px 10px", borderRadius: "var(--border-radius-md)", fontSize: 12,
|
||||
}}>
|
||||
<i className="ti ti-circle-check" aria-hidden="true" style={{ fontSize: 14, flexShrink: 0, marginTop: 1 }}></i>
|
||||
<span style={{ lineHeight: 1.4 }}>
|
||||
<strong>{etiqueta} detectado</strong>{" · "}{resolucion.lat.toFixed(4)}, {resolucion.lng.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fase === "noreconocido" && (
|
||||
<div role="alert" style={{
|
||||
display: "flex", alignItems: "flex-start", gap: 6, marginTop: 10,
|
||||
background: "#FDF2E9", color: "#784212",
|
||||
padding: "6px 10px", borderRadius: "var(--border-radius-md)", fontSize: 12,
|
||||
}}>
|
||||
<i className="ti ti-alert-triangle" aria-hidden="true" style={{ fontSize: 14, flexShrink: 0, marginTop: 1 }}></i>
|
||||
<span style={{ lineHeight: 1.4 }}>{aviso}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fase === "resuelto" && aviso && (
|
||||
<div role="status" style={{
|
||||
display: "flex", alignItems: "flex-start", gap: 6, marginTop: 6,
|
||||
background: "#FDF2E9", color: "#784212",
|
||||
padding: "6px 10px", borderRadius: "var(--border-radius-md)", fontSize: 12,
|
||||
}}>
|
||||
<i className="ti ti-wifi-off" aria-hidden="true" style={{ fontSize: 14, flexShrink: 0, marginTop: 1 }}></i>
|
||||
<span style={{ lineHeight: 1.4 }}>{aviso}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Detecta si el viewport es estrecho (móvil) consultando matchMedia, para
|
||||
// adaptar la presentación de la pasarela (panel a pantalla casi completa).
|
||||
export default function useEsMovil(query = "(max-width: 600px)") {
|
||||
const [esMovil, setEsMovil] = useState(
|
||||
() => typeof window !== "undefined" && window.matchMedia && window.matchMedia(query).matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.matchMedia) return;
|
||||
const mq = window.matchMedia(query);
|
||||
const escuchar = (e) => setEsMovil(e.matches);
|
||||
setEsMovil(mq.matches);
|
||||
if (mq.addEventListener) {
|
||||
mq.addEventListener("change", escuchar);
|
||||
return () => mq.removeEventListener("change", escuchar);
|
||||
}
|
||||
// Safari antiguo
|
||||
mq.addListener(escuchar);
|
||||
return () => mq.removeListener(escuchar);
|
||||
}, [query]);
|
||||
|
||||
return esMovil;
|
||||
}
|
||||
+4
-28
@@ -1,21 +1,7 @@
|
||||
// Utilidades de geolocalización: extraer coordenadas de un enlace de Google Maps
|
||||
// o geocodificar una dirección de texto mediante Nominatim (OpenStreetMap).
|
||||
|
||||
export function parsearEnlaceGoogleMaps(url) {
|
||||
try {
|
||||
let coincidencia = url.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (coincidencia) return { lat: parseFloat(coincidencia[1]), lng: parseFloat(coincidencia[2]) };
|
||||
|
||||
coincidencia = url.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/);
|
||||
if (coincidencia) return { lat: parseFloat(coincidencia[1]), lng: parseFloat(coincidencia[2]) };
|
||||
|
||||
coincidencia = url.match(/[?&]q=(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (coincidencia) return { lat: parseFloat(coincidencia[1]), lng: parseFloat(coincidencia[2]) };
|
||||
} catch {
|
||||
// URL malformada: se ignora y se devuelve null más abajo
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Utilidades de geolocalización usadas por las tarjetas y el mapa: enlaces
|
||||
// para abrir un local en Google Maps o Waze, distancias y búsqueda de lugares.
|
||||
// La extracción de coordenadas de enlaces y la geocodificación por texto viven
|
||||
// ahora en los proveedores de src/utils/ubicacion/.
|
||||
|
||||
// Enlaces para abrir la ubicación de un local directamente en Google Maps o Waze,
|
||||
// usando las coordenadas si existen (más preciso) o si no, el texto de la dirección.
|
||||
@@ -47,16 +33,6 @@ export function formatearDistancia(km) {
|
||||
return km < 1 ? `${Math.round(km * 1000)} m` : `${km.toFixed(1)} km`;
|
||||
}
|
||||
|
||||
export async function geocodificarDireccion(direccion) {
|
||||
const query = encodeURIComponent(`${direccion}, España`);
|
||||
const url = `https://nominatim.openstreetmap.org/search?q=${query}&format=json&limit=1&countrycodes=es`;
|
||||
const respuesta = await fetch(url, { headers: { "Accept-Language": "es", "User-Agent": "LocalesEspanoles/1.0" } });
|
||||
const datos = await respuesta.json();
|
||||
if (datos?.length) {
|
||||
return { lat: parseFloat(datos[0].lat), lng: parseFloat(datos[0].lon), displayName: datos[0].display_name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Busca lugares/negocios por nombre (autocompletar). Usa Nominatim (OpenStreetMap),
|
||||
// que no requiere clave de API. Nota: no es el autocompletar "de Google" (eso exigiría
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Clase base de los proveedores de ubicación: define el contrato común que
|
||||
// implementan todas las integraciones con backends de mapas (Google Maps,
|
||||
// Waze, OpenStreetMap, coordenadas sueltas y Nominatim como fallback de
|
||||
// texto). JS no tiene interfaces nativas, así que el contrato se documenta
|
||||
// aquí y se comprueba al registrar cada instancia (ver index.js).
|
||||
//
|
||||
// Contrato de cada proveedor:
|
||||
// - id: identificador estable del proveedor. Se copia al resultado para
|
||||
// que la UI pueda etiquetar el origen de la ubicación.
|
||||
// - detecta(entrada) → boolean: ¿esta entrada parece pertenecer a este
|
||||
// proveedor? Debe ser barato y síncrono (normalmente un regex).
|
||||
// - extrae(entrada) → Promise<Resultado|null>: extrae la información
|
||||
// disponible y devuelve { proveedor, lat, lng, ...extras } o null
|
||||
// si no se pudo extraer nada. Los errores de red se degradan a un
|
||||
// fallo controlado (null), nunca se lanzan al consumidor.
|
||||
//
|
||||
// El resultado es deliberadamente extensible: los consumidores leen solo los
|
||||
// campos que conocen (hoy lat/lng/nombre/provincia/direccion), de modo que
|
||||
// añadir campos nuevos en el futuro (teléfono, horarios…) no rompe nada.
|
||||
import { PROVINCIAS } from "../../constants/provincias.js";
|
||||
|
||||
export default class ProveedorUbicacion {
|
||||
// Identificador del proveedor; cada subclase debe sobrescribirlo.
|
||||
id = "proveedor";
|
||||
|
||||
// Nombres cooficiales (normalizados y sin artículo) → provincia canónica.
|
||||
// Nominatim puede devolver "Bizkaia", "Girona" o "Ourense" según el idioma
|
||||
// negociado; sin este mapa la provincia quedaría vacía innecesariamente.
|
||||
static ALIAS_PROVINCIAS = {
|
||||
bizkaia: "Vizcaya",
|
||||
gipuzkoa: "Guipúzcoa",
|
||||
araba: "Álava",
|
||||
girona: "Gerona",
|
||||
lleida: "Lérida",
|
||||
ourense: "Orense",
|
||||
coruna: "La Coruña",
|
||||
"illes balears": "Islas Baleares",
|
||||
asturies: "Asturias",
|
||||
};
|
||||
|
||||
// ¿Reconoce esta entrada? La base no reconoce nada.
|
||||
detecta(entrada) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extrae la ubicación de la entrada. Método "abstracto": la base lanza para
|
||||
// que una subclase registrada sin implementarlo falle rápido.
|
||||
async extrae(entrada) {
|
||||
throw new Error("sin implementar");
|
||||
}
|
||||
|
||||
// ── Helpers compartidos por todos los proveedores ────────────────────────
|
||||
|
||||
// Normaliza un texto: minúsculas, sin tildes y sin espacios extremos. Se usa
|
||||
// para comparar provincias y etiquetas sin depender de acentos.
|
||||
static normalizar(texto) {
|
||||
return (texto || "")
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
normalizar(texto) {
|
||||
return ProveedorUbicacion.normalizar(texto);
|
||||
}
|
||||
|
||||
// Empareja un texto libre contra el listado canónico de PROVINCIAS,
|
||||
// insensible a acentos y mayúsculas y tolerante con artículos ("Comunidad
|
||||
// de Madrid" → "Madrid", "A Coruña" → "La Coruña"). Devuelve "" si no casa
|
||||
// con ninguna, para que la UI fuerce la selección manual (mismo
|
||||
// comportamiento que tenía AutocompletarLocal).
|
||||
emparejarProvincia(texto) {
|
||||
const t = this.normalizar(texto);
|
||||
if (!t) return "";
|
||||
const sinArticulo = (s) => s.replace(/^(el|la|los|las|a|o)\s+/, "");
|
||||
const tSin = sinArticulo(t);
|
||||
const directa = PROVINCIAS.find((p) => {
|
||||
const np = ProveedorUbicacion.normalizar(p);
|
||||
const npSin = sinArticulo(np);
|
||||
return t === np || t.includes(np) || tSin === npSin || tSin.includes(npSin);
|
||||
});
|
||||
if (directa) return directa;
|
||||
// Alias de nombres cooficiales: Nominatim puede responder en euskera,
|
||||
// catalán o gallego según el idioma negociado (D12 del diseño).
|
||||
return ProveedorUbicacion.ALIAS_PROVINCIAS[tSin] || "";
|
||||
}
|
||||
|
||||
// fetch a Nominatim con las cabeceras adecuadas y manejo de errores: si la
|
||||
// red o el servicio fallan devuelve null (fallo controlado) en vez de
|
||||
// lanzar, para que la pasarela nunca se rompa por un error de proveedor.
|
||||
async fetchNominatim(url) {
|
||||
try {
|
||||
const respuesta = await fetch(url, {
|
||||
headers: { "Accept-Language": "es", "User-Agent": "LocalesEspanoles/1.0" },
|
||||
});
|
||||
if (!respuesta.ok) return null;
|
||||
return await respuesta.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Punto de entrada del sistema de proveedores de ubicación.
|
||||
//
|
||||
// Registro abierto/cerrado: añadir un backend nuevo consiste en crear una
|
||||
// clase que implemente el contrato de ProveedorUbicacion (id/detecta/extrae)
|
||||
// y sumar una instancia a PROVEEDORES. Ni resolverUbicacion, ni los demás
|
||||
// proveedores, ni la pasarela necesitan cambiar.
|
||||
import ProveedorCoordenadas from "./proveedorCoordenadas.js";
|
||||
import ProveedorGoogleMaps from "./proveedorGoogleMaps.js";
|
||||
import ProveedorWaze from "./proveedorWaze.js";
|
||||
import ProveedorOSM from "./proveedorOSM.js";
|
||||
import ProveedorNominatim from "./proveedorNominatim.js";
|
||||
|
||||
// Comprueba que una instancia cumple el contrato antes de registrarla:
|
||||
// fallo rápido si un proveedor nuevo olvida implementar la interfaz.
|
||||
function registrar(proveedor) {
|
||||
if (!proveedor?.id || typeof proveedor.detecta !== "function" || typeof proveedor.extrae !== "function") {
|
||||
throw new Error(`Proveedor inválido: falta id, detecta() o extrae() en ${proveedor?.constructor?.name || "proveedor"}`);
|
||||
}
|
||||
return proveedor;
|
||||
}
|
||||
|
||||
// Proveedores ordenados por prioridad: lo más específico primero y el texto
|
||||
// de dirección (Nominatim) siempre al final como fallback.
|
||||
export const PROVEEDORES = [
|
||||
registrar(new ProveedorCoordenadas()),
|
||||
registrar(new ProveedorGoogleMaps()),
|
||||
registrar(new ProveedorWaze()),
|
||||
registrar(new ProveedorOSM()),
|
||||
registrar(new ProveedorNominatim()),
|
||||
];
|
||||
|
||||
// Etiquetas legibles por id de proveedor, para los chips de la UI.
|
||||
export const ETIQUETAS_PROVEEDOR = {
|
||||
coordenadas: "Coordenadas",
|
||||
"google-maps": "Google Maps",
|
||||
waze: "Waze",
|
||||
osm: "OpenStreetMap",
|
||||
nominatim: "Dirección (OpenStreetMap)",
|
||||
};
|
||||
|
||||
// Resuelve una entrada libre usando el primer proveedor que la reconozca y
|
||||
// logre extraer datos. Devuelve { proveedor, lat, lng, ...extras } o null si
|
||||
// ningún proveedor la entiende. Los errores de cada proveedor se degradan
|
||||
// (se registran en consola y se continúa con el siguiente), nunca se lanzan
|
||||
// al consumidor.
|
||||
export async function resolverUbicacion(entrada) {
|
||||
const texto = (entrada || "").trim();
|
||||
if (!texto) return null;
|
||||
for (const proveedor of PROVEEDORES) {
|
||||
try {
|
||||
if (!proveedor.detecta(texto)) continue;
|
||||
const resultado = await proveedor.extrae(texto);
|
||||
if (resultado && Number.isFinite(resultado.lat) && Number.isFinite(resultado.lng)) {
|
||||
return resultado;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[ubicacion] el proveedor ${proveedor.id} falló:`, e);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Geocodificación inversa: de coordenadas a { nombre, provincia, direccion }
|
||||
// con la provincia normalizada al listado canónico. Devuelve null si falla;
|
||||
// la UI avisa y permite continuar sin inferencia.
|
||||
export async function inferirDesdeCoords(lat, lng) {
|
||||
const nominatim = PROVEEDORES.find((p) => p.id === "nominatim");
|
||||
if (!nominatim) return null;
|
||||
try {
|
||||
return await nominatim.reverse(lat, lng);
|
||||
} catch (e) {
|
||||
console.error("[ubicacion] la inferencia falló:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Proveedor de coordenadas geográficas sueltas: "41.3851, 2.1734".
|
||||
// Acepta signo, espacios alrededor del separador y coma o punto como
|
||||
// separador decimal (incluido el par con decimales españoles "41,38, 2,17").
|
||||
import ProveedorUbicacion from "./ProveedorUbicacion.js";
|
||||
|
||||
// Par separado por coma o punto y coma: "41.3851, 2.1734", "-3,70;40,41"
|
||||
const PATRON_PAR_COMA = /^\s*(-?\d{1,3}(?:[.,]\d+))\s*[,;]\s*(-?\d{1,3}(?:[.,]\d+))\s*$/;
|
||||
// Par separado por espacio con decimales obligatorios (evita falsos
|
||||
// positivos con texto): "41,3851 2,1734"
|
||||
const PATRON_PAR_ESPACIO = /^\s*(-?\d{1,3}[.,]\d{3,})\s+(-?\d{1,3}[.,]\d{3,})\s*$/;
|
||||
|
||||
const aNumero = (s) => parseFloat(String(s).replace(",", "."));
|
||||
|
||||
export default class ProveedorCoordenadas extends ProveedorUbicacion {
|
||||
id = "coordenadas";
|
||||
|
||||
detecta(entrada) {
|
||||
const t = entrada || "";
|
||||
return PATRON_PAR_COMA.test(t) || PATRON_PAR_ESPACIO.test(t);
|
||||
}
|
||||
|
||||
async extrae(entrada) {
|
||||
const texto = (entrada || "").trim();
|
||||
const par = texto.match(PATRON_PAR_COMA) || texto.match(PATRON_PAR_ESPACIO);
|
||||
if (!par) return null;
|
||||
const lat = aNumero(par[1]);
|
||||
const lng = aNumero(par[2]);
|
||||
// Sanidad: rangos válidos de coordenadas terrestres
|
||||
if (Math.abs(lat) > 90 || Math.abs(lng) > 180) return null;
|
||||
return { proveedor: this.id, lat, lng };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Proveedor de enlaces de Google Maps. Migra la lógica de
|
||||
// parsearEnlaceGoogleMaps (src/utils/geo.js) y reconoce los tres formatos con
|
||||
// coordenadas en la URL:
|
||||
// - @lat,lng → https://maps.google.com/@41.38,-2.17,15z
|
||||
// - !3d…!4d… → https://google.com/maps/place/…/!3d41.38!4d-2.17
|
||||
// - ?q=lat,lng → https://google.com/maps?q=41.38,-2.17
|
||||
// Los enlaces acortados (maps.app.goo.gl) se detectan pero sin coordenadas en
|
||||
// la URL devuelven null: la UI pide el enlace completo (unshorten pendiente).
|
||||
import ProveedorUbicacion from "./ProveedorUbicacion.js";
|
||||
|
||||
const PATRONES_COORDS = [
|
||||
/@(-?\d+\.\d+),(-?\d+\.\d+)/,
|
||||
/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/,
|
||||
/[?&]q=(-?\d+\.\d+),(-?\d+\.\d+)/,
|
||||
];
|
||||
|
||||
export default class ProveedorGoogleMaps extends ProveedorUbicacion {
|
||||
id = "google-maps";
|
||||
|
||||
detecta(entrada) {
|
||||
return /maps\.google\.|google\.[a-z.]+\/maps|maps\.app\.goo\.gl|goo\.gl\/maps/i.test(entrada || "");
|
||||
}
|
||||
|
||||
async extrae(entrada) {
|
||||
const texto = entrada || "";
|
||||
for (const patron of PATRONES_COORDS) {
|
||||
const m = texto.match(patron);
|
||||
if (m) {
|
||||
const lat = parseFloat(m[1]);
|
||||
const lng = parseFloat(m[2]);
|
||||
if (Math.abs(lat) <= 90 && Math.abs(lng) <= 180) {
|
||||
return { proveedor: this.id, lat, lng };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Proveedor de geocodificación por texto (Nominatim / OpenStreetMap). Actúa
|
||||
// como fallback de último recurso: cuando la entrada no es un enlace
|
||||
// reconocible ni un par de coordenadas, se trata como dirección y se busca
|
||||
// su ubicación (/search, migra geocodificarDireccion de src/utils/geo.js).
|
||||
// Además expone reverse(lat, lng) (/reverse) para la geocodificación
|
||||
// inversa con la que la pasarela infiere nombre, provincia y dirección.
|
||||
import ProveedorUbicacion from "./ProveedorUbicacion.js";
|
||||
|
||||
const BASE = "https://nominatim.openstreetmap.org";
|
||||
|
||||
export default class ProveedorNominatim extends ProveedorUbicacion {
|
||||
id = "nominatim";
|
||||
|
||||
// Fallback de texto: acepta cualquier entrada con letras que no parezca una
|
||||
// URL (un enlace que ningún proveedor supo extraer no debe buscarse como
|
||||
// si fuera una dirección).
|
||||
detecta(entrada) {
|
||||
const t = (entrada || "").trim();
|
||||
if (t.length < 3) return false;
|
||||
if (/https?:\/\/|\bwww\.|\b[a-z0-9-]+\.(com|es|org|net|gl|io|app|edu|gob)\b/i.test(t)) return false;
|
||||
return /[a-záéíóúüñ]/i.test(t);
|
||||
}
|
||||
|
||||
// Búsqueda por texto (dirección o nombre de lugar). Devuelve el resultado
|
||||
// con coordenadas y, si Nominatim las facilita, nombre/provincia/dirección.
|
||||
async extrae(entrada) {
|
||||
const q = (entrada || "").trim();
|
||||
if (!q) return null;
|
||||
const params = new URLSearchParams({
|
||||
q: `${q}, España`,
|
||||
format: "json",
|
||||
addressdetails: "1",
|
||||
namedetails: "1",
|
||||
limit: "1",
|
||||
countrycodes: "es",
|
||||
});
|
||||
const datos = await this.fetchNominatim(`${BASE}/search?${params}`);
|
||||
if (!datos?.length) return null;
|
||||
return this.desdeResultado(datos[0]);
|
||||
}
|
||||
|
||||
// Geocodificación inversa: de coordenadas a { nombre, provincia, direccion }.
|
||||
// Devuelve null si el servicio no responde (la UI avisa y sigue sin
|
||||
// inferencia, sin bloquear la pasarela).
|
||||
async reverse(lat, lng) {
|
||||
const params = new URLSearchParams({
|
||||
lat: String(lat),
|
||||
lon: String(lng),
|
||||
format: "jsonv2",
|
||||
addressdetails: "1",
|
||||
namedetails: "1",
|
||||
"accept-language": "es",
|
||||
});
|
||||
const dato = await this.fetchNominatim(`${BASE}/reverse?${params}`);
|
||||
if (!dato || dato.error) return null;
|
||||
const r = this.desdeResultado(dato);
|
||||
return { nombre: r.nombre, provincia: r.provincia, direccion: r.direccion };
|
||||
}
|
||||
|
||||
// Convierte una respuesta de Nominatim (search o reverse) en el resultado
|
||||
// extensible del proveedor. La provincia se normaliza al listado canónico
|
||||
// de PROVINCIAS: si no casa con ninguna, queda vacía para que el usuario la
|
||||
// seleccione manualmente.
|
||||
desdeResultado(d) {
|
||||
const addr = d.address || {};
|
||||
const candidatos = [addr.county, addr.province, addr.state_district, addr.state, addr.city, addr.municipality, addr.city_district].filter(Boolean);
|
||||
let provincia = "";
|
||||
for (const candidato of candidatos) {
|
||||
provincia = this.emparejarProvincia(candidato);
|
||||
if (provincia) break;
|
||||
}
|
||||
return {
|
||||
proveedor: this.id,
|
||||
lat: parseFloat(d.lat),
|
||||
lng: parseFloat(d.lon),
|
||||
nombre: d.namedetails?.name || "",
|
||||
provincia,
|
||||
direccion: d.display_name || "",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Proveedor de enlaces de openstreetmap.org. Extrae las coordenadas de los
|
||||
// parámetros mlat/mlon (enlace de marcador) o del fragmento
|
||||
// #map=zoom/lat/lng (posición actual del visor), p. ej.:
|
||||
// https://www.openstreetmap.org/?mlat=41.38&mlon=2.17#map=15/41.38/2.17
|
||||
import ProveedorUbicacion from "./ProveedorUbicacion.js";
|
||||
|
||||
const PATRON_MAPA = /#map=\d+(?:\.\d+)?\/(-?\d{1,3}\.\d+)\/(-?\d{1,3}\.\d+)/;
|
||||
|
||||
function comoUrl(entrada) {
|
||||
const texto = (entrada || "").trim();
|
||||
if (!texto) return null;
|
||||
try {
|
||||
return new URL(/:\/\//i.test(texto) ? texto : `https://${texto}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default class ProveedorOSM extends ProveedorUbicacion {
|
||||
id = "osm";
|
||||
|
||||
detecta(entrada) {
|
||||
return /openstreetmap\.org/i.test(entrada || "");
|
||||
}
|
||||
|
||||
async extrae(entrada) {
|
||||
const url = comoUrl(entrada);
|
||||
if (!url) return null;
|
||||
|
||||
const mlat = parseFloat(url.searchParams.get("mlat"));
|
||||
const mlon = parseFloat(url.searchParams.get("mlon"));
|
||||
if (Number.isFinite(mlat) && Number.isFinite(mlon)) {
|
||||
return { proveedor: this.id, lat: mlat, lng: mlon };
|
||||
}
|
||||
|
||||
const mapa = (url.hash || "").match(PATRON_MAPA);
|
||||
if (mapa) {
|
||||
return { proveedor: this.id, lat: parseFloat(mapa[1]), lng: parseFloat(mapa[2]) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Proveedor de enlaces de Waze. Extrae las coordenadas del parámetro ll=
|
||||
// (https://waze.com/ul?ll=41.38,2.17&navigate=yes). Si el enlace solo trae
|
||||
// una búsqueda de texto (q=...), delega la resolución en la búsqueda por
|
||||
// texto de Nominatim marcando el resultado como proveedor "waze".
|
||||
import ProveedorUbicacion from "./ProveedorUbicacion.js";
|
||||
import ProveedorNominatim from "./proveedorNominatim.js";
|
||||
|
||||
const PATRON_LL = /^(-?\d{1,3}(?:\.\d+)?),\s*(-?\d{1,3}(?:\.\d+)?)$/;
|
||||
|
||||
function comoUrl(entrada) {
|
||||
const texto = (entrada || "").trim();
|
||||
if (!texto) return null;
|
||||
try {
|
||||
return new URL(/:\/\//i.test(texto) ? texto : `https://${texto}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default class ProveedorWaze extends ProveedorUbicacion {
|
||||
id = "waze";
|
||||
|
||||
// Instancia propia del resolvedor de texto (Waze no lo referencia desde el
|
||||
// registro para evitar acoplamientos con index.js).
|
||||
#nominatim = new ProveedorNominatim();
|
||||
|
||||
detecta(entrada) {
|
||||
return /waze\.(com|to)/i.test(entrada || "");
|
||||
}
|
||||
|
||||
async extrae(entrada) {
|
||||
const url = comoUrl(entrada);
|
||||
if (!url) return null;
|
||||
|
||||
const ll = url.searchParams.get("ll");
|
||||
if (ll) {
|
||||
const m = ll.match(PATRON_LL);
|
||||
if (m) {
|
||||
const lat = parseFloat(m[1]);
|
||||
const lng = parseFloat(m[2]);
|
||||
if (Math.abs(lat) <= 90 && Math.abs(lng) <= 180) {
|
||||
return { proveedor: this.id, lat, lng };
|
||||
}
|
||||
}
|
||||
return null; // ll= presente pero malformado
|
||||
}
|
||||
|
||||
// Solo búsqueda de texto: delegar en Nominatim manteniendo el proveedor
|
||||
const q = url.searchParams.get("q");
|
||||
if (q) {
|
||||
const resultado = await this.#nominatim.extrae(q);
|
||||
if (resultado) return { ...resultado, proveedor: this.id };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
// Control del hook de instalación sin depender de beforeinstallprompt
|
||||
const estadoHook = {
|
||||
isInstalled: false,
|
||||
canPromptInstall: false,
|
||||
promptInstall: vi.fn(async () => "accepted"),
|
||||
isIos: false,
|
||||
};
|
||||
vi.mock("../../src/hooks/usePwaInstall.js", () => ({ default: () => estadoHook }));
|
||||
|
||||
import BannerMovil from "../../src/components/BannerMovil.jsx";
|
||||
|
||||
const matchMediaMock = (esMovil) => (_query) => ({
|
||||
matches: esMovil,
|
||||
media: _query,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
estadoHook.isInstalled = false;
|
||||
estadoHook.canPromptInstall = false;
|
||||
estadoHook.isIos = false;
|
||||
localStorage.clear();
|
||||
localStorage.setItem("localesp_visitas", "5"); // ya superó el mínimo de 2 visitas
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("BannerMovil en PC", () => {
|
||||
beforeEach(() => vi.stubGlobal("matchMedia", matchMediaMock(false)));
|
||||
|
||||
it("muestra un botón compacto arriba a la izquierda, sin la tarjeta", () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it("con diálogo nativo, el botón instala directamente sin desplegar la tarjeta", async () => {
|
||||
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", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import BotonFlotante from "../../src/components/BotonFlotante.jsx";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("BotonFlotante", () => {
|
||||
it("expone su propósito con aria-label (accesible sin hover)", () => {
|
||||
render(<BotonFlotante onClick={() => {}} />);
|
||||
expect(screen.getByRole("button", { name: "Añade un nuevo local al directorio" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("tooltip oculto por defecto y visible al recibir foco", () => {
|
||||
render(<BotonFlotante onClick={() => {}} />);
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
|
||||
fireEvent.focus(screen.getByRole("button"));
|
||||
expect(screen.getByRole("tooltip").textContent).toBe("Añade un nuevo local al directorio");
|
||||
|
||||
fireEvent.blur(screen.getByRole("button"));
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
});
|
||||
|
||||
it("tooltip también en hover", () => {
|
||||
render(<BotonFlotante onClick={() => {}} />);
|
||||
fireEvent.mouseEnter(screen.getByRole("button"));
|
||||
expect(screen.getByRole("tooltip")).toBeTruthy();
|
||||
fireEvent.mouseLeave(screen.getByRole("button"));
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
});
|
||||
|
||||
it("al pulsarlo abre la pasarela (onClick)", () => {
|
||||
const onClick = vi.fn();
|
||||
render(<BotonFlotante onClick={onClick} />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import ModalPasarela from "../../src/components/ModalPasarela.jsx";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); // los mocks de propsBase se comparten entre pruebas
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const FORM_VACIO = {
|
||||
nombre: "", provincia: "", categoria: "", subcategoria: "",
|
||||
direccion: "", enlaceGoogleMaps: "", lat: null, lng: null,
|
||||
entradaUbicacion: "", proveedorUbicacion: "",
|
||||
};
|
||||
|
||||
const FORM_RESUELTO = {
|
||||
...FORM_VACIO,
|
||||
entradaUbicacion: "41.3851, 2.1734",
|
||||
proveedorUbicacion: "coordenadas",
|
||||
lat: 41.3851, lng: 2.1734,
|
||||
nombre: "Bar X", provincia: "Barcelona",
|
||||
categoria: "Restauración", subcategoria: "Tapas y raciones",
|
||||
};
|
||||
|
||||
const propsBase = {
|
||||
setForm: vi.fn(),
|
||||
onCerrar: vi.fn(),
|
||||
onEnviar: vi.fn(),
|
||||
onConfirmarDuplicado: vi.fn(),
|
||||
onDescartarDuplicado: vi.fn(),
|
||||
aceptaPrivacidad: false,
|
||||
setAceptaPrivacidad: vi.fn(),
|
||||
onAbrirPrivacidad: vi.fn(),
|
||||
errorFiltro: "",
|
||||
posibleDuplicado: null,
|
||||
comprobandoDuplicado: false,
|
||||
saving: false,
|
||||
};
|
||||
|
||||
const siguiente = () => screen.getByRole("button", { name: "Siguiente" });
|
||||
|
||||
describe("ModalPasarela", () => {
|
||||
it("arranca en la pantalla de Ubicación con las 6 pantallas declaradas", () => {
|
||||
render(<ModalPasarela {...propsBase} form={FORM_VACIO} />);
|
||||
expect(screen.getByLabelText(/ubicación del local/i)).toBeTruthy();
|
||||
expect(screen.getByText(/paso 1 de 6/i)).toBeTruthy();
|
||||
expect(screen.getAllByRole("generic", { hidden: true })).toBeTruthy();
|
||||
// indicador de progreso: 6 barras
|
||||
const barras = document.querySelectorAll('[aria-hidden="true"] span[style*="width: 16px"]');
|
||||
expect(barras.length).toBe(6);
|
||||
});
|
||||
|
||||
it("Siguiente deshabilitado sin resolución de ubicación válida", () => {
|
||||
render(<ModalPasarela {...propsBase} form={FORM_VACIO} />);
|
||||
expect(siguiente().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("Siguiente habilitado con datos válidos y navega; Atrás conserva los datos", () => {
|
||||
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
|
||||
expect(siguiente().disabled).toBe(false);
|
||||
fireEvent.click(siguiente());
|
||||
// paso 2: nombre pre-rellenado con lo inferido
|
||||
expect(screen.getByLabelText(/nombre del local/i).value).toBe("Bar X");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /atrás/i }));
|
||||
// vuelve a ubicación con la entrada conservada
|
||||
expect(screen.getByLabelText(/ubicación del local/i).value).toBe("41.3851, 2.1734");
|
||||
});
|
||||
|
||||
it("Escape cierra el modal (descarta el borrador vía onCerrar)", () => {
|
||||
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
|
||||
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
|
||||
expect(propsBase.onCerrar).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("✕ cierra el modal", () => {
|
||||
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cerrar" }));
|
||||
expect(propsBase.onCerrar).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("foco inicial en el primer control de cada pantalla", () => {
|
||||
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
|
||||
expect(document.activeElement).toBe(screen.getByLabelText(/ubicación del local/i));
|
||||
|
||||
fireEvent.click(siguiente());
|
||||
expect(document.activeElement).toBe(screen.getByLabelText(/nombre del local/i));
|
||||
});
|
||||
|
||||
it("Tab queda contenido dentro del modal (wrap del primer al último focusable)", () => {
|
||||
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
|
||||
// primer focusable del panel (el ✕ precede al cuerpo en el DOM)
|
||||
const cerrar = screen.getByRole("button", { name: "Cerrar" });
|
||||
cerrar.focus();
|
||||
|
||||
// Shift+Tab desde el primero → salta al último (Siguiente)
|
||||
fireEvent.keyDown(document.activeElement, { key: "Tab", shiftKey: true });
|
||||
expect(document.activeElement).toBe(siguiente());
|
||||
|
||||
// Tab desde el último → vuelve al primero
|
||||
fireEvent.keyDown(document.activeElement, { key: "Tab" });
|
||||
expect(document.activeElement).toBe(cerrar);
|
||||
});
|
||||
|
||||
it("en el paso final muestra el resumen con su propio botón de envío", () => {
|
||||
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
|
||||
for (let i = 0; i < 5; i++) fireEvent.click(siguiente());
|
||||
expect(screen.getByRole("button", { name: /enviar propuesta/i })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Siguiente" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import PasoResumen from "../../src/components/PasoResumen.jsx";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); // los mocks de propsBase se comparten entre pruebas
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const FORM_BASE = {
|
||||
nombre: "Bar El Olivo", provincia: "Madrid", categoria: "Restauración", subcategoria: "Tapas y raciones",
|
||||
direccion: "Calle Mayor 5, Madrid", enlaceGoogleMaps: "", lat: 40.4168, lng: -3.7038,
|
||||
entradaUbicacion: "40.4168, -3.7038", proveedorUbicacion: "coordenadas",
|
||||
};
|
||||
|
||||
const propsBase = {
|
||||
form: FORM_BASE,
|
||||
aceptaPrivacidad: false,
|
||||
setAceptaPrivacidad: vi.fn(),
|
||||
onAbrirPrivacidad: vi.fn(),
|
||||
onEnviar: vi.fn(),
|
||||
onConfirmarDuplicado: vi.fn(),
|
||||
onDescartarDuplicado: vi.fn(),
|
||||
errorFiltro: "",
|
||||
posibleDuplicado: null,
|
||||
comprobandoDuplicado: false,
|
||||
saving: false,
|
||||
};
|
||||
|
||||
describe("PasoResumen", () => {
|
||||
it("muestra un resumen legible de todos los datos", () => {
|
||||
render(<PasoResumen {...propsBase} />);
|
||||
expect(screen.getByText("Bar El Olivo")).toBeTruthy();
|
||||
expect(screen.getByText("Madrid")).toBeTruthy();
|
||||
expect(screen.getByText(/🍽️ Restauración/)).toBeTruthy();
|
||||
expect(screen.getByText("Tapas y raciones")).toBeTruthy();
|
||||
expect(screen.getByText("Calle Mayor 5, Madrid")).toBeTruthy();
|
||||
expect(screen.getByText(/Coordenadas/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("botón de envío deshabilitado sin aceptar la privacidad", () => {
|
||||
render(<PasoResumen {...propsBase} />);
|
||||
const boton = screen.getByRole("button", { name: /enviar propuesta/i });
|
||||
expect(boton.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("al marcar la aceptación se habilita el envío y dispara onEnviar", () => {
|
||||
render(<PasoResumen {...propsBase} />);
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
expect(propsBase.setAceptaPrivacidad).toHaveBeenCalledWith(true);
|
||||
|
||||
// con aceptación ya aplicada por el padre
|
||||
cleanup();
|
||||
render(<PasoResumen {...propsBase} aceptaPrivacidad />);
|
||||
const boton = screen.getByRole("button", { name: /enviar propuesta/i });
|
||||
expect(boton.disabled).toBe(false);
|
||||
fireEvent.click(boton);
|
||||
expect(propsBase.onEnviar).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("el enlace de privacidad abre el modal sin marcar el checkbox", () => {
|
||||
render(<PasoResumen {...propsBase} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /política de privacidad/i }));
|
||||
expect(propsBase.onAbrirPrivacidad).toHaveBeenCalledTimes(1);
|
||||
expect(propsBase.setAceptaPrivacidad).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renderiza el aviso de palabra prohibida dentro del modal", () => {
|
||||
render(<PasoResumen {...propsBase} errorFiltro="Tu propuesta contiene palabras no permitidas." />);
|
||||
expect(screen.getByRole("alert").textContent).toContain("palabras no permitidas");
|
||||
});
|
||||
|
||||
it("renderiza el aviso de duplicado con las opciones del flujo", () => {
|
||||
render(
|
||||
<PasoResumen
|
||||
{...propsBase}
|
||||
posibleDuplicado={{
|
||||
coincidencias: [{ id: "1", nombre: "Bar El Olivo", provincia: "Madrid", direccion: "", estado: "publicado" }],
|
||||
lat: 40.4168, lng: -3.7038,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const aviso = screen.getByRole("alert");
|
||||
expect(aviso.textContent).toContain("Ya existe un local parecido");
|
||||
expect(aviso.textContent).toContain("Bar El Olivo");
|
||||
fireEvent.click(screen.getByRole("button", { name: /enviar de todos modos/i }));
|
||||
expect(propsBase.onConfirmarDuplicado).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: /revisar datos/i }));
|
||||
expect(propsBase.onDescartarDuplicado).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("estados Comprobando…/Enviando… deshabilitan el envío", () => {
|
||||
const { rerender } = render(<PasoResumen {...propsBase} aceptaPrivacidad comprobandoDuplicado />);
|
||||
let boton = screen.getByRole("button", { name: /comprobando/i });
|
||||
expect(boton.disabled).toBe(true);
|
||||
|
||||
rerender(<PasoResumen {...propsBase} aceptaPrivacidad saving />);
|
||||
boton = screen.getByRole("button", { name: /enviando/i });
|
||||
expect(boton.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("../../src/utils/ubicacion/index.js", () => ({
|
||||
ETIQUETAS_PROVEEDOR: {
|
||||
"google-maps": "Google Maps",
|
||||
coordenadas: "Coordenadas",
|
||||
nominatim: "Dirección (OpenStreetMap)",
|
||||
},
|
||||
resolverUbicacion: vi.fn(),
|
||||
inferirDesdeCoords: vi.fn(),
|
||||
}));
|
||||
|
||||
import PasoUbicacion from "../../src/components/PasoUbicacion.jsx";
|
||||
import { resolverUbicacion, inferirDesdeCoords } from "../../src/utils/ubicacion/index.js";
|
||||
|
||||
const FORM = {
|
||||
nombre: "", provincia: "", categoria: "", subcategoria: "",
|
||||
direccion: "", enlaceGoogleMaps: "", lat: null, lng: null,
|
||||
entradaUbicacion: "", proveedorUbicacion: "",
|
||||
};
|
||||
|
||||
// setForm que aplica los updater sobre un objeto (como haría useState) sin
|
||||
// provocar re-render: suficiente para inspeccionar lo que el paso escribe.
|
||||
function renderPaso(formInicial = { ...FORM }) {
|
||||
const estado = { form: formInicial };
|
||||
const setForm = (updater) => {
|
||||
estado.form = typeof updater === "function" ? updater(estado.form) : updater;
|
||||
};
|
||||
render(<PasoUbicacion form={estado.form} setForm={setForm} />);
|
||||
return estado;
|
||||
}
|
||||
|
||||
const escribir = (texto) =>
|
||||
fireEvent.change(screen.getByLabelText(/ubicación del local/i), { target: { value: texto } });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("PasoUbicacion", () => {
|
||||
it("no resuelve antes del debounce (~600ms)", async () => {
|
||||
resolverUbicacion.mockResolvedValue(null);
|
||||
renderPaso();
|
||||
escribir("https://maps.google.com/@41.3851,2.1734");
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(300); });
|
||||
expect(resolverUbicacion).not.toHaveBeenCalled();
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(400); });
|
||||
expect(resolverUbicacion).toHaveBeenCalledWith("https://maps.google.com/@41.3851,2.1734");
|
||||
});
|
||||
|
||||
it("al resolver muestra el chip con el proveedor y coordenadas y pre-rellena el form", async () => {
|
||||
resolverUbicacion.mockResolvedValue({ proveedor: "google-maps", lat: 41.3851, lng: 2.1734, nombre: "Bar X", provincia: "Barcelona", direccion: "Bar X, Barcelona" });
|
||||
inferirDesdeCoords.mockResolvedValue(null); // ya trae nombre+provincia: no debe llamarse
|
||||
const estado = renderPaso();
|
||||
escribir("https://maps.google.com/@41.3851,2.1734");
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(601); });
|
||||
|
||||
expect(screen.getByText(/Google Maps detectado/)).toBeTruthy();
|
||||
expect(screen.getByText(/· 41\.3851, 2\.1734/)).toBeTruthy();
|
||||
expect(estado.form.lat).toBe(41.3851);
|
||||
expect(estado.form.lng).toBe(2.1734);
|
||||
expect(estado.form.proveedorUbicacion).toBe("google-maps");
|
||||
expect(estado.form.nombre).toBe("Bar X");
|
||||
expect(estado.form.provincia).toBe("Barcelona");
|
||||
expect(estado.form.direccion).toBe("Bar X, Barcelona");
|
||||
// es un enlace: se guarda como enlaceGoogleMaps del local
|
||||
expect(estado.form.enlaceGoogleMaps).toBe("https://maps.google.com/@41.3851,2.1734");
|
||||
expect(inferirDesdeCoords).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("con solo coordenadas lanza la inferencia inversa y pre-rellena lo inferido", async () => {
|
||||
resolverUbicacion.mockResolvedValue({ proveedor: "coordenadas", lat: 40.4168, lng: -3.7038, nombre: "", provincia: "" });
|
||||
inferirDesdeCoords.mockResolvedValue({ nombre: "Puerta del Sol", provincia: "Madrid", direccion: "Puerta del Sol, Madrid" });
|
||||
const estado = renderPaso();
|
||||
escribir("40.4168, -3.7038");
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(601); });
|
||||
|
||||
expect(inferirDesdeCoords).toHaveBeenCalledWith(40.4168, -3.7038);
|
||||
expect(estado.form.nombre).toBe("Puerta del Sol");
|
||||
expect(estado.form.provincia).toBe("Madrid");
|
||||
// coordenadas sueltas: no es un enlace
|
||||
expect(estado.form.enlaceGoogleMaps).toBe("");
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("entrada no reconocida → aviso y sin coordenadas en el form", async () => {
|
||||
resolverUbicacion.mockResolvedValue(null);
|
||||
const estado = renderPaso();
|
||||
escribir("https://maps.app.goo.gl/xyz");
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(601); });
|
||||
|
||||
const aviso = screen.getByRole("alert");
|
||||
expect(aviso.textContent).toContain("No se pudo interpretar la ubicación");
|
||||
expect(estado.form.lat).toBeNull();
|
||||
});
|
||||
|
||||
it("si la inferencia inversa falla, avisa pero mantiene la resolución válida", async () => {
|
||||
resolverUbicacion.mockResolvedValue({ proveedor: "coordenadas", lat: 1, lng: 2, nombre: "", provincia: "" });
|
||||
inferirDesdeCoords.mockResolvedValue(null); // fallo controlado
|
||||
renderPaso();
|
||||
escribir("1, 2");
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(601); });
|
||||
|
||||
expect(screen.getByText(/No se pudieron inferir el nombre y la provincia/i)).toBeTruthy();
|
||||
expect(screen.getByText(/Coordenadas detectado/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("al volver a editar invalida la resolución anterior", async () => {
|
||||
resolverUbicacion.mockResolvedValueOnce({ proveedor: "coordenadas", lat: 1, lng: 2 });
|
||||
const estado = renderPaso();
|
||||
escribir("1, 2");
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(601); });
|
||||
expect(estado.form.lat).toBe(1);
|
||||
expect(screen.getByText(/Coordenadas detectado/)).toBeTruthy();
|
||||
|
||||
escribir("1, 2 y algo más");
|
||||
expect(estado.form.lat).toBeNull(); // se limpia inmediatamente
|
||||
expect(screen.queryByText(/detectado/)).toBeNull(); // el chip desaparece
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import PasoNombre from "../../src/components/PasoNombre.jsx";
|
||||
import PasoProvincia from "../../src/components/PasoProvincia.jsx";
|
||||
import PasoCategoria from "../../src/components/PasoCategoria.jsx";
|
||||
import PasoSubcategoria from "../../src/components/PasoSubcategoria.jsx";
|
||||
import { PROVINCIAS } from "../../src/constants/provincias.js";
|
||||
import { CATEGORIAS, NOMBRES_CATEGORIAS } from "../../src/constants/categorias.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// Harness con useState real: los pasos escriben a través de setForm y el
|
||||
// estado resultante se lee desde fuera (más fiel que espiar el updater,
|
||||
// cuya evaluación diferida pierde el valor del evento en React 18).
|
||||
function renderPaso(Componente, inicial) {
|
||||
const estado = { form: inicial };
|
||||
function Wrapper() {
|
||||
const [form, setForm] = useState(inicial);
|
||||
estado.form = form;
|
||||
return <Componente form={form} setForm={setForm} />;
|
||||
}
|
||||
render(<Wrapper />);
|
||||
return estado;
|
||||
}
|
||||
|
||||
describe("PasoNombre", () => {
|
||||
it("muestra el nombre inferido y es editable", () => {
|
||||
const estado = renderPaso(PasoNombre, { nombre: "Bar El Olivo" });
|
||||
const input = screen.getByLabelText(/nombre del local/i);
|
||||
expect(input.value).toBe("Bar El Olivo");
|
||||
fireEvent.change(input, { target: { value: "Bar La Plaza" } });
|
||||
expect(estado.form.nombre).toBe("Bar La Plaza");
|
||||
});
|
||||
|
||||
it("vacío si la inferencia falló", () => {
|
||||
renderPaso(PasoNombre, { nombre: "" });
|
||||
expect(screen.getByLabelText(/nombre del local/i).value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PasoProvincia", () => {
|
||||
it("es un select con el listado PROVINCIAS y la provincia pre-rellenada", () => {
|
||||
renderPaso(PasoProvincia, { provincia: "La Coruña" });
|
||||
const select = screen.getByLabelText(/provincia/i);
|
||||
expect(select.value).toBe("La Coruña");
|
||||
const opciones = [...select.options].map((o) => o.value);
|
||||
expect(opciones).toEqual(["", ...PROVINCIAS]);
|
||||
fireEvent.change(select, { target: { value: "Madrid" } });
|
||||
// (la aserción de estado se cubre con el harness en el resto de pasos)
|
||||
});
|
||||
|
||||
it("sin coincidencia queda vacío (selección manual)", () => {
|
||||
renderPaso(PasoProvincia, { provincia: "" });
|
||||
expect(screen.getByLabelText(/provincia/i).value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PasoCategoria", () => {
|
||||
it("ofrece todas las categorías y al elegir limpia la subcategoría", () => {
|
||||
const estado = renderPaso(PasoCategoria, { categoria: "Pequeño comercio", subcategoria: "Farmacia" });
|
||||
const select = screen.getByLabelText(/categoría/i);
|
||||
const opciones = [...select.options].map((o) => o.value);
|
||||
expect(opciones).toEqual(["", ...NOMBRES_CATEGORIAS]);
|
||||
|
||||
fireEvent.change(select, { target: { value: "Restauración" } });
|
||||
expect(estado.form.categoria).toBe("Restauración");
|
||||
expect(estado.form.subcategoria).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PasoSubcategoria", () => {
|
||||
it("deshabilitado sin categoría y opcional con ella", () => {
|
||||
const { rerender } = render(<PasoSubcategoria form={{ categoria: "", subcategoria: "" }} setForm={() => {}} />);
|
||||
const select = screen.getByLabelText(/tipo específico/i);
|
||||
expect(select.disabled).toBe(true);
|
||||
expect([...select.options][0].textContent).toMatch(/sin tipo específico/i); // se puede continuar
|
||||
|
||||
rerender(<PasoSubcategoria form={{ categoria: "Restauración", subcategoria: "" }} setForm={() => {}} />);
|
||||
expect(select.disabled).toBe(false);
|
||||
const opciones = [...select.options].map((o) => o.value);
|
||||
expect(opciones).toEqual(["", ...CATEGORIAS["Restauración"].subcategorias]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
|
||||
// BD en memoria: aislada, offline y determinista. Debe configurarse ANTES de
|
||||
// importar la app (server/db.js resuelve la ruta en la carga del módulo).
|
||||
process.env.LOCALESP_DB = ":memory:";
|
||||
|
||||
let app;
|
||||
beforeAll(async () => {
|
||||
({ default: app } = await import("../../server/index.js"));
|
||||
});
|
||||
|
||||
// Payload equivalente al que envía la pasarela tras este cambio: sin
|
||||
// descripcion y sin puntuacion (el backend aplica sus defaults).
|
||||
const PROPUESTA_PASARELA = {
|
||||
nombre: "Bar El Olivo",
|
||||
provincia: "Madrid",
|
||||
categoria: "Restauración",
|
||||
subcategoria: "Tapas y raciones",
|
||||
direccion: "Calle Mayor 5, Alcalá de Henares",
|
||||
enlaceGoogleMaps: "",
|
||||
lat: 40.4828,
|
||||
lng: -3.3652,
|
||||
};
|
||||
|
||||
describe("Integración API local (Express + SQLite en memoria)", () => {
|
||||
it("GET /api/locales arranca limpio", async () => {
|
||||
const r = await request(app).get("/api/locales");
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("POST /api/propuestas acepta una propuesta de la pasarela sin descripcion ni puntuacion (defaults)", async () => {
|
||||
const r = await request(app).post("/api/propuestas").send(PROPUESTA_PASARELA);
|
||||
expect(r.status).toBe(201);
|
||||
expect(r.body.nombre).toBe("Bar El Olivo");
|
||||
expect(r.body.puntuacion).toBe(0); // default del backend
|
||||
expect(r.body.estado).toBe("pendiente");
|
||||
expect(r.body.id).toBeTruthy();
|
||||
|
||||
// y aparece en pendientes (vista admin)
|
||||
const pend = await request(app).get("/api/pendientes");
|
||||
expect(pend.body.map((p) => p.nombre)).toContain("Bar El Olivo");
|
||||
});
|
||||
|
||||
it("POST /api/check-duplicado detecta mismo nombre normalizado en la misma provincia", async () => {
|
||||
const r = await request(app)
|
||||
.post("/api/check-duplicado")
|
||||
.send({ nombre: "bar el olivo", provincia: "Madrid", lat: 41.0, lng: -4.0 }); // lejos: cuenta la provincia
|
||||
expect(r.body.duplicado).toBe(true);
|
||||
expect(r.body.coincidencias[0]).toMatchObject({
|
||||
nombre: "Bar El Olivo",
|
||||
provincia: "Madrid",
|
||||
estado: "pendiente", // aún no publicado
|
||||
});
|
||||
});
|
||||
|
||||
it("POST /api/check-duplicado detecta por cercanía aunque cambie la provincia", async () => {
|
||||
const r = await request(app)
|
||||
.post("/api/check-duplicado")
|
||||
.send({ nombre: "BAR EL OLIVO", provincia: "Barcelona", lat: 40.48281, lng: -3.36521 }); // ~1-2 m
|
||||
expect(r.body.duplicado).toBe(true);
|
||||
});
|
||||
|
||||
it("POST /api/check-duplicado no marca nombres distintos ni mismos nombres lejos y en otra provincia", async () => {
|
||||
const distinto = await request(app)
|
||||
.post("/api/check-duplicado")
|
||||
.send({ nombre: "Cafetería Luna", provincia: "Madrid", lat: 40.48, lng: -3.36 });
|
||||
expect(distinto.body.duplicado).toBe(false);
|
||||
|
||||
const lejosOtraProvincia = await request(app)
|
||||
.post("/api/check-duplicado")
|
||||
.send({ nombre: "Bar El Olivo", provincia: "Sevilla", lat: 37.38, lng: -5.98 });
|
||||
expect(lejosOtraProvincia.body.duplicado).toBe(false);
|
||||
});
|
||||
|
||||
it("flujo admin completo: aprobar publica el local con los datos de la propuesta", async () => {
|
||||
const propuesta = (await request(app).get("/api/pendientes")).body[0];
|
||||
const aprobado = await request(app).post(`/api/aprobar/${propuesta.id}`);
|
||||
expect(aprobado.body.ok).toBe(true);
|
||||
|
||||
const locales = (await request(app).get("/api/locales")).body;
|
||||
const publicado = locales.find((l) => l.nombre === "Bar El Olivo");
|
||||
expect(publicado).toMatchObject({
|
||||
provincia: "Madrid",
|
||||
categoria: "Restauración",
|
||||
subcategoria: "Tapas y raciones",
|
||||
direccion: "Calle Mayor 5, Alcalá de Henares",
|
||||
puntuacion: 0, // default aplicado en cascada
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { inferirDesdeCoords, resolverUbicacion } from "../../src/utils/ubicacion/index.js";
|
||||
|
||||
// Integración real con Nominatim (OpenStreetMap). Suite opt-in
|
||||
// (`npm run test:integration`): se auto-salta sin conectividad y mantiene un
|
||||
// ritmo respetuoso con la política de uso (~1 req/s) esperando entre pruebas.
|
||||
const PAUSA_MS = 1100;
|
||||
const pausar = () => new Promise((r) => setTimeout(r, PAUSA_MS));
|
||||
|
||||
const conRed = await fetch("https://nominatim.openstreetmap.org/status", {
|
||||
headers: { "User-Agent": "LocalesEspanoles/1.0" },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
.then((r) => r.ok)
|
||||
.catch(() => false);
|
||||
|
||||
describe.skipIf(!conRed)("Integración Nominatim (red real)", () => {
|
||||
beforeAll(() => {
|
||||
if (!conRed) console.warn("Sin conectividad a Nominatim: suite saltada.");
|
||||
});
|
||||
|
||||
it("busca una dirección en texto y devuelve coordenadas + provincia canónica", async () => {
|
||||
const r = await resolverUbicacion("Calle Mayor 5, Alcalá de Henares");
|
||||
expect(r.proveedor).toBe("nominatim");
|
||||
expect(r.lat).toBeGreaterThan(40.4);
|
||||
expect(r.lat).toBeLessThan(40.6);
|
||||
expect(r.lng).toBeGreaterThan(-3.5);
|
||||
expect(r.lng).toBeLessThan(-3.2);
|
||||
expect(r.provincia).toBe("Madrid");
|
||||
});
|
||||
|
||||
it("geocodificación inversa de Madrid devuelve la provincia canónica", async () => {
|
||||
await pausar();
|
||||
const r = await inferirDesdeCoords(40.4168, -3.7038);
|
||||
expect(r).not.toBeNull();
|
||||
expect(r.provincia).toBe("Madrid");
|
||||
expect(r.nombre).toBeTruthy();
|
||||
expect(r.direccion).toBeTruthy();
|
||||
});
|
||||
|
||||
it("geocodificación inversa de Bilbao normaliza Bizkaia → Vizcaya (acentos/cooficial)", async () => {
|
||||
await pausar();
|
||||
const r = await inferirDesdeCoords(43.263, -2.935);
|
||||
expect(r.provincia).toBe("Vizcaya");
|
||||
});
|
||||
|
||||
it("geocodificación inversa fuera de España deja la provincia vacía (selección manual)", async () => {
|
||||
await pausar();
|
||||
const r = await inferirDesdeCoords(48.8584, 2.2945); // Torre Eiffel
|
||||
expect(r).not.toBeNull();
|
||||
expect(r.provincia).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
// Setup común: React 18 exige este flag para act() fuera de Jest.
|
||||
// Inofensivo en las pruebas de Node puro.
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import ProveedorUbicacion from "../../src/utils/ubicacion/ProveedorUbicacion.js";
|
||||
|
||||
describe("ProveedorUbicacion (clase base)", () => {
|
||||
it("normaliza: minúsculas, sin tildes, sin espacios extremos", () => {
|
||||
const p = new ProveedorUbicacion();
|
||||
expect(p.normalizar(" ÁLAVA ")).toBe("alava");
|
||||
expect(p.normalizar("Guipúzcoa")).toBe("guipuzcoa");
|
||||
expect(ProveedorUbicacion.normalizar("MA DRID")).toBe("ma drid");
|
||||
expect(p.normalizar(null)).toBe("");
|
||||
});
|
||||
|
||||
describe("emparejarProvincia", () => {
|
||||
const p = new ProveedorUbicacion();
|
||||
|
||||
it("coincidencia exacta insensible a acentos y mayúsculas", () => {
|
||||
expect(p.emparejarProvincia("ALAVA")).toBe("Álava");
|
||||
expect(p.emparejarProvincia("Guipuzcoa")).toBe("Guipúzcoa");
|
||||
});
|
||||
|
||||
it("coincidencia parcial dentro de un texto (Comunidad de Madrid)", () => {
|
||||
expect(p.emparejarProvincia("Comunidad de Madrid")).toBe("Madrid");
|
||||
expect(p.emparejarProvincia("Santiago, A Coruña, Galicia")).toBe("La Coruña");
|
||||
});
|
||||
|
||||
it("tolera artículos anteuestos (A Coruña → La Coruña)", () => {
|
||||
expect(p.emparejarProvincia("A Coruña")).toBe("La Coruña");
|
||||
expect(p.emparejarProvincia("Ourense")).toBe("Orense");
|
||||
});
|
||||
|
||||
it("alias de nombres cooficiales (D12)", () => {
|
||||
expect(p.emparejarProvincia("Bizkaia")).toBe("Vizcaya");
|
||||
expect(p.emparejarProvincia("Gipuzkoa")).toBe("Guipúzcoa");
|
||||
expect(p.emparejarProvincia("Girona")).toBe("Gerona");
|
||||
expect(p.emparejarProvincia("Lleida")).toBe("Lérida");
|
||||
expect(p.emparejarProvincia("Illes Balears")).toBe("Islas Baleares");
|
||||
expect(p.emparejarProvincia("Asturies")).toBe("Asturias");
|
||||
});
|
||||
|
||||
it("devuelve \"\" cuando no casa con ninguna provincia", () => {
|
||||
expect(p.emparejarProvincia("Île-de-France")).toBe("");
|
||||
expect(p.emparejarProvincia("")).toBe("");
|
||||
expect(p.emparejarProvincia("Lisboa")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("el contrato base no acepta ninguna entrada", () => {
|
||||
const p = new ProveedorUbicacion();
|
||||
expect(p.detecta("41.38, 2.17")).toBe(false);
|
||||
expect(p.detecta("https://maps.google.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("extrae() de la base lanza \"sin implementar\" (método abstracto)", async () => {
|
||||
const p = new ProveedorUbicacion();
|
||||
await expect(p.extrae("x")).rejects.toThrow("sin implementar");
|
||||
});
|
||||
|
||||
describe("fetchNominatim degrada errores a null", () => {
|
||||
it("devuelve null si fetch lanza (sin red)", async () => {
|
||||
const p = new ProveedorUbicacion();
|
||||
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new Error("sin red"))));
|
||||
expect(await p.fetchNominatim("https://nominatim.openstreetmap.org/x")).toBeNull();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("devuelve null si la respuesta HTTP no es ok", async () => {
|
||||
const p = new ProveedorUbicacion();
|
||||
vi.stubGlobal("fetch", vi.fn(() => Promise.resolve({ ok: false, status: 503 })));
|
||||
expect(await p.fetchNominatim("https://nominatim.openstreetmap.org/x")).toBeNull();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("devuelve el JSON cuando todo va bien y manda cabeceras", async () => {
|
||||
const p = new ProveedorUbicacion();
|
||||
const fetchMock = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve([{ lat: "1" }]) }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
expect(await p.fetchNominatim("https://nominatim.openstreetmap.org/x")).toEqual([{ lat: "1" }]);
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(init.headers["Accept-Language"]).toBe("es");
|
||||
expect(init.headers["User-Agent"]).toBe("LocalesEspanoles/1.0");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ProveedorCoordenadas from "../../src/utils/ubicacion/proveedorCoordenadas.js";
|
||||
|
||||
describe("ProveedorCoordenadas", () => {
|
||||
const p = new ProveedorCoordenadas();
|
||||
|
||||
it("detecta pares con coma y punto decimal, signo y espacios", async () => {
|
||||
for (const [entrada, lat, lng] of [
|
||||
["41.3851, 2.1734", 41.3851, 2.1734],
|
||||
["41,3851,2,1734", 41.3851, 2.1734], // decimales españoles con coma
|
||||
["-3.7038, 40.4168", -3.7038, 40.4168], // orden invertido con signo
|
||||
[" 41.38 ; 2.17 ", 41.38, 2.17], // separador ; y espacios
|
||||
["41,3851 2,1734", 41.3851, 2.1734], // separado por espacio
|
||||
]) {
|
||||
expect(p.detecta(entrada), entrada).toBe(true);
|
||||
expect(await p.extrae(entrada), entrada).toEqual({ proveedor: "coordenadas", lat, lng });
|
||||
}
|
||||
});
|
||||
|
||||
it("latitud fuera de rango → null", async () => {
|
||||
expect(p.detecta("91.234, 2.17")).toBe(true); // sintácticamente es un par…
|
||||
expect(await p.extrae("91.234, 2.17")).toBeNull(); // …pero se descarta por rango
|
||||
expect(await p.extrae("41.38, 181.5")).toBeNull();
|
||||
});
|
||||
|
||||
it("no detecta textos ni URLs ni números sueltos", () => {
|
||||
expect(p.detecta("Calle Mayor 5, Madrid")).toBe(false);
|
||||
expect(p.detecta("https://maps.google.com/@41.38,2.17")).toBe(false);
|
||||
expect(p.detecta("41.3851")).toBe(false);
|
||||
expect(p.detecta("")).toBe(false);
|
||||
});
|
||||
|
||||
it("entrada no par → extrae null", async () => {
|
||||
expect(await p.extrae("hola mundo")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ProveedorGoogleMaps from "../../src/utils/ubicacion/proveedorGoogleMaps.js";
|
||||
|
||||
describe("ProveedorGoogleMaps", () => {
|
||||
const p = new ProveedorGoogleMaps();
|
||||
|
||||
it("detecta URLs de Google Maps (dominios y subdominios)", () => {
|
||||
expect(p.detecta("https://maps.google.com/@41.38,2.17")).toBe(true);
|
||||
expect(p.detecta("https://www.google.es/maps?q=41.38,2.17")).toBe(true);
|
||||
expect(p.detecta("https://maps.app.goo.gl/abc")).toBe(true);
|
||||
expect(p.detecta("https://waze.com/ul?ll=41.38,2.17")).toBe(false);
|
||||
expect(p.detecta("41.38, 2.17")).toBe(false);
|
||||
});
|
||||
|
||||
it("extrae del formato @lat,lng", async () => {
|
||||
expect(await p.extrae("https://www.google.com/maps/@41.3851,2.1734,15z"))
|
||||
.toEqual({ proveedor: "google-maps", lat: 41.3851, lng: 2.1734 });
|
||||
});
|
||||
|
||||
it("extrae del formato !3d…!4d… cuando no hay @", async () => {
|
||||
expect(await p.extrae("https://www.google.com/maps/place/Bar/data=!4m2!3m1!1s0x0:0x0?hl=es"))
|
||||
.toBeNull(); // sin coordenadas en absoluto
|
||||
expect(await p.extrae("https://www.google.com/maps/place/Bar/data=!3d41.3851!4d2.1734"))
|
||||
.toEqual({ proveedor: "google-maps", lat: 41.3851, lng: 2.1734 });
|
||||
});
|
||||
|
||||
it("extrae del formato ?q=lat,lng", async () => {
|
||||
expect(await p.extrae("https://www.google.com/maps?q=41.3851,-2.1734"))
|
||||
.toEqual({ proveedor: "google-maps", lat: 41.3851, lng: -2.1734 });
|
||||
});
|
||||
|
||||
it("prioriza @ sobre !3d!4d (comportamiento migrado de parsearEnlaceGoogleMaps)", async () => {
|
||||
const r = await p.extrae("https://www.google.com/maps/place/X/@40.4168,-3.7038,17z/data=!3d40.4178!4d-3.7028");
|
||||
expect(r).toEqual({ proveedor: "google-maps", lat: 40.4168, lng: -3.7038 });
|
||||
});
|
||||
|
||||
it("enlace acortado: detecta pero no puede extraer → null", async () => {
|
||||
expect(await p.extrae("https://maps.app.goo.gl/AbCdEf123")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import ProveedorNominatim from "../../src/utils/ubicacion/proveedorNominatim.js";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("ProveedorNominatim", () => {
|
||||
const p = new ProveedorNominatim();
|
||||
|
||||
describe("detecta (fallback de texto)", () => {
|
||||
it("acepta direcciones y nombres con letras", () => {
|
||||
expect(p.detecta("Calle Mayor 5, Alcalá de Henares, Madrid")).toBe(true);
|
||||
expect(p.detecta("Bar El Olivo Sevilla")).toBe(true);
|
||||
});
|
||||
|
||||
it("rechaza URLs (las gestiona otro proveedor)", () => {
|
||||
expect(p.detecta("https://ejemplo.com/calle")).toBe(false);
|
||||
expect(p.detecta("www.ejemplo.com")).toBe(false);
|
||||
expect(p.detecta("https://maps.app.goo.gl/abc")).toBe(false);
|
||||
});
|
||||
|
||||
it("rechaza textos demasiado cortos o sin letras", () => {
|
||||
expect(p.detecta("ab")).toBe(false);
|
||||
expect(p.detecta("")).toBe(false);
|
||||
expect(p.detecta("12345")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("desdeResultado (normalización de provincia)", () => {
|
||||
it("normaliza provincia canónica insensible a acentos", () => {
|
||||
const r = p.desdeResultado({
|
||||
lat: "40.4", lon: "-3.7",
|
||||
display_name: "X, Madrid, España",
|
||||
address: { state: "Comunidad de Madrid" },
|
||||
namedetails: { name: "X" },
|
||||
});
|
||||
expect(r.provincia).toBe("Madrid");
|
||||
});
|
||||
|
||||
it("alias cooficiales: Bizkaia → Vizcaya, A Coruña → La Coruña", () => {
|
||||
const r = p.desdeResultado({ lat: "1", lon: "2", display_name: "d", address: { province: "Bizkaia" }, namedetails: {} });
|
||||
expect(r.provincia).toBe("Vizcaya");
|
||||
const r2 = p.desdeResultado({ lat: "1", lon: "2", display_name: "d", address: { city: "A Coruña" }, namedetails: {} });
|
||||
expect(r2.provincia).toBe("La Coruña");
|
||||
});
|
||||
|
||||
it("provincia fuera del listado → \"\" (selección manual)", () => {
|
||||
const r = p.desdeResultado({ lat: "1", lon: "2", display_name: "d", address: { state: "Île-de-France" }, namedetails: {} });
|
||||
expect(r.provincia).toBe("");
|
||||
});
|
||||
|
||||
it("resultado extensible: lat/lng/proveedor/nombre/direccion", () => {
|
||||
const r = p.desdeResultado({
|
||||
lat: "41.5", lon: "2.1",
|
||||
display_name: "X, Girona, España",
|
||||
address: { province: "Girona" },
|
||||
namedetails: { name: "X" },
|
||||
});
|
||||
expect(r).toEqual({ proveedor: "nominatim", lat: 41.5, lng: 2.1, nombre: "X", provincia: "Gerona", direccion: "X, Girona, España" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("extrae (búsqueda por texto)", () => {
|
||||
it("devuelve el primer resultado normalizado", async () => {
|
||||
const fetchMock = vi.fn(() => ({
|
||||
ok: true,
|
||||
json: async () => [{
|
||||
lat: "40.4828632", lon: "-3.3652613",
|
||||
display_name: "5, Calle Mayor, Alcalá de Henares, España",
|
||||
address: { city: "Alcalá de Henares", state: "Comunidad de Madrid" },
|
||||
namedetails: { name: "Casa de la Panadería" },
|
||||
}],
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const r = await p.extrae("Calle Mayor 5, Alcalá de Henares");
|
||||
expect(r.proveedor).toBe("nominatim");
|
||||
expect(r.lat).toBeCloseTo(40.4828632, 6);
|
||||
expect(r.provincia).toBe("Madrid");
|
||||
expect(r.nombre).toBe("Casa de la Panadería");
|
||||
// la búsqueda acota a España
|
||||
const [url] = fetchMock.mock.calls[0];
|
||||
expect(decodeURIComponent(String(url).replace(/\+/g, " "))).toContain("Calle Mayor 5, Alcalá de Henares, España");
|
||||
});
|
||||
|
||||
it("sin resultados → null", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(() => ({ ok: true, json: async () => [] })));
|
||||
expect(await p.extrae("calle inventada zzzz")).toBeNull();
|
||||
});
|
||||
|
||||
it("fallo de red → null (degradación controlada)", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new Error("sin red"))));
|
||||
expect(await p.extrae("Calle Mayor 5, Madrid")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ProveedorOSM from "../../src/utils/ubicacion/proveedorOSM.js";
|
||||
|
||||
describe("ProveedorOSM", () => {
|
||||
const p = new ProveedorOSM();
|
||||
|
||||
it("detecta URLs de openstreetmap.org", () => {
|
||||
expect(p.detecta("https://www.openstreetmap.org/#map=12/39.4699/-0.3763")).toBe(true);
|
||||
expect(p.detecta("https://openstreetmap.org/?mlat=41.38&mlon=2.17")).toBe(true);
|
||||
expect(p.detecta("https://www.google.com/maps")).toBe(false);
|
||||
});
|
||||
|
||||
it("extrae de mlat/mlon (enlace de marcador)", async () => {
|
||||
expect(await p.extrae("https://www.openstreetmap.org/?mlat=41.9794&mlon=2.8214"))
|
||||
.toEqual({ proveedor: "osm", lat: 41.9794, lng: 2.8214 });
|
||||
});
|
||||
|
||||
it("prioriza mlat/mlon sobre el fragmento #map=", async () => {
|
||||
const r = await p.extrae("https://www.openstreetmap.org/?mlat=41.9794&mlon=2.8214#map=15/39.4699/-0.3763");
|
||||
expect(r).toEqual({ proveedor: "osm", lat: 41.9794, lng: 2.8214 });
|
||||
});
|
||||
|
||||
it("extrae del fragmento #map=zoom/lat/lng cuando no hay mlat/mlon", async () => {
|
||||
expect(await p.extrae("https://www.openstreetmap.org/#map=12/39.4699/-0.3763"))
|
||||
.toEqual({ proveedor: "osm", lat: 39.4699, lng: -0.3763 });
|
||||
expect(await p.extrae("https://www.openstreet.org/#map=16.5/43.5321/-5.6556")) // zoom decimal
|
||||
.toEqual({ proveedor: "osm", lat: 43.5321, lng: -5.6556 });
|
||||
});
|
||||
|
||||
it("sin mlat/mlon ni #map= → null", async () => {
|
||||
expect(await p.extrae("https://www.openstreetmap.org/way/123456")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import ProveedorWaze from "../../src/utils/ubicacion/proveedorWaze.js";
|
||||
|
||||
const respuestaNominatim = (resultados) => ({
|
||||
ok: true,
|
||||
json: async () => resultados,
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("ProveedorWaze", () => {
|
||||
const p = new ProveedorWaze();
|
||||
|
||||
it("detecta URLs de Waze", () => {
|
||||
expect(p.detecta("https://waze.com/ul?ll=41.38,2.17&navigate=yes")).toBe(true);
|
||||
expect(p.detecta("https://www.waze.com/live-map/")).toBe(true);
|
||||
expect(p.detecta("waze.to/abc")).toBe(true);
|
||||
expect(p.detecta("https://google.com/maps")).toBe(false);
|
||||
});
|
||||
|
||||
it("extrae de ll=lat,lng", async () => {
|
||||
expect(await p.extrae("https://waze.com/ul?ll=41.3851,2.1734&navigate=yes"))
|
||||
.toEqual({ proveedor: "waze", lat: 41.3851, lng: 2.1734 });
|
||||
// sin esquema https://
|
||||
expect(await p.extrae("waze.com/ul?ll=43.263,-2.935"))
|
||||
.toEqual({ proveedor: "waze", lat: 43.263, lng: -2.935 });
|
||||
});
|
||||
|
||||
it("ll malformado → null sin consultar Nominatim", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
expect(await p.extrae("https://waze.com/ul?ll=abc,def")).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("q=texto delega en la búsqueda de Nominatim marcando proveedor waze", async () => {
|
||||
const fetchMock = vi.fn(() => respuestaNominatim([{
|
||||
lat: "40.4828632", lon: "-3.3652613",
|
||||
display_name: "5, Calle Mayor, Alcalá de Henares, Comunidad de Madrid, 28801, España",
|
||||
address: { city: "Alcalá de Henares", state: "Comunidad de Madrid" },
|
||||
namedetails: { name: "" },
|
||||
}]));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const r = await p.extrae("https://waze.com/ul?q=Calle%20Mayor%205%2C%20Alcal%C3%A1%20de%20Henares");
|
||||
expect(r).toEqual({
|
||||
proveedor: "waze",
|
||||
lat: 40.4828632,
|
||||
lng: -3.3652613,
|
||||
nombre: "",
|
||||
provincia: "Madrid",
|
||||
direccion: "5, Calle Mayor, Alcalá de Henares, Comunidad de Madrid, 28801, España",
|
||||
});
|
||||
// la petición lleva countrycodes=es y las cabeceras de Nominatim
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toContain("countrycodes=es");
|
||||
expect(init.headers["User-Agent"]).toBe("LocalesEspanoles/1.0");
|
||||
});
|
||||
|
||||
it("q=texto sin resultados en Nominatim → null", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(() => respuestaNominatim([])));
|
||||
expect(await p.extrae("https://waze.com/ul?q=calle%20inexistente%20zzzz")).toBeNull();
|
||||
});
|
||||
|
||||
it("URL de Waze sin ll ni q → null", async () => {
|
||||
expect(await p.extrae("https://waze.com/live-map/")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ETIQUETAS_PROVEEDOR, PROVEEDORES, inferirDesdeCoords, resolverUbicacion } from "../../src/utils/ubicacion/index.js";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("resolverUbicacion", () => {
|
||||
it("respeta el orden de prioridad de PROVEEDORES (coordenadas → google → waze → osm → nominatim)", () => {
|
||||
expect(PROVEEDORES.map((p) => p.id)).toEqual(["coordenadas", "google-maps", "waze", "osm", "nominatim"]);
|
||||
});
|
||||
|
||||
it("resuelve un enlace de Google Maps sin tocar la red", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const r = await resolverUbicacion("https://maps.google.com/@41.3851,2.1734,15z");
|
||||
expect(r).toEqual({ proveedor: "google-maps", lat: 41.3851, lng: 2.1734 });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resuelve coordenadas sueltas con el proveedor de coordenadas", async () => {
|
||||
const r = await resolverUbicacion("41.3851, 2.1734");
|
||||
expect(r.proveedor).toBe("coordenadas");
|
||||
});
|
||||
|
||||
it("hace fallback a Nominatim con texto de dirección (fetch simulado)", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(() => ({
|
||||
ok: true,
|
||||
json: async () => [{ lat: "40.1", lon: "-3.2", display_name: "X, Madrid", address: { state: "Comunidad de Madrid" }, namedetails: {} }],
|
||||
})));
|
||||
const r = await resolverUbicacion("Calle Mayor 5, Madrid");
|
||||
expect(r.proveedor).toBe("nominatim");
|
||||
expect(r.provincia).toBe("Madrid");
|
||||
});
|
||||
|
||||
it("un proveedor que lanza se degrada y continúa con el siguiente", async () => {
|
||||
// se inyecta temporalmente un proveedor roto con máxima prioridad
|
||||
const roto = { id: "roto", detecta: () => true, extrae: async () => { throw new Error("boom"); } };
|
||||
PROVEEDORES.unshift(roto);
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
const r = await resolverUbicacion("https://maps.google.com/@41.3851,2.1734");
|
||||
expect(r.proveedor).toBe("google-maps");
|
||||
expect(error).toHaveBeenCalled();
|
||||
} finally {
|
||||
PROVEEDORES.shift();
|
||||
error.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("entrada no reconocida por nadie → null", async () => {
|
||||
expect(await resolverUbicacion("https://maps.app.goo.gl/abc123")).toBeNull();
|
||||
expect(await resolverUbicacion("")).toBeNull();
|
||||
expect(await resolverUbicacion(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferirDesdeCoords", () => {
|
||||
it("usa el reverse de Nominatim y normaliza la provincia", async () => {
|
||||
const fetchMock = vi.fn(() => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
lat: "40.4168", lon: "-3.7038",
|
||||
display_name: "Puerta del Sol, Madrid, España",
|
||||
address: { state: "Comunidad de Madrid" },
|
||||
namedetails: { name: "Puerta del Sol" },
|
||||
}),
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const r = await inferirDesdeCoords(40.4168, -3.7038);
|
||||
expect(r).toEqual({ nombre: "Puerta del Sol", provincia: "Madrid", direccion: "Puerta del Sol, Madrid, España" });
|
||||
const [url] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toContain("/reverse");
|
||||
expect(String(url)).toContain("addressdetails=1");
|
||||
expect(String(url)).toContain("namedetails=1");
|
||||
});
|
||||
|
||||
it("fallo de red → null sin lanzar", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new Error("sin red"))));
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(await inferirDesdeCoords(1, 1)).toBeNull();
|
||||
error.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ETIQUETAS_PROVEEDOR", () => {
|
||||
it("cubre todos los ids registrados (para los chips de la UI)", () => {
|
||||
for (const p of PROVEEDORES) {
|
||||
expect(ETIQUETAS_PROVEEDOR[p.id], `falta etiqueta para ${p.id}`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// Suite de integración (opt-in, `npm run test:integration`): habla con los
|
||||
// backends reales — la API Express con BD en memoria y Nominatim con red de
|
||||
// verdad (se auto-salta sin conectividad). Timeouts amplios por las llamadas
|
||||
// de red y el ritmo ~1 req/s impuesto a Nominatim.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["tests/integration/**/*.spec.js"],
|
||||
testTimeout: 30000,
|
||||
hookTimeout: 15000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// Suite rápida (unitarias + componente): 100% offline y determinista para CI.
|
||||
// Las pruebas de componente usan jsdom vía docblock // @vitest-environment jsdom
|
||||
// al inicio de cada archivo; el resto corre en Node puro (más rápido).
|
||||
export default defineConfig({
|
||||
esbuild: { jsx: "automatic" },
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["tests/unit/**/*.spec.{js,jsx}", "tests/component/**/*.spec.jsx"],
|
||||
setupFiles: ["tests/setup.js"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user