Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cc326e8ac | ||
|
|
6323fe8f0e | ||
|
|
adb2b4dfe4 | ||
|
|
98b19aa3a2 | ||
|
|
27166eb3f4 | ||
|
|
e08d959cf9 | ||
|
|
c06300833b |
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: Bug
|
||||||
|
about: Reportar un error
|
||||||
|
labels: bug
|
||||||
|
---
|
||||||
|
|
||||||
|
**Qué pasa**
|
||||||
|
Descripción breve.
|
||||||
|
|
||||||
|
**Pasos para reproducir**
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
|
||||||
|
**Qué esperabas**
|
||||||
|
|
||||||
|
**Entorno**
|
||||||
|
- Navegador:
|
||||||
|
- Sistema operativo:
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
name: Propuesta de mejora
|
||||||
|
about: Sugerir una funcionalidad o mejora
|
||||||
|
labels: enhancement
|
||||||
|
---
|
||||||
|
|
||||||
|
**Problema o necesidad**
|
||||||
|
¿Qué te falta? ¿Quién la usaría?
|
||||||
|
|
||||||
|
**Propuesta**
|
||||||
|
Cómo lo resolverías. Mantenlo simple: si la propuesta requiere muchas piezas
|
||||||
|
nuevas, conviene discutirla antes en Discussions.
|
||||||
|
|
||||||
|
**Alternativas que has considerado**
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
## Qué cambia
|
||||||
|
|
||||||
|
<!-- En una o dos frases. -->
|
||||||
|
|
||||||
|
## Por qué
|
||||||
|
|
||||||
|
<!-- Issue relacionado o motivación. -->
|
||||||
|
|
||||||
|
## Notas para el revisor
|
||||||
|
|
||||||
|
- [ ] La app arranca con `npm run dev`.
|
||||||
|
- [ ] No he añadido dependencias innecesarias.
|
||||||
|
- [ ] He seguido la estructura existente.
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
|
# artefactos de despliegue manual / datos locales
|
||||||
|
*.tgz
|
||||||
localesp.db
|
localesp.db
|
||||||
.claude/
|
|
||||||
.codex/
|
|
||||||
.pi/
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) [AÑO] [ORGANIZACIÓN]
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,2 +1,39 @@
|
|||||||
# LocalESP
|
# Business Map
|
||||||
Registro colaborativo de negocios españoles
|
|
||||||
|
Registro colaborativo de negocios, en un mapa abierto.
|
||||||
|
|
||||||
|
Prototipo open source mantenido por **[ORGANIZACIÓN]**. Cualquiera puede ver
|
||||||
|
los negocios registrados y proponer nuevos; las propuestas son revisadas
|
||||||
|
antes de aparecer públicamente.
|
||||||
|
|
||||||
|
## Demo local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Abre la URL que indique Vite (normalmente `http://localhost:5173`).
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- React 18 + Vite
|
||||||
|
- Leaflet + OpenStreetMap (mapa)
|
||||||
|
- CSS plano
|
||||||
|
|
||||||
|
Sin backend en el prototipo: los datos vienen de un mock en memoria. Ver
|
||||||
|
`docs/02-implementation-alternatives.md` para la decisión de backend.
|
||||||
|
|
||||||
|
## Documentación
|
||||||
|
|
||||||
|
- [`docs/01-use-cases.md`](docs/01-use-cases.md) – qué hace y qué no.
|
||||||
|
- [`docs/02-implementation-alternatives.md`](docs/02-implementation-alternatives.md) – decisiones técnicas.
|
||||||
|
- [`docs/03-decisions.md`](docs/03-decisions.md) – ADRs.
|
||||||
|
- [`docs/04-governance.md`](docs/04-governance.md) – cómo se gobierna el proyecto.
|
||||||
|
- [`docs/05-contributing.md`](docs/05-contributing.md) – cómo contribuir.
|
||||||
|
- [`docs/06-github-setup.md`](docs/06-github-setup.md) – configuración inicial del repo.
|
||||||
|
- [`docs/07-cicd.md`](docs/07-cicd.md) – CI/CD: despliegue por rama en `<rama>.localesp.es`.
|
||||||
|
|
||||||
|
## Licencia
|
||||||
|
|
||||||
|
MIT. Ver [LICENSE](LICENSE).
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Casos de uso
|
||||||
|
|
||||||
|
Este documento describe los casos de uso del prototipo. El objetivo es mantener
|
||||||
|
el alcance acotado para minimizar el coste de mantenimiento.
|
||||||
|
|
||||||
|
## Actores
|
||||||
|
|
||||||
|
- **Visitante**: cualquier persona que accede a la aplicación. No requiere
|
||||||
|
autenticación. Puede consultar el mapa y contribuir con nuevas entradas.
|
||||||
|
- **Revisor**: persona designada por la organización que aprueba o rechaza las
|
||||||
|
contribuciones. Su flujo está fuera del frontend del prototipo (ver
|
||||||
|
alternativas de implementación).
|
||||||
|
|
||||||
|
## Casos de uso del prototipo
|
||||||
|
|
||||||
|
### CU-01: Explorar negocios en el mapa
|
||||||
|
- El visitante abre la aplicación.
|
||||||
|
- Ve un mapa centrado en una ubicación por defecto con marcadores de los
|
||||||
|
negocios aprobados.
|
||||||
|
- Puede hacer zoom y desplazarse libremente.
|
||||||
|
|
||||||
|
### CU-02: Ver detalles de un negocio
|
||||||
|
- El visitante selecciona un marcador en el mapa.
|
||||||
|
- La barra lateral muestra los detalles del negocio: nombre, categoría,
|
||||||
|
dirección, descripción corta y enlace/contacto si existe.
|
||||||
|
- Al seleccionar otro marcador, la barra lateral se actualiza.
|
||||||
|
|
||||||
|
### CU-03: Contribuir con un nuevo negocio
|
||||||
|
- El visitante abre el formulario de contribución desde la interfaz principal.
|
||||||
|
- Rellena los campos mínimos: nombre, categoría, dirección, descripción y
|
||||||
|
contacto opcional. La ubicación se obtiene geocodificando la dirección o
|
||||||
|
haciendo clic en el mapa.
|
||||||
|
- Envía el formulario. La entrada queda en estado "pendiente de revisión" y no
|
||||||
|
aparece en el mapa hasta ser aprobada.
|
||||||
|
- El visitante recibe confirmación visual del envío.
|
||||||
|
|
||||||
|
## Fuera de alcance del prototipo
|
||||||
|
|
||||||
|
Decisiones tomadas para mantener el proyecto simple (ver
|
||||||
|
`02-implementation-alternatives.md` para el razonamiento):
|
||||||
|
|
||||||
|
- Autenticación de usuarios.
|
||||||
|
- Panel de moderación dentro de la aplicación. La revisión se hace en el
|
||||||
|
backend (por ejemplo, en el dashboard del proveedor de datos elegido).
|
||||||
|
- Edición o eliminación de negocios por el visitante.
|
||||||
|
- Búsqueda avanzada, filtros complejos o categorías jerárquicas.
|
||||||
|
- Comentarios, valoraciones, fotos múltiples o multimedia.
|
||||||
|
- Internacionalización. El prototipo usa un único idioma.
|
||||||
|
|
||||||
|
Estos casos pueden añadirse después si la comunidad lo pide, pero no forman
|
||||||
|
parte del prototipo.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Alternativas de implementación
|
||||||
|
|
||||||
|
Este documento registra las decisiones técnicas tomadas y por qué. El criterio
|
||||||
|
guía es **mínimo coste de mantenimiento** para una organización sin ánimo de
|
||||||
|
lucro, siguiendo KISS y evitando YAGNI.
|
||||||
|
|
||||||
|
## 1. Framework frontend
|
||||||
|
|
||||||
|
| Opción | Pros | Contras |
|
||||||
|
|---|---|---|
|
||||||
|
| **React + Vite** (elegido) | Ecosistema enorme, fácil encontrar contribuidores, build rápido. | Requiere build step. |
|
||||||
|
| Next.js | SSR, rutas, imágenes. | Demasiado para una SPA de una página; aumenta complejidad y coste de hosting. |
|
||||||
|
| Vanilla JS | Sin dependencias. | Más código manual, menos contribuidores familiarizados. |
|
||||||
|
|
||||||
|
**Decisión**: React + Vite. Es el stack más común y los contribuidores
|
||||||
|
potenciales lo conocen. Vite mantiene la configuración mínima.
|
||||||
|
|
||||||
|
## 2. Mapa
|
||||||
|
|
||||||
|
| Opción | Pros | Contras |
|
||||||
|
|---|---|---|
|
||||||
|
| **Leaflet + OpenStreetMap** (elegido) | Open source, sin claves API, sin coste, tiles gratuitos. | Menos pulido que Mapbox. |
|
||||||
|
| Mapbox GL | Más bonito, mejor rendimiento. | Requiere API key y tiene cuotas de pago. |
|
||||||
|
| Google Maps | Familiar. | Requiere facturación activada, cuotas estrictas, no encaja con espíritu open source. |
|
||||||
|
|
||||||
|
**Decisión**: Leaflet con tiles de OpenStreetMap. Cero coste, sin claves, y la
|
||||||
|
librería `react-leaflet` lo hace trivial de integrar.
|
||||||
|
|
||||||
|
## 3. Backend y almacenamiento de datos
|
||||||
|
|
||||||
|
| Opción | Pros | Contras |
|
||||||
|
|---|---|---|
|
||||||
|
| **Backend-as-a-Service** (Supabase o similar) elegido | Sin servidor que mantener, base de datos gestionada, panel de moderación incluido en su dashboard. Plan gratuito generoso. | Dependencia de un proveedor. |
|
||||||
|
| Backend propio (Node + Postgres) | Control total. | Hay que mantenerlo, pagarlo, securizarlo. Alto coste para una ONG. |
|
||||||
|
| Archivo JSON en el repositorio | Sin backend, máxima simplicidad. | Las contribuciones tendrían que llegar como Pull Requests, fricción alta para no-técnicos. |
|
||||||
|
|
||||||
|
**Decisión**: BaaS tipo Supabase. Para el prototipo, la capa de datos se
|
||||||
|
abstrae en `src/services/businessRepository.js`, así que cambiar de proveedor
|
||||||
|
después es localizado. El prototipo entregado usa un **mock en memoria** para
|
||||||
|
no acoplar a un proveedor concreto antes de que la organización lo decida.
|
||||||
|
|
||||||
|
## 4. Moderación
|
||||||
|
|
||||||
|
| Opción | Pros | Contras |
|
||||||
|
|---|---|---|
|
||||||
|
| **Aprobación desde el dashboard del BaaS** (elegido) | Cero código extra. El revisor cambia un campo `status` de `pending` a `approved`. | Requiere acceso al dashboard. |
|
||||||
|
| Panel de moderación dentro de la app | Más cómodo. | Requiere autenticación, roles, vistas extra: explosión de complejidad. |
|
||||||
|
|
||||||
|
**Decisión**: La moderación vive fuera del frontend público. El modelo de
|
||||||
|
datos incluye un campo `status` (`pending` / `approved` / `rejected`). El
|
||||||
|
frontend solo lee los `approved`.
|
||||||
|
|
||||||
|
## 5. Estilos
|
||||||
|
|
||||||
|
| Opción | Pros | Contras |
|
||||||
|
|---|---|---|
|
||||||
|
| **CSS plano con variables** (elegido) | Cero dependencias, fácil de leer, los contribuidores con HTML/CSS básico pueden colaborar. | Sin utilidades. |
|
||||||
|
| Tailwind | Productivo. | Cadena de build extra, curva de aprendizaje. |
|
||||||
|
| CSS-in-JS | Componentes encapsulados. | Runtime extra, peor accesibilidad para nuevos contribuidores. |
|
||||||
|
|
||||||
|
**Decisión**: CSS plano. La app es de una página; no se justifica más.
|
||||||
|
|
||||||
|
## 6. Gestión de estado
|
||||||
|
|
||||||
|
**Decisión**: `useState` y `useEffect` de React. No hay Redux, Zustand ni
|
||||||
|
Context global. La app es pequeña; añadir una librería de estado sería YAGNI.
|
||||||
|
|
||||||
|
## 7. Geocodificación de direcciones del formulario
|
||||||
|
|
||||||
|
**Decisión para el prototipo**: el usuario hace clic en el mapa para fijar la
|
||||||
|
ubicación, o introduce lat/lng. Geocodificación automática vía Nominatim
|
||||||
|
(OpenStreetMap) queda como mejora futura con bajo coste de añadir.
|
||||||
|
|
||||||
|
## 8. Tests
|
||||||
|
|
||||||
|
**Decisión**: el prototipo no incluye suite de tests para no inflarlo, pero la
|
||||||
|
estructura (servicios separados de componentes, funciones puras donde posible)
|
||||||
|
facilita añadirlos. Cuando se incorporen, **Vitest** es la elección natural por
|
||||||
|
integrarse con Vite sin configuración.
|
||||||
|
|
||||||
|
## 9. Despliegue
|
||||||
|
|
||||||
|
**Decisión recomendada**: **GitHub Pages** o **Cloudflare Pages** (gratuitos
|
||||||
|
para open source). Una SPA estática construida con `vite build` se despliega
|
||||||
|
sin coste y sin servidor. Ver `docs/06-github-setup.md`.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Registro de decisiones
|
||||||
|
|
||||||
|
Lista corta y cronológica de decisiones que merecen ser revisadas más
|
||||||
|
adelante. Formato ADR ligero.
|
||||||
|
|
||||||
|
## D-001: SPA de una sola página
|
||||||
|
**Contexto**: el uso principal es ver un mapa y unos detalles.
|
||||||
|
**Decisión**: una sola página, sin router. El formulario es un panel/modal en
|
||||||
|
la misma vista.
|
||||||
|
**Consecuencia**: si en el futuro hay vistas adicionales (estadísticas, perfil
|
||||||
|
del negocio en URL propia), habrá que introducir React Router. Reevaluar
|
||||||
|
cuando aparezca el segundo caso de uso navegable.
|
||||||
|
|
||||||
|
## D-002: Capa de servicios
|
||||||
|
**Decisión**: todo acceso a datos pasa por `src/services/businessRepository.js`.
|
||||||
|
**Razón**: cumplir Dependency Inversion (SOLID). Los componentes no saben si
|
||||||
|
los datos vienen de un mock, de Supabase o de un JSON.
|
||||||
|
**Consecuencia**: cambiar de proveedor es modificar un único archivo.
|
||||||
|
|
||||||
|
## D-003: Modelo de datos mínimo
|
||||||
|
Campos del negocio:
|
||||||
|
- `id` (string)
|
||||||
|
- `name` (string)
|
||||||
|
- `category` (string)
|
||||||
|
- `address` (string)
|
||||||
|
- `description` (string)
|
||||||
|
- `contact` (string, opcional)
|
||||||
|
- `lat` (number)
|
||||||
|
- `lng` (number)
|
||||||
|
- `status` (`pending` | `approved` | `rejected`)
|
||||||
|
- `createdAt` (ISO string)
|
||||||
|
|
||||||
|
Sin slugs, sin fotos, sin tags. Reevaluar tras feedback de la comunidad.
|
||||||
|
|
||||||
|
## D-004: Sin autenticación en el prototipo
|
||||||
|
**Razón**: el visitante no necesita cuenta. El moderador opera en el panel del
|
||||||
|
backend.
|
||||||
|
**Reevaluar si**: aparecen casos de uso que requieran identidad (favoritos,
|
||||||
|
edición por el dueño del negocio).
|
||||||
|
|
||||||
|
## D-005: Idioma
|
||||||
|
El prototipo está en español, sin i18n. Si la organización quiere expandirse,
|
||||||
|
añadir `react-i18next` cuando exista una segunda lengua real, no antes.
|
||||||
|
|
||||||
|
## D-006: Mock de datos en el prototipo
|
||||||
|
El repositorio entregado usa un mock en memoria con varios negocios de
|
||||||
|
ejemplo. La organización debe decidir el backend antes de pasar a producción.
|
||||||
|
La interfaz del repositorio (`getApproved`, `submit`) está pensada para
|
||||||
|
mapearse 1:1 a Supabase u otro BaaS.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Gobernanza
|
||||||
|
|
||||||
|
Este proyecto es mantenido por **[NOMBRE DE LA ORGANIZACIÓN]**, una entidad
|
||||||
|
sin ánimo de lucro. Estas pautas son deliberadamente breves; queremos invertir
|
||||||
|
el esfuerzo en código y comunidad, no en burocracia.
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
- **Mantenedores**: personas con permisos de merge en `main`. Son nombradas
|
||||||
|
por la organización. Responsables de revisar PRs, etiquetar issues y publicar
|
||||||
|
releases.
|
||||||
|
- **Contribuidores**: cualquiera que envíe un Pull Request, abra un issue o
|
||||||
|
participe en las discusiones.
|
||||||
|
- **Moderadores de contenido**: personas con acceso al panel del backend para
|
||||||
|
aprobar o rechazar los negocios enviados por los visitantes. No
|
||||||
|
necesariamente coinciden con los mantenedores del código.
|
||||||
|
|
||||||
|
## Toma de decisiones
|
||||||
|
|
||||||
|
- Decisiones pequeñas (bugs, mejoras menores): las toma quien revisa el PR.
|
||||||
|
- Decisiones de diseño o que afecten al modelo de datos: discusión pública en
|
||||||
|
un issue durante al menos 7 días. Cierra el debate un mantenedor buscando
|
||||||
|
consenso. Si no lo hay, decide la organización.
|
||||||
|
- Las decisiones relevantes se registran en `docs/03-decisions.md`.
|
||||||
|
|
||||||
|
## Código de conducta
|
||||||
|
|
||||||
|
Adoptamos el [Contributor Covenant](https://www.contributor-covenant.org/). En
|
||||||
|
resumen: trata a las personas con respeto. Las violaciones se reportan al
|
||||||
|
correo `[CORREO_CONTACTO]` y las gestiona la organización.
|
||||||
|
|
||||||
|
## Licencia
|
||||||
|
|
||||||
|
El proyecto se publica bajo **MIT**. Las contribuciones se entienden
|
||||||
|
licenciadas bajo los mismos términos.
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
Versionado semántico (semver). Los mantenedores publican versiones cuando
|
||||||
|
acumulan cambios suficientes. No hay calendario fijo.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Cómo contribuir
|
||||||
|
|
||||||
|
¡Gracias por querer ayudar! El proyecto se mantiene gracias a la comunidad.
|
||||||
|
|
||||||
|
## Antes de empezar
|
||||||
|
|
||||||
|
1. Mira los [issues abiertos](../../issues), sobre todo los marcados
|
||||||
|
`good first issue`.
|
||||||
|
2. Si tu cambio es grande, abre un issue antes de programar para discutirlo.
|
||||||
|
|
||||||
|
## Flujo
|
||||||
|
|
||||||
|
1. Haz fork del repositorio.
|
||||||
|
2. Crea una rama desde `main`: `git checkout -b mi-cambio`.
|
||||||
|
3. Escribe el código siguiendo las pautas de abajo.
|
||||||
|
4. Asegúrate de que la app arranca: `npm install && npm run dev`.
|
||||||
|
5. Abre un Pull Request hacia `main` describiendo el cambio.
|
||||||
|
|
||||||
|
## Pautas de código
|
||||||
|
|
||||||
|
- **Mantén la app simple.** Si dudas entre dos soluciones, elige la más corta
|
||||||
|
y obvia. Este proyecto evita la complejidad a propósito.
|
||||||
|
- **Sigue la estructura existente.** Componentes en `src/components`, acceso a
|
||||||
|
datos en `src/services`.
|
||||||
|
- **No añadas dependencias sin necesidad.** Cada nueva librería es coste de
|
||||||
|
mantenimiento.
|
||||||
|
- **SOLID y Clean Code aplicados con sentido común.** Nombres claros,
|
||||||
|
funciones cortas, una responsabilidad por archivo.
|
||||||
|
- **Estilo**: 2 espacios de indentación, comillas dobles en JSX y simples en JS.
|
||||||
|
|
||||||
|
## Tipos de contribución
|
||||||
|
|
||||||
|
- **Código**: bugs, mejoras, nuevas funciones acordadas en issues.
|
||||||
|
- **Documentación**: cualquier archivo de `docs/` o el README.
|
||||||
|
- **Diseño**: propuestas en issues con mockups.
|
||||||
|
- **Reportar negocios**: usa la propia aplicación; no es necesario un PR.
|
||||||
|
|
||||||
|
## Revisión
|
||||||
|
|
||||||
|
Un mantenedor revisará tu PR. Puede pedir cambios. Una vez aprobado, hará
|
||||||
|
merge. Sé paciente: somos voluntarios.
|
||||||
|
|
||||||
|
## Reconocimiento
|
||||||
|
|
||||||
|
Todas las personas que han contribuido aparecen automáticamente en la pestaña
|
||||||
|
de Contributors del repositorio.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Configuración del repositorio en GitHub
|
||||||
|
|
||||||
|
Pasos para dejar listo el repositorio. Pensado para que lo hagas una sola vez.
|
||||||
|
|
||||||
|
## 1. Crear el repositorio
|
||||||
|
|
||||||
|
1. En GitHub, crea un repositorio en la cuenta de la organización.
|
||||||
|
Recomendado: nombre corto, kebab-case, por ejemplo `business-map`.
|
||||||
|
2. Marca **Public**.
|
||||||
|
3. **No** inicialices con README, .gitignore ni licencia (ya vienen en este
|
||||||
|
prototipo).
|
||||||
|
4. Sube el código:
|
||||||
|
```bash
|
||||||
|
git init
|
||||||
|
git add .
|
||||||
|
git commit -m "Prototipo inicial"
|
||||||
|
git branch -M main
|
||||||
|
git remote add origin git@github.com:ORG/business-map.git
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Ajustes básicos (Settings → General)
|
||||||
|
|
||||||
|
- **Features**: deja activos Issues y Discussions. Desactiva Wikis y Projects
|
||||||
|
hasta que se usen.
|
||||||
|
- **Pull Requests**: marca "Allow squash merging" como única opción y "Always
|
||||||
|
suggest updating pull request branches".
|
||||||
|
- **Archive**: deja desactivado.
|
||||||
|
|
||||||
|
## 3. Protección de la rama `main` (Settings → Branches)
|
||||||
|
|
||||||
|
Añade una regla para `main`:
|
||||||
|
- Require a pull request before merging.
|
||||||
|
- Require approvals: 1.
|
||||||
|
- Dismiss stale approvals when new commits are pushed.
|
||||||
|
- Do not allow bypassing the above settings.
|
||||||
|
|
||||||
|
## 4. Plantillas
|
||||||
|
|
||||||
|
Las plantillas ya están en `.github/` del prototipo:
|
||||||
|
- `ISSUE_TEMPLATE/bug.md`
|
||||||
|
- `ISSUE_TEMPLATE/feature.md`
|
||||||
|
- `PULL_REQUEST_TEMPLATE.md`
|
||||||
|
|
||||||
|
## 5. Documentos visibles en GitHub
|
||||||
|
|
||||||
|
Para que GitHub los muestre como insignias del repositorio:
|
||||||
|
- `README.md` (raíz).
|
||||||
|
- `LICENSE` (raíz).
|
||||||
|
- `docs/05-contributing.md`: súbelo además como `CONTRIBUTING.md` en la raíz o
|
||||||
|
enlázalo desde el README.
|
||||||
|
- `CODE_OF_CONDUCT.md` recomendado en la raíz (Contributor Covenant oficial).
|
||||||
|
|
||||||
|
## 6. Despliegue gratuito
|
||||||
|
|
||||||
|
Opción recomendada: **Cloudflare Pages** o **GitHub Pages**.
|
||||||
|
|
||||||
|
Para GitHub Pages con Vite:
|
||||||
|
1. Settings → Pages → Build and deployment → Source: **GitHub Actions**.
|
||||||
|
2. Añade un workflow en `.github/workflows/deploy.yml` (no incluido en el
|
||||||
|
prototipo para mantenerlo mínimo; añadirlo cuando se decida la URL final).
|
||||||
|
3. Ajusta `base` en `vite.config.js` al path del repo.
|
||||||
|
|
||||||
|
## 7. Etiquetas iniciales sugeridas
|
||||||
|
|
||||||
|
Crea en Issues → Labels:
|
||||||
|
- `good first issue` (verde)
|
||||||
|
- `bug` (rojo)
|
||||||
|
- `enhancement` (azul)
|
||||||
|
- `documentation` (gris)
|
||||||
|
- `discussion` (morado)
|
||||||
|
|
||||||
|
## 8. Discussions
|
||||||
|
|
||||||
|
Activa Discussions con las categorías: Announcements, Ideas, Q&A. Sirven como
|
||||||
|
foro de baja fricción para la comunidad.
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# CI/CD — despliegue por rama
|
||||||
|
|
||||||
|
Cada rama se despliega automáticamente en `https://<rama>.localesp.es`.
|
||||||
|
|
||||||
|
## Cómo funciona
|
||||||
|
|
||||||
|
```
|
||||||
|
push a rama X
|
||||||
|
│ Gitea Actions (workflow: .gitea/workflows/deploy.yml)
|
||||||
|
▼
|
||||||
|
runner self-hosted (host executor, en el propio VPS)
|
||||||
|
│ npm ci → vite build → scripts/deploy.sh <rama>
|
||||||
|
▼
|
||||||
|
/opt/localesp/<slug>/ código + dist/ + node_modules (prod)
|
||||||
|
systemd localesp-<slug>.service node server/index.js (PORT=80xx)
|
||||||
|
nginx <slug>.localesp.es proxy_pass → localhost:80xx
|
||||||
|
certbot *.localesp.es Let's Encrypt (si el DNS ya apunta)
|
||||||
|
```
|
||||||
|
|
||||||
|
El **slug** se deriva del nombre de rama (`Feature/Foo` → `feature-foo`),
|
||||||
|
válido como hostname DNS. Un entorno = un subdirectorio bajo `/opt/localesp/`,
|
||||||
|
un servicio, un vhost y un puerto. La base de datos `localesp.db` de cada
|
||||||
|
entorno **se conserva** entre despliegues (no se sobreescribe).
|
||||||
|
|
||||||
|
Puertos: los históricos son `8080` (prototipo-yb01) y `8081` (master); los
|
||||||
|
nuevos se asignan desde `8082` hacia arriba. Si la rama ya tiene su
|
||||||
|
`localesp-<slug>.service`, se reutiliza su puerto (adopta el entorno existente).
|
||||||
|
|
||||||
|
## Precondición manual: el registro DNS
|
||||||
|
|
||||||
|
El registro A de `<rama>.localesp.es` **se crea a mano** (no está automatizado).
|
||||||
|
|
||||||
|
1. En el proveedor DNS de `localesp.es`, añade un registro A:
|
||||||
|
`<rama>.localesp.es A <IP-pública-del-VPS>`
|
||||||
|
2. Espera a que propague (`dig +short <rama>.localesp.es`).
|
||||||
|
3. Lanza el workflow (push a la rama, o *Actions → Run workflow* en Gitea).
|
||||||
|
|
||||||
|
Mientras el DNS no apunte al VPS, `deploy.sh` despliega la app igualmente y la
|
||||||
|
sirve por **HTTP**; emite el cert Let's Encrypt en cuanto detecta que el DNS ya
|
||||||
|
resuelve. No hay que tocar nada más.
|
||||||
|
|
||||||
|
## Añadir una rama nueva
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b mi-feature
|
||||||
|
# ... cambios ...
|
||||||
|
git push -u origin mi-feature
|
||||||
|
```
|
||||||
|
|
||||||
|
El workflow corre solo. Si quieres URL pública: crea el registro A (arriba).
|
||||||
|
Si no, el entorno existe pero no es alcanzable por hostname (útil para tests internos
|
||||||
|
si añades el host a `/etc/hosts`).
|
||||||
|
|
||||||
|
## Borrar una rama
|
||||||
|
|
||||||
|
Al borrar la rama en Gitea, el job `teardown` limpia el entorno (unit systemd,
|
||||||
|
vhost, cert y `/opt/localesp-<slug>`). También manual:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh localesp.jumpingcrab.com 'bash /opt/localesp-master/scripts/teardown.sh mi-feature'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entornos existentes (ya migrados al layout unificado)
|
||||||
|
|
||||||
|
Los entornos legacy ya están reubicados bajo `/opt/localesp/<rama>` con sus
|
||||||
|
systemd units renombradas al esquema `localesp-<slug>.service`, de modo que el
|
||||||
|
CI los **adopta** sin cambios (reutiliza puerto y vhost):
|
||||||
|
|
||||||
|
| Rama | Dir | Unit | Puerto | Hosts |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `master` | `/opt/localesp/master` | `localesp-master.service` | 8081 | `localesp.es`, `master.localesp.es` |
|
||||||
|
| `prototipo-yb01` | `/opt/localesp/prototipo-yb01` | `localesp-prototipo-yb01.service` | 8080 | `prototipo-yb01.localesp.es` |
|
||||||
|
|
||||||
|
El antiguo `localesp.service` (sin slug) se retiró; ahora todas las units
|
||||||
|
siguen el patrón `localesp-<slug>.service`. La db de cada entorno se preservó.
|
||||||
|
|
||||||
|
## Seguridad
|
||||||
|
|
||||||
|
El runner (`gitea-runner`) se ejecuta como **root en el VPS de producción** y en
|
||||||
|
modo **host** (sin contenedor): cualquier workflow que se ejecute corresponde a
|
||||||
|
código del repo corriendo con privilegios de root en la caja. Esto es aceptable
|
||||||
|
mientras el repo sea propio / de baja colaboración. Si entran colaboradores
|
||||||
|
externos, conviene migrar a:
|
||||||
|
|
||||||
|
- runner en contenedor + despliegue por SSH (clave como secret de Gitea), o
|
||||||
|
- *ephemeral runners* (`gitea-runner register --ephemeral`).
|
||||||
|
|
||||||
|
## Evolución: estrategia B (wildcard)
|
||||||
|
|
||||||
|
Para eliminar la precondición manual del DNS y el cert por rama, más adelante:
|
||||||
|
|
||||||
|
1. Registro **wildcard `*.localesp.es`** (DNS).
|
||||||
|
2. **Wildcard cert** `*.localesp.es` vía challenge DNS-01 (una sola vez).
|
||||||
|
3. Un único vhost `*.localesp.es` que enruta por `Host` con un `map` rama→puerto.
|
||||||
|
|
||||||
|
`deploy.sh` apenas cambia: se caen los pasos de creación de vhost y certbot.
|
||||||
|
Nada de lo hecho aquí se pierde.
|
||||||
|
|
||||||
|
## Infra del runner (referencia)
|
||||||
|
|
||||||
|
Instalado en el VPS (`localesp.jumpingcrab.com`):
|
||||||
|
|
||||||
|
- Binario: `/usr/local/bin/gitea-runner` (v2.2.0), host executor.
|
||||||
|
**Cadena de suministro verificada**: el binario coincide (sha256 `d6b3f5bb…`)
|
||||||
|
con el descomprimido del release oficial `gitea-runner-2.2.0-linux-amd64.xz`, cuya suma
|
||||||
|
`13357e0d…` está publicada en el release. Gitea no publica firma GPG del runner; la suma
|
||||||
|
es la garantía máxima disponible. No hay paquete `dnf` oficial (solo binario o Docker image).
|
||||||
|
- Config: `/etc/gitea-runner/config.yaml` — etiquetas `self-hosted:host`, `linux:host`.
|
||||||
|
- Registro: `/var/lib/gitea-runner/.runner` (instance-level, `https://git.localesp.es`).
|
||||||
|
- Servicio: `gitea-runner.service` (systemd, `User=root`, `WorkingDirectory=/var/lib/gitea-runner`).
|
||||||
|
|
||||||
|
Comprobar estado: `systemctl status gitea-runner` · `journalctl -u gitea-runner -f`.
|
||||||
|
Ver runners en Gitea: *Site administration → Actions → Runners*.
|
||||||
@@ -3,49 +3,12 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Locales Españoles</title>
|
<title>Business Map</title>
|
||||||
<meta name="description" content="Directorio colaborativo de negocios por toda España" />
|
<link
|
||||||
<!-- No indexar el admin -->
|
rel="stylesheet"
|
||||||
<meta name="robots" content="index, follow" />
|
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||||
<!-- PWA -->
|
crossorigin=""
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
/>
|
||||||
<meta name="theme-color" content="#8B0000" />
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
||||||
<meta name="apple-mobile-web-app-title" content="LocalESP" />
|
|
||||||
<!-- Tabler Icons -->
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3.11.0/dist/tabler-icons.min.css" />
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
||||||
--color-background-primary: #ffffff;
|
|
||||||
--color-background-secondary: #f5f5f7;
|
|
||||||
--color-text-primary: #1d1d1f;
|
|
||||||
--color-text-secondary: #6e6e73;
|
|
||||||
--color-text-tertiary: #aeaeb2;
|
|
||||||
--color-text-info: #1a73e8;
|
|
||||||
--color-border-secondary: #d2d2d7;
|
|
||||||
--color-border-tertiary: #e5e5ea;
|
|
||||||
--border-radius-md: 8px;
|
|
||||||
--border-radius-lg: 12px;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--color-background-primary: #1c1c1e;
|
|
||||||
--color-background-secondary: #2c2c2e;
|
|
||||||
--color-text-primary: #f5f5f7;
|
|
||||||
--color-text-secondary: #aeaeb2;
|
|
||||||
--color-text-tertiary: #636366;
|
|
||||||
--color-border-secondary: #3a3a3c;
|
|
||||||
--color-border-tertiary: #2c2c2e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body { margin: 0; background: var(--color-background-secondary); color: var(--color-text-primary); font-family: var(--font-sans); min-height: 100vh; }
|
|
||||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
[build]
|
|
||||||
command = "npm run build"
|
|
||||||
publish = "dist"
|
|
||||||
|
|
||||||
# SPA: todas las rutas van al index.html (React gestiona /admin; el resto es
|
|
||||||
# la PWA principal, instalable en móvil)
|
|
||||||
[[redirects]]
|
|
||||||
from = "/*"
|
|
||||||
to = "/index.html"
|
|
||||||
status = 200
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
schema: spec-driven
|
|
||||||
created: 2026-08-15
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# 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)
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
## 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
|
|
||||||
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# Botón flotante para proponer locales
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Punto de entrada único y siempre visible para proponer un nuevo local al directorio: un botón de acción flotante (FAB) fijo en la esquina inferior derecha, usable en PC y móvil, con tooltip explicativo y apertura de la pasarela de propuesta al pulsarlo.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Requirement: Botón flotante fijo en la esquina inferior derecha
|
|
||||||
El sistema SHALL mostrar un botón de acción flotante (FAB) fijado en la esquina inferior derecha de la ventana, siempre visible sobre el contenido, tanto en PC como en móvil.
|
|
||||||
|
|
||||||
#### Scenario: Visible en escritorio
|
|
||||||
- **WHEN** la página se carga en una pantalla de escritorio
|
|
||||||
- **THEN** el botón flotante aparece fijado en la esquina inferior derecha, por encima del resto del contenido
|
|
||||||
|
|
||||||
#### Scenario: Visible en móvil
|
|
||||||
- **WHEN** la página se carga o se hace scroll en un dispositivo móvil
|
|
||||||
- **THEN** el botón flotante permanece fijo y accesible en la esquina inferior derecha, sin quedar oculto por el contenido ni interferir con la barra inferior existente
|
|
||||||
|
|
||||||
#### Scenario: Reemplazo del botón de cabecera
|
|
||||||
- **WHEN** la página se carga
|
|
||||||
- **THEN** ya no existe el botón "Proponer local" en la cabecera y el único punto de entrada para proponer un local es el botón flotante
|
|
||||||
|
|
||||||
### Requirement: Tooltip explicativo al pasar el ratón
|
|
||||||
El botón flotante SHALL mostrar un tooltip al pasar el ratón por encima (hover) y al recibir foco por teclado, explicando que sirve para añadir un nuevo local.
|
|
||||||
|
|
||||||
#### Scenario: Tooltip en hover
|
|
||||||
- **WHEN** el usuario pasa el ratón por encima del botón flotante
|
|
||||||
- **THEN** aparece un tooltip con un texto explicativo del tipo "Añade un nuevo local al directorio"
|
|
||||||
|
|
||||||
#### Scenario: Tooltip accesible sin ratón
|
|
||||||
- **WHEN** el botón flotante recibe el foco por teclado o se consulta con un lector de pantalla
|
|
||||||
- **THEN** el botón expone su propósito mediante `aria-label`, de modo que la función sea accesible sin depender del hover
|
|
||||||
|
|
||||||
### Requirement: El botón abre la pasarela en un modal
|
|
||||||
El botón flotante SHALL abrir, al pulsarlo, la pasarela de propuesta de local en un modal por encima de la página.
|
|
||||||
|
|
||||||
#### Scenario: Apertura de la pasarela
|
|
||||||
- **WHEN** el usuario pulsa el botón flotante
|
|
||||||
- **THEN** se abre un modal con la pasarela de propuesta, comenzando por la primera pantalla (Ubicación)
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
# Pasarela de propuesta de local
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Experiencia guiada (wizard) en un modal para proponer un local con un solo campo por pantalla —empezando por la ubicación con autodetección del proveedor e inferencia de nombre/provincia—, sin campos de comentario ni puntuación, conservando las validaciones existentes (palabras prohibidas, duplicados, privacidad obligatoria).
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Requirement: Pasarela paso a paso en un modal
|
|
||||||
El sistema SHALL presentar la propuesta de un local como una pasarela (wizard) dentro de un modal, con un solo campo por pantalla, navegación atrás/siguiente e indicador de progreso.
|
|
||||||
|
|
||||||
#### Scenario: Una pantalla por campo
|
|
||||||
- **WHEN** la pasarela está abierta
|
|
||||||
- **THEN** cada pantalla muestra únicamente el campo correspondiente al paso actual, con botones para avanzar y retroceder
|
|
||||||
|
|
||||||
#### Scenario: Navegación hacia atrás
|
|
||||||
- **WHEN** el usuario pulsa "Atrás" en una pantalla intermedia
|
|
||||||
- **THEN** vuelve a la pantalla anterior conservando los datos ya introducidos
|
|
||||||
|
|
||||||
#### Scenario: Avance bloqueado sin dato válido
|
|
||||||
- **WHEN** el campo de la pantalla actual está vacío o es inválido
|
|
||||||
- **THEN** el botón "Siguiente" está deshabilitado y la pantalla no avanza
|
|
||||||
|
|
||||||
#### Scenario: Cierre del modal
|
|
||||||
- **WHEN** el usuario cierra el modal (botón ✕, botón Cancelar o tecla Escape)
|
|
||||||
- **THEN** la pasarela se cierra y se descarta el borrador en curso
|
|
||||||
|
|
||||||
### Requirement: Orden de las pantallas empezando por Ubicación
|
|
||||||
La pasarela SHALL presentar las pantallas en este orden: 1) Ubicación, 2) Nombre, 3) Provincia, 4) Categoría, 5) Tipo específico (opcional), 6) Revisión y envío.
|
|
||||||
|
|
||||||
#### Scenario: Primera pantalla
|
|
||||||
- **WHEN** se abre la pasarela
|
|
||||||
- **THEN** la primera pantalla que se muestra es la de Ubicación
|
|
||||||
|
|
||||||
#### Scenario: Tipo específico omitible
|
|
||||||
- **WHEN** el usuario está en la pantalla de Tipo específico
|
|
||||||
- **THEN** puede continuar sin seleccionar ninguna subcategoría
|
|
||||||
|
|
||||||
### Requirement: Inferencia de Nombre y Provincia desde la Ubicación
|
|
||||||
El sistema SHALL pre-rellenar los campos Nombre y Provincia a partir de la ubicación introducida en la primera pantalla (geocodificación inversa), permitiendo al usuario corregirlos.
|
|
||||||
|
|
||||||
#### Scenario: Nombre y provincia pre-rellenados
|
|
||||||
- **WHEN** el usuario introduce una ubicación válida en la primera pantalla y esta se resuelve con éxito
|
|
||||||
- **THEN** al llegar a las pantallas de Nombre y Provincia ambos campos aparecen pre-rellenados con los valores inferidos
|
|
||||||
|
|
||||||
#### Scenario: Valores corregibles
|
|
||||||
- **WHEN** los valores inferidos no son correctos
|
|
||||||
- **THEN** el usuario puede editarlos libremente antes de enviar
|
|
||||||
|
|
||||||
#### Scenario: Degradación sin inferencia
|
|
||||||
- **WHEN** la inferencia falla o devuelve campos vacíos
|
|
||||||
- **THEN** las pantallas de Nombre y Provincia aparecen vacías y el usuario los rellena manualmente, sin bloquear la pasarela
|
|
||||||
|
|
||||||
### Requirement: Sin campo de comentario ni puntuación
|
|
||||||
La pasarela SHALL NOT incluir el campo de comentario/descripción ni el campo de puntuación; la propuesta se envía sin esos datos.
|
|
||||||
|
|
||||||
#### Scenario: Envío sin comentario ni puntuación
|
|
||||||
- **WHEN** el usuario completa la pasarela y envía
|
|
||||||
- **THEN** la propuesta se registra con nombre, provincia, categoría, subcategoría, dirección/enlace y coordenadas, sin descripción y sin puntuación
|
|
||||||
|
|
||||||
### Requirement: Revisión y aceptación de privacidad antes de enviar
|
|
||||||
La última pantalla de la pasarela SHALL mostrar un resumen de los datos introducidos y el checkbox de aceptación de la política de privacidad, siendo este último obligatorio para enviar.
|
|
||||||
|
|
||||||
#### Scenario: Envío bloqueado sin aceptar privacidad
|
|
||||||
- **WHEN** el usuario llega a la pantalla final sin marcar la casilla de privacidad
|
|
||||||
- **THEN** el botón de envío está deshabilitado hasta que la marque
|
|
||||||
|
|
||||||
#### Scenario: Resumen antes de enviar
|
|
||||||
- **WHEN** el usuario llega a la pantalla final
|
|
||||||
- **THEN** ve un resumen legible de todos los datos introducidos con posibilidad de volver atrás a corregirlos
|
|
||||||
|
|
||||||
### Requirement: Validaciones existentes integradas en la pasarela
|
|
||||||
La pasarela SHALL conservar las validaciones actuales del formulario: filtro de palabras prohibidas y comprobación de duplicados, mostrando los avisos dentro del modal.
|
|
||||||
|
|
||||||
#### Scenario: Palabra prohibida detectada
|
|
||||||
- **WHEN** el texto introducido contiene una palabra no permitida
|
|
||||||
- **THEN** se muestra un aviso dentro del modal y la propuesta no se envía
|
|
||||||
|
|
||||||
#### Scenario: Posible duplicado detectado
|
|
||||||
- **WHEN** al enviar se detecta un local igual o muy cercano
|
|
||||||
- **THEN** se muestra el aviso de duplicado dentro del modal con las coincidencias y las opciones de enviar de todos modos o revisar los datos
|
|
||||||
|
|
||||||
#### Scenario: Envío correcto
|
|
||||||
- **WHEN** la propuesta se envía con éxito
|
|
||||||
- **THEN** el modal se cierra y aparece la confirmación de propuesta enviada en la página principal
|
|
||||||
|
|
||||||
### Requirement: Modal responsive accesible
|
|
||||||
El modal de la pasarela SHALL ser usable en PC (diálogo centrado) y móvil (ocupando prácticamente toda la pantalla), con foco contenido y cierre mediante Escape.
|
|
||||||
|
|
||||||
#### Scenario: Uso en móvil
|
|
||||||
- **WHEN** la pasarela se abre en una pantalla estrecha
|
|
||||||
- **THEN** el modal ocupa el ancho completo y es operativo con teclado y scroll táctil
|
|
||||||
|
|
||||||
#### Scenario: Foco contenido en el modal
|
|
||||||
- **WHEN** el modal está abierto
|
|
||||||
- **THEN** el foco de teclado permanece dentro del modal y la tecla Escape lo cierra
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# Proveedores de ubicación (integraciones extensible)
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Sistema extensible de integraciones con backends de ubicación (Google Maps, Waze, OpenStreetMap, coordenadas sueltas y geocodificación Nominatim) bajo una interfaz común (`id`/`detecta`/`extrae`), capaz de autodetectar el proveedor de una entrada libre, extraer coordenadas e inferir nombre y provincia, y ampliable a otras extracciones futuras sin modificar los proveedores existentes.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Requirement: Interfaz común para proveedores de ubicación
|
|
||||||
Cada integración con un backend de ubicación SHALL implementarse como una clase que cumpla una misma interfaz: identificarse (`id`), indicar si reconoce una entrada (`detecta(entrada)`) y extraer la información disponible (`extrae(entrada)`).
|
|
||||||
|
|
||||||
#### Scenario: Contrato uniforme
|
|
||||||
- **WHEN** se consulta cualquier proveedor registrado
|
|
||||||
- **THEN** expone los métodos `detecta(entrada)` y `extrae(entrada)` con la misma firma y semántica, de modo que el consumidor no necesita conocer la clase concreta
|
|
||||||
|
|
||||||
### Requirement: Resultado de extracción extensible
|
|
||||||
El método `extrae(entrada)` SHALL devolver un objeto de resultado extensible que incluya como mínimo las coordenadas (`lat`, `lng`) y el identificador del proveedor que las extrajo, pudiendo incorporar campos adicionales en el futuro sin romper a los consumidores.
|
|
||||||
|
|
||||||
#### Scenario: Extracción mínima
|
|
||||||
- **WHEN** un proveedor extrae una ubicación válida
|
|
||||||
- **THEN** el resultado contiene `lat`, `lng` y el identificador del proveedor
|
|
||||||
|
|
||||||
#### Scenario: Campos adicionales sin ruptura
|
|
||||||
- **WHEN** en el futuro un proveedor añade nuevos campos al resultado (horarios, teléfono, fotos…)
|
|
||||||
- **THEN** los consumidores existentes siguen funcionando sin modificación, ignorando los campos que no conocen
|
|
||||||
|
|
||||||
### Requirement: Autodetección del proveedor a partir de la entrada
|
|
||||||
El sistema SHALL autodetectar automáticamente el proveedor adecuado para una entrada dada, recorriendo los proveedores registrados y usando el primero que la reconozca.
|
|
||||||
|
|
||||||
#### Scenario: Enlace de Google Maps
|
|
||||||
- **WHEN** el usuario pega un enlace de Google Maps (formatos `@lat,lng`, `!3d…!4d…` o `?q=lat,lng`)
|
|
||||||
- **THEN** el sistema lo resuelve con el proveedor de Google Maps y obtiene las coordenadas
|
|
||||||
|
|
||||||
#### Scenario: Enlace de Waze
|
|
||||||
- **WHEN** el usuario pega un enlace de Waze (`waze.com/ul`, parámetro `ll=lat,lng` o búsqueda `q=`)
|
|
||||||
- **THEN** el sistema lo resuelve con el proveedor de Waze y obtiene las coordenadas
|
|
||||||
|
|
||||||
#### Scenario: Enlace de OpenStreetMap
|
|
||||||
- **WHEN** el usuario pega un enlace de openstreetmap.org (parámetros `mlat`/`mlon` o fragmento `#map=zoom/lat/lng`)
|
|
||||||
- **THEN** el sistema lo resuelve con el proveedor de OpenStreetMap y obtiene las coordenadas
|
|
||||||
|
|
||||||
#### Scenario: Coordenadas geográficas sueltas
|
|
||||||
- **WHEN** el usuario escribe coordenadas directamente (por ejemplo `41.3851, 2.1734`)
|
|
||||||
- **THEN** el sistema lo resuelve con el proveedor de coordenadas y obtiene lat/lng
|
|
||||||
|
|
||||||
#### Scenario: Texto de dirección como fallback
|
|
||||||
- **WHEN** la entrada no coincide con ningún enlace soportado y parece una dirección en texto
|
|
||||||
- **THEN** el sistema la resuelve con el proveedor de geocodificación (Nominatim) realizando una búsqueda por texto
|
|
||||||
|
|
||||||
#### Scenario: Entrada no reconocida
|
|
||||||
- **WHEN** la entrada no es reconocida por ningún proveedor ni resuelta como dirección
|
|
||||||
- **THEN** el sistema informa de que no se pudo interpretar la ubicación y no avanza de pantalla
|
|
||||||
|
|
||||||
### Requirement: Inferencia de nombre y provincia
|
|
||||||
Los proveedores SHALL extraer, cuando estén disponibles, los campos inferibles `nombre` y `provincia` a partir de la entrada resuelta (geocodificación inversa cuando solo hay coordenadas), normalizando la provincia al listado canónico de provincias de España.
|
|
||||||
|
|
||||||
#### Scenario: Inferencia desde coordenadas
|
|
||||||
- **WHEN** la entrada se resuelve a coordenadas sin más contexto
|
|
||||||
- **THEN** una geocodificación inversa obtiene un nombre descriptivo y la provincia canónica (coincidencia insensible a acentos y mayúsculas con el listado de PROVINCIAS)
|
|
||||||
|
|
||||||
#### Scenario: Provincia fuera del listado
|
|
||||||
- **WHEN** la provincia inferida no coincide con ninguna provincia del listado canónico
|
|
||||||
- **THEN** el campo provincia queda vacío para que el usuario lo seleccione manualmente
|
|
||||||
|
|
||||||
### Requirement: Extensibilidad sin modificar proveedores existentes
|
|
||||||
Añadir un proveedor nuevo SHALL consistir en crear una nueva clase que implemente la interfaz y registrarla en el resolver, sin modificar las clases existentes ni la lógica de la pasarela (principio abierto/cerrado).
|
|
||||||
|
|
||||||
#### Scenario: Registro de un proveedor nuevo
|
|
||||||
- **WHEN** se añade una clase `MiProveedor` con `detecta`/`extrae` y se registra en la lista de proveedores
|
|
||||||
- **THEN** el campo universal empieza a aceptar las entradas que ese proveedor reconoce sin ningún otro cambio de código
|
|
||||||
|
|
||||||
### Requirement: Errores de red degradados
|
|
||||||
Los proveedores SHALL tratar sus errores de red o de servicio devolviendo un fallo controlado, sin romper la pasarela ni propagar excepciones al consumidor.
|
|
||||||
|
|
||||||
#### Scenario: Fallo del servicio de geocodificación
|
|
||||||
- **WHEN** Nominatim no responde o devuelve un error
|
|
||||||
- **THEN** la pantalla de Ubicación muestra un aviso de error y el usuario puede reintentar o continuar sin inferencia
|
|
||||||
@@ -1,32 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "locales-espanoles",
|
"name": "business-map",
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"server": "node server/index.js",
|
"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",
|
"build": "vite build",
|
||||||
"test:watch": "vitest",
|
"preview": "vite preview"
|
||||||
"test:integration": "vitest run --config vitest.config.integration.mjs"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.0.0",
|
"better-sqlite3": "^12.10.0",
|
||||||
"express": "^5.0.0",
|
"express": "^5.2.1",
|
||||||
"react": "^18.2.0",
|
"leaflet": "^1.9.4",
|
||||||
"react-dom": "^18.2.0"
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-leaflet": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"concurrently": "^10.0.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"vite": "^5.4.0"
|
||||||
"concurrently": "^8.0.0",
|
|
||||||
"jsdom": "^30.0.1",
|
|
||||||
"supertest": "^7.2.2",
|
|
||||||
"vite": "^5.2.0",
|
|
||||||
"vitest": "^4.1.10"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 50 KiB |
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "LocalESP · Locales Españoles",
|
|
||||||
"short_name": "LocalESP",
|
|
||||||
"description": "Directorio colaborativo de negocios locales por toda España",
|
|
||||||
"start_url": "/",
|
|
||||||
"scope": "/",
|
|
||||||
"display": "standalone",
|
|
||||||
"background_color": "#ffffff",
|
|
||||||
"theme_color": "#8B0000",
|
|
||||||
"lang": "es",
|
|
||||||
"icons": [
|
|
||||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
|
||||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
|
||||||
{ "src": "/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
const CACHE_NAME = "localesp-shell-v2";
|
|
||||||
const SHELL_ASSETS = [
|
|
||||||
"/",
|
|
||||||
"/manifest.webmanifest",
|
|
||||||
"/icon-192.png",
|
|
||||||
"/icon-512.png",
|
|
||||||
"/icon-512-maskable.png",
|
|
||||||
"/apple-touch-icon.png",
|
|
||||||
"/favicon-32.png",
|
|
||||||
"/logo-localesp.png",
|
|
||||||
"/logo-localesp@2x.png",
|
|
||||||
];
|
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_ASSETS))
|
|
||||||
);
|
|
||||||
self.skipWaiting();
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener("activate", (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
caches.keys().then((keys) =>
|
|
||||||
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
self.clients.claim();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Network-first para la API (los datos deben estar frescos); cache-first
|
|
||||||
// para el resto del shell estático, para que la app abra offline.
|
|
||||||
self.addEventListener("fetch", (event) => {
|
|
||||||
const url = new URL(event.request.url);
|
|
||||||
|
|
||||||
if (event.request.method !== "GET") return;
|
|
||||||
|
|
||||||
if (url.pathname.startsWith("/api/")) {
|
|
||||||
event.respondWith(
|
|
||||||
fetch(event.request).catch(() => caches.match(event.request))
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.respondWith(
|
|
||||||
caches.match(event.request).then((cached) => {
|
|
||||||
const network = fetch(event.request)
|
|
||||||
.then((response) => {
|
|
||||||
const clone = response.clone();
|
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
|
||||||
return response;
|
|
||||||
})
|
|
||||||
.catch(() => cached);
|
|
||||||
return cached || network;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -3,87 +3,36 @@ import { fileURLToPath } from "url";
|
|||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
// Ruta de la BD sobreescribible para pruebas (admite ":memory:"); en
|
const db = new Database(join(__dirname, "..", "localesp.db"));
|
||||||
// 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(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS locales (
|
CREATE TABLE IF NOT EXISTS businesses (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
nombre TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
descripcion TEXT,
|
category TEXT NOT NULL,
|
||||||
provincia TEXT,
|
address TEXT NOT NULL,
|
||||||
subcategoria TEXT,
|
description TEXT NOT NULL,
|
||||||
direccion TEXT,
|
contact TEXT NOT NULL DEFAULT '',
|
||||||
enlaceGoogleMaps TEXT,
|
lat REAL NOT NULL,
|
||||||
puntuacion INTEGER DEFAULT 0,
|
lng REAL NOT NULL,
|
||||||
categoria TEXT,
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
fecha TEXT DEFAULT CURRENT_TIMESTAMP,
|
createdAt TEXT NOT NULL
|
||||||
lat REAL,
|
)
|
||||||
lng REAL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pendientes (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
nombre TEXT NOT NULL,
|
|
||||||
descripcion TEXT,
|
|
||||||
provincia TEXT,
|
|
||||||
subcategoria TEXT,
|
|
||||||
direccion TEXT,
|
|
||||||
enlaceGoogleMaps TEXT,
|
|
||||||
puntuacion INTEGER DEFAULT 0,
|
|
||||||
categoria TEXT,
|
|
||||||
estado TEXT DEFAULT 'pendiente',
|
|
||||||
fechaPropuesta TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
lat REAL,
|
|
||||||
lng REAL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS config (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
datos TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS reportes (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
localId TEXT NOT NULL,
|
|
||||||
motivo TEXT,
|
|
||||||
comentario TEXT,
|
|
||||||
fecha TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
estado TEXT DEFAULT 'pendiente'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
email TEXT UNIQUE NOT NULL,
|
|
||||||
password TEXT NOT NULL,
|
|
||||||
created TEXT DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Migración suave: si la base de datos ya existía con el esquema antiguo
|
const empty = db.prepare("SELECT COUNT(*) AS n FROM businesses").get().n === 0;
|
||||||
// (sin provincia/subcategoria/direccion/enlaceGoogleMaps/puntuacion),
|
if (empty) {
|
||||||
// añadimos las columnas que falten sin borrar nada, porque
|
const ins = db.prepare(`
|
||||||
// CREATE TABLE IF NOT EXISTS no modifica tablas ya creadas.
|
INSERT INTO businesses
|
||||||
function ensureColumn(table, column, definition) {
|
(id, name, category, address, description, contact, lat, lng, status, createdAt)
|
||||||
const cols = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
|
VALUES
|
||||||
if (!cols.includes(column)) {
|
(@id, @name, @category, @address, @description, @contact, @lat, @lng, @status, @createdAt)
|
||||||
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
`);
|
||||||
|
for (const b of [
|
||||||
|
{ id: "1", name: "Café del Mercado", category: "Cafetería", address: "Plaza Mayor 3, Madrid", description: "Café de especialidad y bollería artesana.", contact: "hola@cafedelmercado.example", lat: 40.4155, lng: -3.7074, status: "approved", createdAt: "2025-01-10T10:00:00Z" },
|
||||||
|
{ id: "2", name: "Librería El Faro", category: "Librería", address: "Calle Luna 12, Madrid", description: "Librería independiente con sección infantil.", contact: "", lat: 40.4221, lng: -3.7059, status: "approved", createdAt: "2025-01-12T10:00:00Z" },
|
||||||
|
{ id: "3", name: "Taller Bicicletas Rueda", category: "Servicios", address: "Calle Embajadores 45, Madrid", description: "Reparación y venta de segunda mano.", contact: "+34 600 000 000", lat: 40.4078, lng: -3.7053, status: "approved", createdAt: "2025-01-15T10:00:00Z" },
|
||||||
|
]) ins.run(b);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (const table of ["locales", "pendientes"]) {
|
|
||||||
ensureColumn(table, "provincia", "TEXT");
|
|
||||||
ensureColumn(table, "subcategoria", "TEXT");
|
|
||||||
ensureColumn(table, "direccion", "TEXT");
|
|
||||||
ensureColumn(table, "enlaceGoogleMaps", "TEXT");
|
|
||||||
ensureColumn(table, "puntuacion", "INTEGER DEFAULT 0");
|
|
||||||
}
|
|
||||||
// fechaConfirmacion: última vez que alguien confirmó que el local sigue activo.
|
|
||||||
// Al aprobarse por primera vez se inicializa igual a "fecha"; luego se actualiza
|
|
||||||
// con /api/locales/:id/confirmar cada vez que un usuario pulsa "Sigue aquí".
|
|
||||||
ensureColumn("locales", "fechaConfirmacion", "TEXT");
|
|
||||||
db.prepare("UPDATE locales SET fechaConfirmacion = fecha WHERE fechaConfirmacion IS NULL").run();
|
|
||||||
|
|
||||||
export default db;
|
export default db;
|
||||||
|
|||||||
@@ -1,180 +1,51 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import { fileURLToPath, pathToFileURL } from "url";
|
import { randomUUID } from "crypto";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import db from "./db.js";
|
import db from "./db.js";
|
||||||
import { randomUUID } from "crypto";
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static(join(__dirname, "../dist")));
|
app.use(express.static(join(__dirname, "../dist")));
|
||||||
|
|
||||||
// Locales (público, solo lectura)
|
app.get("/api/businesses", (_req, res) => {
|
||||||
app.get("/api/locales", (_req, res) => {
|
res.json(db.prepare("SELECT * FROM businesses WHERE status = 'approved' ORDER BY createdAt DESC").all());
|
||||||
const locales = db.prepare("SELECT * FROM locales ORDER BY fecha DESC").all();
|
|
||||||
res.json(locales);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pendientes (admin)
|
app.post("/api/businesses", (req, res) => {
|
||||||
app.get("/api/pendientes", (_req, res) => {
|
const { name, category, address, description, contact, lat, lng } = req.body;
|
||||||
const pendientes = db.prepare("SELECT * FROM pendientes ORDER BY fechaPropuesta DESC").all();
|
const business = {
|
||||||
res.json(pendientes);
|
id: randomUUID(),
|
||||||
});
|
name, category, address, description,
|
||||||
|
contact: contact || "",
|
||||||
// Utilidades para detectar duplicados
|
lat, lng,
|
||||||
function normaliza(s) {
|
status: "pending",
|
||||||
return (s || "").toString().toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim();
|
createdAt: new Date().toISOString(),
|
||||||
}
|
};
|
||||||
function distanciaMetros(lat1, lng1, lat2, lng2) {
|
|
||||||
const R = 6371000;
|
|
||||||
const toRad = (d) => (d * Math.PI) / 180;
|
|
||||||
const dLat = toRad(lat2 - lat1);
|
|
||||||
const dLng = toRad(lng2 - lng1);
|
|
||||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
|
||||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Comprueba si ya existe un local con el mismo nombre (en la misma provincia o muy
|
|
||||||
// cerca geográficamente) entre los ya publicados y los pendientes de revisión.
|
|
||||||
app.post("/api/check-duplicado", (req, res) => {
|
|
||||||
const { nombre, provincia, lat, lng } = req.body;
|
|
||||||
const nombreN = normaliza(nombre);
|
|
||||||
if (!nombreN) return res.json({ duplicado: false, coincidencias: [] });
|
|
||||||
|
|
||||||
const candidatos = [
|
|
||||||
...db.prepare("SELECT id, nombre, provincia, direccion, categoria, lat, lng FROM locales").all().map((l) => ({ ...l, estado: "publicado" })),
|
|
||||||
...db.prepare("SELECT id, nombre, provincia, direccion, categoria, lat, lng FROM pendientes").all().map((l) => ({ ...l, estado: "pendiente" })),
|
|
||||||
];
|
|
||||||
|
|
||||||
const coincidencias = candidatos.filter((l) => {
|
|
||||||
if (normaliza(l.nombre) !== nombreN) return false;
|
|
||||||
const mismaProvincia = provincia && normaliza(l.provincia) === normaliza(provincia);
|
|
||||||
const cerca = lat && lng && l.lat && l.lng && distanciaMetros(lat, lng, l.lat, l.lng) < 200;
|
|
||||||
return mismaProvincia || cerca;
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ duplicado: coincidencias.length > 0, coincidencias });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Enviar propuesta
|
|
||||||
app.post("/api/propuestas", (req, res) => {
|
|
||||||
const { nombre, descripcion, provincia, subcategoria, direccion, enlaceGoogleMaps, puntuacion, categoria, lat, lng } = req.body;
|
|
||||||
const id = randomUUID();
|
|
||||||
const fechaPropuesta = new Date().toISOString();
|
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO pendientes (id, nombre, descripcion, provincia, subcategoria, direccion, enlaceGoogleMaps, puntuacion, categoria, estado, fechaPropuesta, lat, lng)
|
INSERT INTO businesses (id, name, category, address, description, contact, lat, lng, status, createdAt)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pendiente', ?, ?, ?)
|
VALUES (@id, @name, @category, @address, @description, @contact, @lat, @lng, @status, @createdAt)
|
||||||
`).run(id, nombre, descripcion, provincia, subcategoria, direccion, enlaceGoogleMaps, puntuacion ?? 0, categoria, fechaPropuesta, lat, lng);
|
`).run(business);
|
||||||
|
res.status(201).json(business);
|
||||||
res.status(201).json({ id, nombre, descripcion, provincia, subcategoria, direccion, enlaceGoogleMaps, puntuacion: puntuacion ?? 0, categoria, estado: "pendiente", fechaPropuesta, lat, lng });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Aprobar propuesta
|
app.get("/api/admin/pending", (_req, res) => {
|
||||||
app.post("/api/aprobar/:id", (req, res) => {
|
res.json(db.prepare("SELECT * FROM businesses WHERE status = 'pending' ORDER BY createdAt ASC").all());
|
||||||
const { id } = req.params;
|
});
|
||||||
const propuesta = db.prepare("SELECT * FROM pendientes WHERE id = ?").get(id);
|
|
||||||
|
|
||||||
if (!propuesta) return res.status(404).json({ error: "Propuesta no encontrada" });
|
|
||||||
|
|
||||||
const localeId = randomUUID();
|
|
||||||
const fecha = new Date().toISOString();
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO locales (id, nombre, descripcion, provincia, subcategoria, direccion, enlaceGoogleMaps, puntuacion, categoria, fecha, lat, lng, fechaConfirmacion)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`).run(localeId, propuesta.nombre, propuesta.descripcion, propuesta.provincia, propuesta.subcategoria, propuesta.direccion, propuesta.enlaceGoogleMaps, propuesta.puntuacion ?? 0, propuesta.categoria, fecha, propuesta.lat, propuesta.lng, fecha);
|
|
||||||
|
|
||||||
db.prepare("DELETE FROM pendientes WHERE id = ?").run(id);
|
|
||||||
|
|
||||||
|
app.patch("/api/admin/businesses/:id", (req, res) => {
|
||||||
|
const { status } = req.body;
|
||||||
|
if (!["approved", "rejected"].includes(status))
|
||||||
|
return res.status(400).json({ error: "status must be 'approved' or 'rejected'" });
|
||||||
|
const info = db.prepare("UPDATE businesses SET status = ? WHERE id = ?").run(status, req.params.id);
|
||||||
|
if (info.changes === 0) return res.status(404).json({ error: "not found" });
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rechazar propuesta
|
|
||||||
app.post("/api/rechazar/:id", (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
db.prepare("DELETE FROM pendientes WHERE id = ?").run(id);
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Eliminar local
|
|
||||||
app.delete("/api/locales/:id", (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
db.prepare("DELETE FROM locales WHERE id = ?").run(id);
|
|
||||||
db.prepare("DELETE FROM reportes WHERE localId = ?").run(id);
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Confirmar que un local sigue activo (renueva fechaConfirmacion)
|
|
||||||
app.post("/api/locales/:id/confirmar", (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
const local = db.prepare("SELECT id FROM locales WHERE id = ?").get(id);
|
|
||||||
if (!local) return res.status(404).json({ error: "Local no encontrado" });
|
|
||||||
const ahora = new Date().toISOString();
|
|
||||||
db.prepare("UPDATE locales SET fechaConfirmacion = ? WHERE id = ?").run(ahora, id);
|
|
||||||
res.json({ ok: true, fechaConfirmacion: ahora });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reportar un local (cerrado / datos incorrectos / inapropiado / otro)
|
|
||||||
app.post("/api/locales/:id/reportar", (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { motivo, comentario } = req.body;
|
|
||||||
const local = db.prepare("SELECT id FROM locales WHERE id = ?").get(id);
|
|
||||||
if (!local) return res.status(404).json({ error: "Local no encontrado" });
|
|
||||||
const reporteId = randomUUID();
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO reportes (id, localId, motivo, comentario, fecha, estado)
|
|
||||||
VALUES (?, ?, ?, ?, ?, 'pendiente')
|
|
||||||
`).run(reporteId, id, motivo || "otro", comentario || "", new Date().toISOString());
|
|
||||||
res.status(201).json({ ok: true, id: reporteId });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reportes (admin)
|
|
||||||
app.get("/api/reportes", (_req, res) => {
|
|
||||||
const reportes = db.prepare(`
|
|
||||||
SELECT r.*, l.nombre AS localNombre, l.provincia AS localProvincia, l.direccion AS localDireccion
|
|
||||||
FROM reportes r LEFT JOIN locales l ON l.id = r.localId
|
|
||||||
WHERE r.estado = 'pendiente'
|
|
||||||
ORDER BY r.fecha DESC
|
|
||||||
`).all();
|
|
||||||
res.json(reportes);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Descartar un reporte (el admin revisó y no hace falta actuar)
|
|
||||||
app.post("/api/reportes/:id/descartar", (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
db.prepare("UPDATE reportes SET estado = 'descartado' WHERE id = ?").run(id);
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Config (palabras filtradas)
|
|
||||||
app.get("/api/config/filtro_palabras", (_req, res) => {
|
|
||||||
const config = db.prepare("SELECT datos FROM config WHERE id = ?").get("filtro_palabras");
|
|
||||||
if (config) {
|
|
||||||
res.json({ palabras: JSON.parse(config.datos) });
|
|
||||||
} else {
|
|
||||||
res.json({ palabras: [] });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post("/api/config/filtro_palabras", (req, res) => {
|
|
||||||
const { palabras } = req.body;
|
|
||||||
db.prepare("INSERT OR REPLACE INTO config (id, datos) VALUES (?, ?)").run("filtro_palabras", JSON.stringify(palabras));
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// SPA fallback
|
|
||||||
app.get(/^\/(?!api\/).*/, (_req, res) => {
|
app.get(/^\/(?!api\/).*/, (_req, res) => {
|
||||||
res.sendFile(join(__dirname, "../dist/index.html"));
|
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;
|
const PORT = process.env.PORT || 3000;
|
||||||
const esPuntoDeEntrada = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
app.listen(PORT, () => console.log(`API listening on http://localhost:${PORT}`));
|
||||||
if (esPuntoDeEntrada) {
|
|
||||||
app.listen(PORT, () => console.log(`Server listening on http://localhost:${PORT}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
export default app;
|
|
||||||
|
|||||||
@@ -1,351 +0,0 @@
|
|||||||
// AdminPanel.jsx — /admin
|
|
||||||
// No aparece en ningún menú público. Acceso solo por URL directa.
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import {
|
|
||||||
escucharAuth, loginAdmin, registrarAdmin, cerrarSesion,
|
|
||||||
suscribirPendientes, aprobarPropuesta, rechazarPropuesta,
|
|
||||||
suscribirLocales, deleteLocal,
|
|
||||||
obtenerReportes, descartarReporte,
|
|
||||||
obtenerPalabrasFiltradas, guardarPalabrasFiltradas,
|
|
||||||
} from "./firebase.js";
|
|
||||||
import { obtenerCategoria } from "./constants/categorias.js";
|
|
||||||
import { enlaceGoogleMapsDesdeLocal, enlaceWazeDesdeLocal } from "./utils/geo.js";
|
|
||||||
import StarRating from "./components/StarRating.jsx";
|
|
||||||
import Toast from "./components/Toast.jsx";
|
|
||||||
import useToast from "./hooks/useToast.js";
|
|
||||||
|
|
||||||
const CLAVE_TOAST_LOGIN = "localesp_mostrar_toast_login";
|
|
||||||
|
|
||||||
const S = {
|
|
||||||
page: { minHeight:"100vh", background:"#F2EDE8", fontFamily:"-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" },
|
|
||||||
header: { background:"#1A1A1A", color:"white", padding:"14px 28px", display:"flex", alignItems:"center", justifyContent:"space-between", position:"sticky", top:0, zIndex:10 },
|
|
||||||
wrap: { maxWidth:920, margin:"0 auto", padding:"28px 20px 60px" },
|
|
||||||
card: { background:"white", borderRadius:12, border:"1px solid #E8E0D5", padding:"20px 24px", marginBottom:14 },
|
|
||||||
input: { width:"100%", boxSizing:"border-box", padding:"10px 13px", border:"1px solid #DDD", borderRadius:8, fontSize:14, fontFamily:"inherit", outline:"none" },
|
|
||||||
btn: (bg="#8B0000",fg="white") => ({ padding:"9px 20px", background:bg, color:fg, border:"none", borderRadius:8, cursor:"pointer", fontWeight:600, fontSize:14, fontFamily:"inherit" }),
|
|
||||||
tab: (a) => ({ padding:"11px 22px", border:"none", background:"none", cursor:"pointer", fontFamily:"inherit", fontSize:14, fontWeight:a?700:400, color:a?"#8B0000":"#888", borderBottom:a?"2px solid #8B0000":"2px solid transparent", marginBottom:-2 }),
|
|
||||||
badge: (c,bg) => ({ display:"inline-block", padding:"3px 10px", borderRadius:20, fontSize:11, fontWeight:600, background:bg, color:c }),
|
|
||||||
tag: { display:"inline-flex", alignItems:"center", gap:5, background:"#F0EBE5", borderRadius:20, padding:"4px 12px", fontSize:13 },
|
|
||||||
label: { fontSize:11, color:"#999", fontWeight:700, textTransform:"uppercase", letterSpacing:"0.5px", display:"block", marginBottom:5 },
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Pantalla de login/registro ────────────────────────────────────────────────
|
|
||||||
function PantallaAuth() {
|
|
||||||
const [modo, setModo] = useState("login");
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [pass, setPass] = useState("");
|
|
||||||
const [pass2, setPass2] = useState("");
|
|
||||||
const [err, setErr] = useState("");
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
const errMsg = code => ({ "auth/wrong-password":"Contraseña incorrecta.", "auth/user-not-found":"No existe ningún admin con ese email.", "auth/invalid-credential":"Email o contraseña incorrectos.", "auth/email-already-in-use":"Ese email ya está registrado.", "auth/weak-password":"Mínimo 6 caracteres.", "auth/invalid-email":"Email no válido.", "auth/too-many-requests":"Demasiados intentos. Espera un momento." }[code] || "Error inesperado.");
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
setErr("");
|
|
||||||
if (!email || !pass) { setErr("Rellena todos los campos."); return; }
|
|
||||||
if (modo === "registro" && pass !== pass2) { setErr("Las contraseñas no coinciden."); return; }
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
modo === "login" ? await loginAdmin(email, pass) : await registrarAdmin(email, pass);
|
|
||||||
try { sessionStorage.setItem(CLAVE_TOAST_LOGIN, modo === "login" ? "1" : "0"); } catch { /* sessionStorage no disponible */ }
|
|
||||||
}
|
|
||||||
catch(e) { setErr(errMsg(e.code)); }
|
|
||||||
setBusy(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ ...S.page, display:"flex", alignItems:"center", justifyContent:"center" }}>
|
|
||||||
<div style={{ width:"100%", maxWidth:400, padding:"0 20px" }}>
|
|
||||||
<div style={{ textAlign:"center", marginBottom:28 }}>
|
|
||||||
<div style={{ fontSize:52, marginBottom:8 }}>🔐</div>
|
|
||||||
<h1 style={{ margin:0, fontSize:22, color:"#1A1A1A" }}>Panel de Administración</h1>
|
|
||||||
<p style={{ margin:"6px 0 0", color:"#999", fontSize:13 }}>Locales Españoles · acceso restringido</p>
|
|
||||||
</div>
|
|
||||||
<div style={{ ...S.card, padding:"24px 28px" }}>
|
|
||||||
<div style={{ display:"flex", borderBottom:"1px solid #EEE", marginBottom:20 }}>
|
|
||||||
{[{id:"login",l:"Iniciar sesión"},{id:"registro",l:"Crear admin"}].map(t => (
|
|
||||||
<button key={t.id} onClick={() => { setModo(t.id); setErr(""); }}
|
|
||||||
style={{ flex:1, padding:"10px 0", border:"none", background:"none", cursor:"pointer", fontFamily:"inherit", fontSize:14, fontWeight:modo===t.id?700:400, color:modo===t.id?"#8B0000":"#888", borderBottom:modo===t.id?"2px solid #8B0000":"2px solid transparent", marginBottom:-1 }}>
|
|
||||||
{t.l}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div style={{ display:"flex", flexDirection:"column", gap:14 }}>
|
|
||||||
<div><label style={S.label}>Email</label><input style={S.input} type="email" placeholder="admin@ejemplo.com" value={email} onChange={e=>setEmail(e.target.value)} onKeyDown={e=>e.key==="Enter"&&submit()} /></div>
|
|
||||||
<div><label style={S.label}>Contraseña</label><input style={S.input} type="password" placeholder="Mínimo 6 caracteres" value={pass} onChange={e=>setPass(e.target.value)} onKeyDown={e=>e.key==="Enter"&&submit()} /></div>
|
|
||||||
{modo==="registro" && <div><label style={S.label}>Repetir contraseña</label><input style={S.input} type="password" placeholder="Repite la contraseña" value={pass2} onChange={e=>setPass2(e.target.value)} onKeyDown={e=>e.key==="Enter"&&submit()} /></div>}
|
|
||||||
{err && <p style={{ margin:0, color:"#C0392B", fontSize:13, background:"#FDEDEC", padding:"8px 12px", borderRadius:8 }}>{err}</p>}
|
|
||||||
<button onClick={submit} disabled={busy} style={{ ...S.btn(), opacity:busy?0.6:1, marginTop:4 }}>
|
|
||||||
{busy ? "..." : modo==="login" ? "Entrar" : "Crear cuenta de administrador"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p style={{ textAlign:"center", fontSize:12, color:"#CCC", marginTop:14 }}>Esta página no está indexada en el sitio público</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tarjeta propuesta pendiente ───────────────────────────────────────────────
|
|
||||||
function TarjetaPropuesta({ p, onAprobar, onRechazar, busy }) {
|
|
||||||
const cat = obtenerCategoria(p.categoria);
|
|
||||||
const fecha = new Date(p.fechaPropuesta).toLocaleDateString("es-ES", { day:"numeric", month:"short", hour:"2-digit", minute:"2-digit" });
|
|
||||||
const gmUrl = p.enlaceGoogleMaps || enlaceGoogleMapsDesdeLocal(p);
|
|
||||||
const wazeUrl = enlaceWazeDesdeLocal(p);
|
|
||||||
return (
|
|
||||||
<div style={{ ...S.card, borderLeft:`4px solid ${cat.color}`, marginBottom:10 }}>
|
|
||||||
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"flex-start", flexWrap:"wrap", gap:12 }}>
|
|
||||||
<div style={{ flex:1 }}>
|
|
||||||
<div style={{ display:"flex", alignItems:"center", gap:8, flexWrap:"wrap", marginBottom:6 }}>
|
|
||||||
<span style={{ fontWeight:700, fontSize:17 }}>{p.nombre}</span>
|
|
||||||
{p.categoria && <span style={S.badge(cat.textColor, cat.bgLight)}>{cat.emoji} {p.categoria}</span>}
|
|
||||||
{p.subcategoria && <span style={{ fontSize:12, color:"#888", background:"#F5F5F5", padding:"2px 8px", borderRadius:20 }}>{p.subcategoria}</span>}
|
|
||||||
</div>
|
|
||||||
<div style={{ display:"flex", flexWrap:"wrap", alignItems:"center", gap:16, fontSize:13, color:"#666" }}>
|
|
||||||
<span>📍 {p.provincia}</span>
|
|
||||||
{p.direccion && (gmUrl ? <a href={gmUrl} target="_blank" rel="noopener noreferrer" style={{ color:"#666", textDecoration:"underline" }}>🗺 {p.direccion}</a> : <span>🗺 {p.direccion}</span>)}
|
|
||||||
<StarRating value={p.puntuacion} readOnly size={14} />
|
|
||||||
<span style={{ color:"#AAA" }}>Enviado: {fecha}</span>
|
|
||||||
</div>
|
|
||||||
{p.descripcion && <p style={{ margin:"8px 0 0", fontSize:13, color:"#666", fontStyle:"italic", background:"#FAFAFA", padding:"8px 12px", borderRadius:8 }}>"{p.descripcion}"</p>}
|
|
||||||
<div style={{ display:"flex", gap:10 }}>
|
|
||||||
{gmUrl && <a href={gmUrl} target="_blank" rel="noopener noreferrer" style={{ display:"inline-block", marginTop:6, fontSize:12, color:"#1A73E8" }}>Ver en Google Maps ↗</a>}
|
|
||||||
{wazeUrl && <a href={wazeUrl} target="_blank" rel="noopener noreferrer" style={{ display:"inline-block", marginTop:6, fontSize:12, color:"#0A9FD9" }}>Ver en Waze ↗</a>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display:"flex", gap:8, flexShrink:0 }}>
|
|
||||||
<button onClick={() => onAprobar(p)} disabled={busy} style={{ ...S.btn("#1E8449"), padding:"8px 16px", fontSize:13 }}>✓ Aprobar</button>
|
|
||||||
<button onClick={() => onRechazar(p.id)} disabled={busy} style={{ ...S.btn("#C0392B"), padding:"8px 16px", fontSize:13 }}>✕ Rechazar</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Panel principal ───────────────────────────────────────────────────────────
|
|
||||||
function PanelAdmin({ email }) {
|
|
||||||
const [tab, setTab] = useState("pendientes");
|
|
||||||
const [pendientes, setPendientes] = useState([]);
|
|
||||||
const [publicados, setPublicados] = useState([]);
|
|
||||||
const [reportes, setReportes] = useState([]);
|
|
||||||
const [palabras, setPalabras] = useState([]);
|
|
||||||
const [nuevaP, setNuevaP] = useState("");
|
|
||||||
const [cargando, setCargando] = useState(true);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [guardando, setGuardando] = useState(false);
|
|
||||||
const [buscador, setBuscador] = useState("");
|
|
||||||
const { toast, mostrarToast } = useToast();
|
|
||||||
|
|
||||||
const cargarReportes = () => obtenerReportes().then(setReportes).catch(() => {});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
const marca = sessionStorage.getItem(CLAVE_TOAST_LOGIN);
|
|
||||||
if (marca !== null) {
|
|
||||||
mostrarToast(marca === "1" ? `Sesión iniciada como ${email}` : "Cuenta de administrador creada", "exito");
|
|
||||||
sessionStorage.removeItem(CLAVE_TOAST_LOGIN);
|
|
||||||
}
|
|
||||||
} catch { /* sessionStorage no disponible */ }
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCargando(true);
|
|
||||||
let cancelado = false;
|
|
||||||
let limpiarPendientes, limpiarPublicados;
|
|
||||||
suscribirPendientes(l => { if (!cancelado) { setPendientes(l); setCargando(false); } }, () => !cancelado && setCargando(false))
|
|
||||||
.then(fn => { if (cancelado) fn?.(); else limpiarPendientes = fn; });
|
|
||||||
suscribirLocales(l => !cancelado && setPublicados(l), () => {})
|
|
||||||
.then(fn => { if (cancelado) fn?.(); else limpiarPublicados = fn; });
|
|
||||||
obtenerPalabrasFiltradas().then(p => !cancelado && setPalabras(p));
|
|
||||||
cargarReportes();
|
|
||||||
return () => { cancelado = true; limpiarPendientes?.(); limpiarPublicados?.(); };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const aprobar = async (p) => { setBusy(true); try { await aprobarPropuesta(p); mostrarToast(`✅ "${p.nombre}" aprobado y publicado`, "exito"); } catch(e) { mostrarToast("Error: "+e.message, "error"); } setBusy(false); };
|
|
||||||
const rechazar = async (id) => { if (!confirm("¿Rechazar y eliminar esta propuesta?")) return; try { await rechazarPropuesta(id); mostrarToast("Propuesta rechazada", "info"); } catch(e) { mostrarToast("Error: "+e.message, "error"); } };
|
|
||||||
const eliminar = async (id) => { if (!confirm("¿Eliminar este local publicado?")) return; try { await deleteLocal(id); cargarReportes(); mostrarToast("Local eliminado", "info"); } catch(e) { mostrarToast("Error: "+e.message, "error"); } };
|
|
||||||
const descartar = async (id) => { try { await descartarReporte(id); setReportes(r => r.filter(x => x.id !== id)); mostrarToast("Aviso descartado", "info"); } catch(e) { mostrarToast("Error: "+e.message, "error"); } };
|
|
||||||
|
|
||||||
const addPalabra = () => {
|
|
||||||
const p = nuevaP.trim().toLowerCase();
|
|
||||||
if (!p || palabras.includes(p)) return;
|
|
||||||
setPalabras([...palabras, p]); setNuevaP("");
|
|
||||||
};
|
|
||||||
const guardarFiltro = async () => {
|
|
||||||
setGuardando(true);
|
|
||||||
try { await guardarPalabrasFiltradas(palabras); mostrarToast("💾 Filtro de palabras guardado", "exito"); }
|
|
||||||
catch(e) { mostrarToast("Error: "+e.message, "error"); }
|
|
||||||
setGuardando(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const pubFiltrados = publicados.filter(l =>
|
|
||||||
!buscador || l.nombre.toLowerCase().includes(buscador.toLowerCase()) || l.provincia?.toLowerCase().includes(buscador.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
const TABS = [
|
|
||||||
{ id:"pendientes", l:`Pendientes (${pendientes.length})` },
|
|
||||||
{ id:"publicados", l:`Publicados (${publicados.length})` },
|
|
||||||
{ id:"reportes", l:`Reportes${reportes.length ? ` (${reportes.length})` : ""}` },
|
|
||||||
{ id:"filtro", l:"Filtro de palabras" },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={S.page}>
|
|
||||||
<div style={S.header}>
|
|
||||||
<div style={{ display:"flex", alignItems:"center", gap:12 }}>
|
|
||||||
<span style={{ fontSize:24 }}>🛡️</span>
|
|
||||||
<div>
|
|
||||||
<p style={{ margin:0, fontWeight:700, fontSize:16 }}>Panel de Administración</p>
|
|
||||||
<p style={{ margin:0, fontSize:12, opacity:0.55 }}>Locales Españoles · {email}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={cerrarSesion} style={{ background:"rgba(255,255,255,0.1)", border:"1px solid rgba(255,255,255,0.2)", color:"white", padding:"7px 16px", borderRadius:8, cursor:"pointer", fontSize:13, fontFamily:"inherit" }}>
|
|
||||||
Cerrar sesión
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={S.wrap}>
|
|
||||||
{/* Tabs */}
|
|
||||||
<div style={{ display:"flex", borderBottom:"2px solid #E8E0D5", marginBottom:24 }}>
|
|
||||||
{TABS.map(t => <button key={t.id} onClick={() => setTab(t.id)} style={S.tab(tab===t.id)}>{t.l}</button>)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Pendientes ── */}
|
|
||||||
{tab === "pendientes" && (
|
|
||||||
cargando ? <p style={{ color:"#999", textAlign:"center", padding:"3rem" }}>Cargando propuestas...</p>
|
|
||||||
: pendientes.length === 0 ? (
|
|
||||||
<div style={{ textAlign:"center", padding:"4rem", color:"#AAA" }}>
|
|
||||||
<div style={{ fontSize:52, marginBottom:12 }}>🎉</div>
|
|
||||||
<p style={{ margin:0, fontSize:16, fontWeight:600, color:"#888" }}>Sin propuestas pendientes</p>
|
|
||||||
<p style={{ margin:"6px 0 0", fontSize:13 }}>Cuando un usuario proponga un local, aparecerá aquí para que lo apruebes o rechaces.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p style={{ margin:"0 0 16px", fontSize:13, color:"#888" }}>
|
|
||||||
{pendientes.length} propuesta{pendientes.length!==1?"s":""} esperando revisión.
|
|
||||||
</p>
|
|
||||||
{pendientes.map(p => <TarjetaPropuesta key={p.id} p={p} onAprobar={aprobar} onRechazar={rechazar} busy={busy} />)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Publicados ── */}
|
|
||||||
{tab === "publicados" && (
|
|
||||||
<>
|
|
||||||
<div style={{ marginBottom:16 }}>
|
|
||||||
<input style={{ ...S.input, maxWidth:380 }} placeholder="🔍 Buscar por nombre o provincia..." value={buscador} onChange={e => setBuscador(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
{pubFiltrados.length === 0
|
|
||||||
? <p style={{ color:"#AAA", textAlign:"center", padding:"2rem" }}>{publicados.length===0 ? "No hay locales publicados." : "Sin resultados."}</p>
|
|
||||||
: pubFiltrados.map(l => (
|
|
||||||
<div key={l.id} style={{ ...S.card, padding:"12px 18px", marginBottom:8 }}>
|
|
||||||
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"center", gap:12 }}>
|
|
||||||
<div style={{ flex:1 }}>
|
|
||||||
<div style={{ display:"flex", alignItems:"center", gap:8, flexWrap:"wrap" }}>
|
|
||||||
<span style={{ fontWeight:600, fontSize:15 }}>{l.nombre}</span>
|
|
||||||
{l.categoria && <span style={S.badge(obtenerCategoria(l.categoria).textColor, obtenerCategoria(l.categoria).bgLight)}>{obtenerCategoria(l.categoria).emoji} {l.categoria}</span>}
|
|
||||||
<span style={{ fontSize:12, color:"#AAA" }}>📍 {l.provincia} · {new Date(l.fecha).toLocaleDateString("es-ES",{day:"numeric",month:"short",year:"numeric"})}</span>
|
|
||||||
</div>
|
|
||||||
{l.direccion && (() => {
|
|
||||||
const url = l.enlaceGoogleMaps || enlaceGoogleMapsDesdeLocal(l);
|
|
||||||
return url
|
|
||||||
? <a href={url} target="_blank" rel="noopener noreferrer" style={{ margin:"3px 0 0", fontSize:13, color:"#888", textDecoration:"underline", display:"block" }}>{l.direccion}</a>
|
|
||||||
: <p style={{ margin:"3px 0 0", fontSize:13, color:"#888" }}>{l.direccion}</p>;
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
<button onClick={() => eliminar(l.id)} style={{ background:"none", border:"1px solid #FADADD", borderRadius:8, padding:"6px 14px", color:"#C0392B", cursor:"pointer", fontSize:12, fontWeight:600, flexShrink:0 }}>Eliminar</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Reportes ── */}
|
|
||||||
{tab === "reportes" && (
|
|
||||||
reportes.length === 0 ? (
|
|
||||||
<div style={{ textAlign:"center", padding:"4rem", color:"#AAA" }}>
|
|
||||||
<div style={{ fontSize:52, marginBottom:12 }}>🚩</div>
|
|
||||||
<p style={{ margin:0, fontSize:16, fontWeight:600, color:"#888" }}>Sin reportes pendientes</p>
|
|
||||||
<p style={{ margin:"6px 0 0", fontSize:13 }}>Cuando alguien reporte un local (cerrado, datos incorrectos...), aparecerá aquí.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p style={{ margin:"0 0 16px", fontSize:13, color:"#888" }}>{reportes.length} reporte{reportes.length!==1?"s":""} sin revisar.</p>
|
|
||||||
{reportes.map(r => (
|
|
||||||
<div key={r.id} style={{ ...S.card, borderLeft:"4px solid #C0392B", marginBottom:10 }}>
|
|
||||||
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"flex-start", flexWrap:"wrap", gap:12 }}>
|
|
||||||
<div style={{ flex:1 }}>
|
|
||||||
<div style={{ display:"flex", alignItems:"center", gap:8, flexWrap:"wrap", marginBottom:6 }}>
|
|
||||||
<span style={{ fontWeight:700, fontSize:16 }}>{r.localNombre || "(local ya eliminado)"}</span>
|
|
||||||
<span style={S.badge("#C0392B", "#FDEDEC")}>{{ cerrado:"Ha cerrado", datos_incorrectos:"Datos incorrectos", inapropiado:"Contenido inapropiado", otro:"Otro motivo" }[r.motivo] || r.motivo}</span>
|
|
||||||
</div>
|
|
||||||
<p style={{ margin:0, fontSize:13, color:"#666" }}>📍 {r.localProvincia} {r.localDireccion ? `· ${r.localDireccion}` : ""}</p>
|
|
||||||
{r.comentario && <p style={{ margin:"8px 0 0", fontSize:13, color:"#666", fontStyle:"italic", background:"#FAFAFA", padding:"8px 12px", borderRadius:8 }}>"{r.comentario}"</p>}
|
|
||||||
<p style={{ margin:"6px 0 0", fontSize:11, color:"#AAA" }}>Reportado: {new Date(r.fecha).toLocaleDateString("es-ES", { day:"numeric", month:"short", hour:"2-digit", minute:"2-digit" })}</p>
|
|
||||||
</div>
|
|
||||||
<div style={{ display:"flex", gap:8, flexShrink:0 }}>
|
|
||||||
{r.localId && <button onClick={() => eliminar(r.localId)} style={{ ...S.btn("#C0392B"), padding:"8px 16px", fontSize:13 }}>🗑 Eliminar local</button>}
|
|
||||||
<button onClick={() => descartar(r.id)} style={{ ...S.btn("#EEE","#666"), padding:"8px 16px", fontSize:13 }}>Descartar aviso</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Filtro de palabras ── */}
|
|
||||||
{tab === "filtro" && (
|
|
||||||
<div style={{ maxWidth:620 }}>
|
|
||||||
<div style={S.card}>
|
|
||||||
<p style={{ margin:"0 0 4px", fontWeight:700, fontSize:16 }}>Palabras prohibidas</p>
|
|
||||||
<p style={{ margin:"0 0 18px", fontSize:13, color:"#666", lineHeight:1.6 }}>
|
|
||||||
Las propuestas que contengan estas palabras en nombre, dirección o descripción serán bloqueadas automáticamente, sin llegar siquiera al panel de moderación. No distingue mayúsculas ni tildes.
|
|
||||||
</p>
|
|
||||||
<div style={{ display:"flex", gap:8, marginBottom:16 }}>
|
|
||||||
<input style={{ ...S.input, flex:1 }} placeholder="Añadir palabra o frase..." value={nuevaP}
|
|
||||||
onChange={e => setNuevaP(e.target.value)} onKeyDown={e => e.key==="Enter" && addPalabra()} />
|
|
||||||
<button onClick={addPalabra} style={S.btn()}>Añadir</button>
|
|
||||||
</div>
|
|
||||||
{palabras.length === 0
|
|
||||||
? <p style={{ color:"#BBB", fontSize:13, fontStyle:"italic" }}>No hay palabras filtradas todavía.</p>
|
|
||||||
: <div style={{ display:"flex", flexWrap:"wrap", gap:8, marginBottom:16 }}>
|
|
||||||
{palabras.map(p => (
|
|
||||||
<span key={p} style={S.tag}>
|
|
||||||
{p}
|
|
||||||
<button onClick={() => setPalabras(palabras.filter(x=>x!==p))} style={{ background:"none", border:"none", cursor:"pointer", color:"#AAA", padding:0, fontSize:15, lineHeight:1 }}>✕</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<button onClick={guardarFiltro} disabled={guardando} style={{ ...S.btn(), opacity:guardando?0.6:1 }}>
|
|
||||||
{guardando ? "Guardando..." : "💾 Guardar filtro"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ ...S.card, background:"#FFFBF0", border:"1px solid #F5E6C8" }}>
|
|
||||||
<p style={{ margin:"0 0 8px", fontWeight:600, fontSize:14, color:"#784212" }}>ℹ️ Cómo funciona el flujo completo</p>
|
|
||||||
<ol style={{ margin:0, paddingLeft:20, fontSize:13, color:"#555", lineHeight:2 }}>
|
|
||||||
<li>El usuario rellena el formulario público y pulsa <em>"Enviar propuesta"</em>.</li>
|
|
||||||
<li>Antes de guardar, se comprueba el filtro de palabras. Si hay coincidencia → se rechaza al instante con un aviso.</li>
|
|
||||||
<li>Si pasa el filtro → se guarda en <code>/pendientes</code> en Firestore.</li>
|
|
||||||
<li>Aquí en el panel aparece la tarjeta. Tú pulsas <strong>Aprobar</strong> → pasa a <code>/locales</code> y se publica. O <strong>Rechazar</strong> → se elimina sin publicar.</li>
|
|
||||||
<li>Solo los locales en <code>/locales</code> son visibles en la web y la app móvil.</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Toast toast={toast} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Raíz del panel ────────────────────────────────────────────────────────────
|
|
||||||
export default function AdminPanel() {
|
|
||||||
const [user, setUser] = useState(undefined);
|
|
||||||
useEffect(() => escucharAuth(u => setUser(u)), []);
|
|
||||||
if (user === undefined) return <div style={{ ...S.page, display:"flex", alignItems:"center", justifyContent:"center" }}><p style={{ color:"#999" }}>Cargando...</p></div>;
|
|
||||||
if (!user) return <PantallaAuth />;
|
|
||||||
return <PanelAdmin email={user.email} />;
|
|
||||||
}
|
|
||||||
@@ -1,454 +1,74 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { comprobarDuplicado, contienepalabrasProhibidas, enviarPropuesta, obtenerPalabrasFiltradas, suscribirLocales } from "./firebase.js";
|
import MapView from "./components/MapView.jsx";
|
||||||
import { CATEGORIAS, COLORES_MAPA, NOMBRES_CATEGORIAS, obtenerCategoria } from "./constants/categorias.js";
|
import Sidebar from "./components/Sidebar.jsx";
|
||||||
import { COORDS_PROVINCIAS, PROVINCIAS, CENTRO_ESPANA } from "./constants/provincias.js";
|
import SubmitForm from "./components/SubmitForm.jsx";
|
||||||
import { distanciaKm as calcularDistanciaKm, enlaceGoogleMapsDesdeLocal, enlaceWazeDesdeLocal } from "./utils/geo.js";
|
import AdminPanel from "./components/AdminPanel.jsx";
|
||||||
import { inputStyle, ANCHO_CUERPO } from "./styles/shared.js";
|
import { getApproved, submit } from "./services/businessRepository.js";
|
||||||
import LocalCard from "./components/LocalCard.jsx";
|
|
||||||
import BannerMovil from "./components/BannerMovil.jsx";
|
|
||||||
import ModalPrivacidad from "./components/ModalPrivacidad.jsx";
|
|
||||||
import BotonFlotante from "./components/BotonFlotante.jsx";
|
|
||||||
import BotonProponerArriba from "./components/BotonProponerArriba.jsx";
|
|
||||||
import ModalPasarela from "./components/ModalPasarela.jsx";
|
|
||||||
import useFavoritos from "./hooks/useFavoritos.js";
|
|
||||||
import useUbicacion from "./hooks/useUbicacion.js";
|
|
||||||
|
|
||||||
const FORM_VACIO = {
|
|
||||||
nombre: "", provincia: "", categoria: "", subcategoria: "",
|
|
||||||
direccion: "", enlaceGoogleMaps: "", lat: null, lng: null,
|
|
||||||
entradaUbicacion: "", proveedorUbicacion: "", // estado interno del paso de ubicación, no se envía
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── App principal ─────────────────────────────────────────────────────────────
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [locales, setLocales] = useState([]);
|
const [businesses, setBusinesses] = useState([]);
|
||||||
const [vista, setVista] = useState("lista");
|
const [selectedId, setSelectedId] = useState(null);
|
||||||
const [pasarelaAbierta, setPasarelaAbierta] = useState(false);
|
const [formStep, setFormStep] = useState(null); // null | 'picking' | 'editing'
|
||||||
const [loading, setLoading] = useState(true);
|
const [pickedLocation, setPickedLocation] = useState(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [adminOpen, setAdminOpen] = useState(false);
|
||||||
const [enviado, setEnviado] = useState(false); // confirmación de propuesta enviada
|
|
||||||
const [errorFiltro, setErrorFiltro] = useState(""); // palabra prohibida detectada
|
|
||||||
const [busqueda, setBusqueda] = useState("");
|
|
||||||
const [filtroCategoria, setFiltroCategoria] = useState("");
|
|
||||||
const [filtroSubcat, setFiltroSubcat] = useState("");
|
|
||||||
const [filtroProvincia, setFiltroProvincia] = useState("");
|
|
||||||
const [orden, setOrden] = useState("fecha");
|
|
||||||
const [palabrasProhibidas, setPalabrasProhibidas] = useState([]);
|
|
||||||
const [comprobandoDuplicado, setComprobandoDuplicado] = useState(false);
|
|
||||||
const [posibleDuplicado, setPosibleDuplicado] = useState(null); // { coincidencias, lat, lng }
|
|
||||||
const [soloFavoritos, setSoloFavoritos] = useState(false);
|
|
||||||
const [aceptaPrivacidad, setAceptaPrivacidad] = useState(false);
|
|
||||||
const [mostrarPrivacidad, setMostrarPrivacidad] = useState(false);
|
|
||||||
const [localDestacado, setLocalDestacado] = useState(null); // id del local abierto vía enlace compartido
|
|
||||||
const { favoritos, esFavorito } = useFavoritos();
|
|
||||||
const { ubicacion, buscando: buscandoUbicacion, error: errorUbicacion, pedirUbicacion, limpiarUbicacion } = useUbicacion();
|
|
||||||
const mapRef = useRef(null);
|
|
||||||
const mapInstanceRef = useRef(null);
|
|
||||||
const markersRef = useRef([]);
|
|
||||||
|
|
||||||
const [form, setForm] = useState(FORM_VACIO);
|
async function loadBusinesses() {
|
||||||
|
setBusinesses(await getApproved());
|
||||||
// Carga locales aprobados en tiempo real.
|
|
||||||
// suscribirLocales es async (hace fetch antes de devolver la función de
|
|
||||||
// limpieza), así que su valor de retorno inmediato es una Promise, no la
|
|
||||||
// función en sí: hay que esperarla antes de poder cancelar el intervalo.
|
|
||||||
useEffect(() => {
|
|
||||||
setLoading(true);
|
|
||||||
let cancelado = false;
|
|
||||||
let limpiar;
|
|
||||||
suscribirLocales(
|
|
||||||
(lista) => { if (!cancelado) { setLocales(lista); setLoading(false); } },
|
|
||||||
() => { if (!cancelado) { setLocales([]); setLoading(false); } },
|
|
||||||
).then((fn) => { if (cancelado) fn?.(); else limpiar = fn; });
|
|
||||||
return () => { cancelado = true; limpiar?.(); };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Carga filtro de palabras desde el backend
|
|
||||||
useEffect(() => {
|
|
||||||
obtenerPalabrasFiltradas().then((p) => setPalabrasProhibidas(p));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Enlace compartido (?local=ID): al cargar, resalta y desplaza hasta esa ficha
|
|
||||||
useEffect(() => {
|
|
||||||
const id = new URLSearchParams(window.location.search).get("local");
|
|
||||||
if (!id || loading || locales.length === 0) return;
|
|
||||||
setLocalDestacado(id);
|
|
||||||
const el = document.getElementById(`local-${id}`);
|
|
||||||
if (el) {
|
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
const timer = setTimeout(() => setLocalDestacado(null), 3000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [loading, locales]);
|
|
||||||
|
|
||||||
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 {
|
|
||||||
await enviarPropuesta({
|
|
||||||
nombre: form.nombre, provincia: form.provincia,
|
|
||||||
categoria: form.categoria, subcategoria: form.subcategoria,
|
|
||||||
direccion: form.direccion, enlaceGoogleMaps: form.enlaceGoogleMaps,
|
|
||||||
lat, lng,
|
|
||||||
});
|
|
||||||
setEnviado(true);
|
|
||||||
resetForm();
|
|
||||||
setAceptaPrivacidad(false);
|
|
||||||
setPasarelaAbierta(false);
|
|
||||||
setPosibleDuplicado(null);
|
|
||||||
setTimeout(() => setEnviado(false), 5000);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
setSaving(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const enviarLocal = async () => {
|
|
||||||
setErrorFiltro("");
|
|
||||||
setPosibleDuplicado(null);
|
|
||||||
// Comprobar filtro de palabras
|
|
||||||
const textoCompleto = [form.nombre, form.direccion].join(" ");
|
|
||||||
if (contienepalabrasProhibidas(textoCompleto, palabrasProhibidas)) {
|
|
||||||
setErrorFiltro("Tu propuesta contiene palabras no permitidas. Por favor, revisa el nombre o la dirección.");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { lat, lng } = form;
|
useEffect(() => { loadBusinesses(); }, []);
|
||||||
if (!lat || !lng) {
|
|
||||||
const fallback = COORDS_PROVINCIAS[form.provincia] || CENTRO_ESPANA;
|
const selected = businesses.find((b) => b.id === selectedId) || null;
|
||||||
lat = fallback[0] + (Math.random() - 0.5) * 0.04;
|
|
||||||
lng = fallback[1] + (Math.random() - 0.5) * 0.04;
|
function handlePickLocation(loc) {
|
||||||
|
setPickedLocation(loc);
|
||||||
|
if (formStep === "picking") setFormStep("editing");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Comprobar si ya existe un local igual (mismo nombre + provincia, o muy cerca)
|
async function handleSubmit(data) {
|
||||||
setComprobandoDuplicado(true);
|
await submit(data);
|
||||||
try {
|
setFormStep(null);
|
||||||
const chequeo = await comprobarDuplicado({ nombre: form.nombre, provincia: form.provincia, lat, lng });
|
setPickedLocation(null);
|
||||||
setComprobandoDuplicado(false);
|
alert("¡Gracias! Tu propuesta queda pendiente de revisión.");
|
||||||
if (chequeo?.duplicado) {
|
|
||||||
setPosibleDuplicado({ coincidencias: chequeo.coincidencias, lat, lng });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
setComprobandoDuplicado(false);
|
|
||||||
// Si falla la comprobación, se permite continuar para no bloquear el envío
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await guardarPropuesta(lat, lng);
|
function handleCancel() {
|
||||||
};
|
setFormStep(null);
|
||||||
|
setPickedLocation(null);
|
||||||
const confirmarPeseADuplicado = () => {
|
|
||||||
if (!posibleDuplicado) return;
|
|
||||||
guardarPropuesta(posibleDuplicado.lat, posibleDuplicado.lng);
|
|
||||||
};
|
|
||||||
|
|
||||||
const subcatsFiltro = filtroCategoria ? CATEGORIAS[filtroCategoria]?.subcategorias || [] : [];
|
|
||||||
|
|
||||||
const localesFiltrados = locales
|
|
||||||
.filter((l) => {
|
|
||||||
const q = busqueda.toLowerCase();
|
|
||||||
return (!q || l.nombre.toLowerCase().includes(q) || l.provincia.toLowerCase().includes(q) || (l.categoria || "").toLowerCase().includes(q) || (l.subcategoria || "").toLowerCase().includes(q) || (l.direccion || "").toLowerCase().includes(q))
|
|
||||||
&& (!filtroCategoria || l.categoria === filtroCategoria)
|
|
||||||
&& (!filtroSubcat || l.subcategoria === filtroSubcat)
|
|
||||||
&& (!filtroProvincia || l.provincia === filtroProvincia)
|
|
||||||
&& (!soloFavoritos || esFavorito(l.id));
|
|
||||||
})
|
|
||||||
.sort((a, b) => {
|
|
||||||
if (orden === "distancia" && ubicacion) {
|
|
||||||
const da = a.lat && a.lng ? calcularDistanciaKm(ubicacion.lat, ubicacion.lng, a.lat, a.lng) : Infinity;
|
|
||||||
const db = b.lat && b.lng ? calcularDistanciaKm(ubicacion.lat, ubicacion.lng, b.lat, b.lng) : Infinity;
|
|
||||||
return da - db;
|
|
||||||
}
|
}
|
||||||
if (orden === "fecha") return new Date(b.fecha) - new Date(a.fecha);
|
|
||||||
if (orden === "puntuacion") return b.puntuacion - a.puntuacion;
|
|
||||||
return a.nombre.localeCompare(b.nombre);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mapa Leaflet. El contenedor se mantiene siempre montado (ver más abajo,
|
|
||||||
// solo se oculta con CSS) para que la instancia de Leaflet no se pierda al
|
|
||||||
// cambiar de pestaña. Si ya existe, solo recalculamos su tamaño: Leaflet no
|
|
||||||
// lo hace solo cuando el contenedor pasa de display:none a visible, y por
|
|
||||||
// eso antes hacía falta refrescar la página para que el mapa se viera bien.
|
|
||||||
useEffect(() => {
|
|
||||||
if (vista !== "mapa") return;
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
if (!mapRef.current) return;
|
|
||||||
if (mapInstanceRef.current) {
|
|
||||||
mapInstanceRef.current.invalidateSize();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!window.L) {
|
|
||||||
const link = document.createElement("link");
|
|
||||||
link.rel = "stylesheet";
|
|
||||||
link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
|
|
||||||
document.head.appendChild(link);
|
|
||||||
const script = document.createElement("script");
|
|
||||||
script.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js";
|
|
||||||
script.onload = () => initMap();
|
|
||||||
document.head.appendChild(script);
|
|
||||||
} else {
|
|
||||||
initMap();
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [vista]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (vista === "mapa" && mapInstanceRef.current && window.L) updateMarkers();
|
|
||||||
}, [locales, vista, filtroCategoria, filtroSubcat, filtroProvincia, busqueda, soloFavoritos, favoritos]);
|
|
||||||
|
|
||||||
function initMap() {
|
|
||||||
if (!mapRef.current || mapInstanceRef.current) return;
|
|
||||||
const L = window.L;
|
|
||||||
const map = L.map(mapRef.current, { zoomControl: true }).setView([40.416, -3.703], 6);
|
|
||||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>', maxZoom: 19 }).addTo(map);
|
|
||||||
mapInstanceRef.current = map;
|
|
||||||
updateMarkers(map);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateMarkers(mapObj) {
|
|
||||||
const L = window.L;
|
|
||||||
const map = mapObj || mapInstanceRef.current;
|
|
||||||
if (!L || !map) return;
|
|
||||||
markersRef.current.forEach((m) => m.remove());
|
|
||||||
markersRef.current = [];
|
|
||||||
localesFiltrados.forEach((local) => {
|
|
||||||
if (!local.lat || !local.lng) return;
|
|
||||||
const cat = obtenerCategoria(local.categoria);
|
|
||||||
const color = COLORES_MAPA[local.categoria] || cat.color;
|
|
||||||
const puntuacion = Math.min(5, Math.max(0, Math.round(Number(local.puntuacion) || 0)));
|
|
||||||
const stars = "★".repeat(puntuacion) + "☆".repeat(5 - puntuacion);
|
|
||||||
const labelDir = local.direccion ? ` · ${local.direccion.split(",")[0]}` : "";
|
|
||||||
const marker = L.marker([local.lat, local.lng], {
|
|
||||||
icon: L.divIcon({ className: "", html: `<div style="background:${color};color:white;border-radius:8px 8px 8px 0;padding:5px 10px;font-size:12px;font-weight:600;white-space:nowrap;box-shadow:0 2px 8px rgba(0,0,0,0.3);border:2px solid white;max-width:220px;overflow:hidden;text-overflow:ellipsis;">${cat.emoji} ${local.nombre}${labelDir}</div>`, iconAnchor: [0, 32] }),
|
|
||||||
}).addTo(map);
|
|
||||||
const gmLink = local.enlaceGoogleMaps || enlaceGoogleMapsDesdeLocal(local);
|
|
||||||
const wazeLink = enlaceWazeDesdeLocal(local);
|
|
||||||
const direccionHtml = local.direccion
|
|
||||||
? `<br><span style="font-size:12px;color:#444;margin-top:4px;display:block">📍 ${gmLink ? `<a href="${gmLink}" target="_blank" style="color:#444;text-decoration:underline">${local.direccion}</a>` : local.direccion}</span>`
|
|
||||||
: "";
|
|
||||||
const enlacesHtml = `${gmLink ? `<a href="${gmLink}" target="_blank" style="display:inline-block;margin-top:6px;margin-right:6px;font-size:12px;color:#1A73E8;font-weight:500;text-decoration:none;background:#E8F0FE;padding:3px 8px;border-radius:4px">Google Maps ↗</a>` : ""}${wazeLink ? `<a href="${wazeLink}" target="_blank" style="display:inline-block;margin-top:6px;font-size:12px;color:#0A9FD9;font-weight:500;text-decoration:none;background:#E6FAFF;padding:3px 8px;border-radius:4px">Waze ↗</a>` : ""}`;
|
|
||||||
marker.bindPopup(`<div style="font-family:sans-serif;min-width:200px;max-width:260px"><strong style="font-size:15px">${local.nombre}</strong><br><span style="color:#666;font-size:12px">${local.provincia}</span><br><span style="background:${cat.bgLight};color:${color};font-size:12px;padding:2px 8px;border-radius:4px;display:inline-block;margin:4px 0;font-weight:500">${cat.emoji} ${local.categoria}</span>${local.subcategoria ? `<span style="font-size:11px;color:#666;margin-left:4px">${local.subcategoria}</span>` : ""}${direccionHtml}<br>${enlacesHtml}<br><span style="color:#D4AF37;font-size:16px;display:block;margin-top:4px">${stars}</span>${local.descripcion ? `<em style="font-size:12px;color:#555">"${local.descripcion}"</em>` : ""}</div>`);
|
|
||||||
markersRef.current.push(marker);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const conteosCat = NOMBRES_CATEGORIAS.reduce((acc, c) => { acc[c] = locales.filter((l) => l.categoria === c).length; return acc; }, {});
|
|
||||||
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) : "—";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: ANCHO_CUERPO, margin: "0 auto", padding: "1.5rem 1rem", fontFamily: "var(--font-sans)" }}>
|
<div className="app">
|
||||||
{/* Header */}
|
<Sidebar
|
||||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: "1.5rem", gap: 12, flexWrap: "wrap" }}>
|
business={selected}
|
||||||
<div>
|
onContribute={() => setFormStep("picking")}
|
||||||
<h1 style={{ margin: 0, lineHeight: 0 }}>
|
onClose={() => setSelectedId(null)}
|
||||||
<img
|
picking={formStep === "picking"}
|
||||||
src="/logo-localesp.png"
|
onCancelPicking={handleCancel}
|
||||||
srcSet="/logo-localesp.png 1x, /logo-localesp@2x.png 2x"
|
onAdmin={() => setAdminOpen(true)}
|
||||||
alt="LocalESP · Locales Españoles"
|
|
||||||
width={220}
|
|
||||||
height={62}
|
|
||||||
style={{ display: "block", width: "clamp(140px, 35vw, 200px)", height: "auto" }}
|
|
||||||
/>
|
/>
|
||||||
</h1>
|
<MapView
|
||||||
<p style={{ margin: "6px 0 0", fontSize: 13, color: "var(--color-text-secondary)" }}>Directorio colaborativo de negocios por toda España</p>
|
businesses={businesses}
|
||||||
</div>
|
selectedId={selectedId}
|
||||||
</div>
|
onSelect={setSelectedId}
|
||||||
|
pickingLocation={formStep === "picking"}
|
||||||
{/* Confirmación enviada */}
|
onPickLocation={handlePickLocation}
|
||||||
{enviado && (
|
pickedLocation={pickedLocation}
|
||||||
<div role="status" style={{ background: "#EAFAF1", border: "1px solid #A9DFBF", borderRadius: "var(--border-radius-md)", padding: "12px 16px", marginBottom: "1rem", display: "flex", alignItems: "center", gap: 10 }}>
|
|
||||||
<span style={{ fontSize: 20 }} aria-hidden="true">✅</span>
|
|
||||||
<div>
|
|
||||||
<p style={{ margin: 0, fontWeight: 600, color: "#1E8449", fontSize: 14 }}>¡Propuesta enviada!</p>
|
|
||||||
<p style={{ margin: 0, fontSize: 13, color: "#1E8449" }}>El administrador revisará tu propuesta y la publicará si es correcta.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10, marginBottom: "1.25rem" }}>
|
|
||||||
{[{ label: "Locales", value: locales.length }, { label: "Provincias", value: provincias.length }, { label: "Media ★", value: mediaGlobal }].map((s) => (
|
|
||||||
<div key={s.label} style={{ background: "var(--color-background-secondary)", borderRadius: "var(--border-radius-md)", padding: "0.75rem 1rem", textAlign: "center" }}>
|
|
||||||
<p style={{ margin: 0, fontSize: 13, color: "var(--color-text-secondary)" }}>{s.label}</p>
|
|
||||||
<p style={{ margin: "4px 0 0", fontSize: 24, fontWeight: 500 }}>{s.value}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chips categorías */}
|
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginBottom: "1.25rem" }}>
|
|
||||||
{NOMBRES_CATEGORIAS.map((cat) => {
|
|
||||||
const c = CATEGORIAS[cat];
|
|
||||||
const activo = filtroCategoria === cat;
|
|
||||||
return (
|
|
||||||
<button key={cat} onClick={() => { setFiltroCategoria(activo ? "" : cat); setFiltroSubcat(""); }}
|
|
||||||
style={{ background: activo ? c.bgLight : "var(--color-background-secondary)", color: activo ? c.textColor : "var(--color-text-secondary)", border: `0.5px solid ${activo ? c.textColor : "var(--color-border-tertiary)"}`, borderRadius: 20, padding: "5px 12px", fontSize: 12, fontWeight: activo ? 500 : 400, cursor: "pointer", display: "flex", alignItems: "center", gap: 5, transition: "all 0.15s" }}
|
|
||||||
aria-pressed={activo}>
|
|
||||||
{c.emoji} {cat}
|
|
||||||
{conteosCat[cat] > 0 && <span style={{ background: activo ? c.textColor : "var(--color-border-secondary)", color: activo ? "white" : "var(--color-text-secondary)", borderRadius: 10, padding: "0 6px", fontSize: 11 }}>{conteosCat[cat]}</span>}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{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>
|
|
||||||
|
|
||||||
|
|
||||||
{/* 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 }}>
|
|
||||||
{[{ id: "lista", label: "Lista", icon: "ti-list" }, { id: "mapa", label: "Mapa", icon: "ti-map-2" }].map((v) => (
|
|
||||||
<button key={v.id} role="tab" aria-selected={vista === v.id} onClick={() => setVista(v.id)}
|
|
||||||
style={{ background: vista === v.id ? "#8B0000" : "none", color: vista === v.id ? "white" : "var(--color-text-secondary)", border: `0.5px solid ${vista === v.id ? "#8B0000" : "var(--color-border-tertiary)"}`, borderRadius: "var(--border-radius-md)", padding: "6px 14px", fontSize: 13, fontWeight: 500, cursor: "pointer", display: "flex", alignItems: "center", gap: 5 }}>
|
|
||||||
<i className={`ti ${v.icon}`} aria-hidden="true"></i> {v.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{vista === "lista" && (
|
|
||||||
<>
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr auto", gap: 8, marginBottom: 8 }}>
|
|
||||||
<input style={inputStyle} placeholder="🔍 Buscar por nombre, dirección..." value={busqueda} onChange={(e) => setBusqueda(e.target.value)} aria-label="Buscar por nombre, dirección o categoría" />
|
|
||||||
<select style={{ ...inputStyle, opacity: filtroCategoria ? 1 : 0.6 }} value={filtroSubcat} onChange={(e) => setFiltroSubcat(e.target.value)} disabled={!filtroCategoria} aria-label="Filtrar por tipo específico">
|
|
||||||
<option value="">{filtroCategoria ? "Todos los tipos" : "Elige categoría arriba"}</option>
|
|
||||||
{subcatsFiltro.map((s) => <option key={s} value={s}>{s}</option>)}
|
|
||||||
</select>
|
|
||||||
<select style={inputStyle} value={filtroProvincia} onChange={(e) => setFiltroProvincia(e.target.value)} aria-label="Filtrar por provincia">
|
|
||||||
<option value="">Todas las provincias</option>
|
|
||||||
{PROVINCIAS.map((p) => <option key={p} value={p}>{p}</option>)}
|
|
||||||
</select>
|
|
||||||
<select style={{ ...inputStyle, width: "auto" }} value={orden} onChange={(e) => setOrden(e.target.value)} aria-label="Ordenar locales">
|
|
||||||
<option value="fecha">Reciente</option>
|
|
||||||
<option value="puntuacion">★ Mejor</option>
|
|
||||||
<option value="nombre">A-Z</option>
|
|
||||||
{ubicacion && <option value="distancia">📍 Más cerca</option>}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap", marginBottom: "1rem" }}>
|
|
||||||
{!ubicacion ? (
|
|
||||||
<button onClick={pedirUbicacion} disabled={buscandoUbicacion}
|
|
||||||
style={{ display: "flex", alignItems: "center", gap: 5, background: "none", border: "0.5px solid var(--color-border-secondary)", borderRadius: 20, padding: "5px 12px", fontSize: 12, color: "var(--color-text-secondary)", cursor: "pointer" }}>
|
|
||||||
<i className="ti ti-current-location" aria-hidden="true"></i> {buscandoUbicacion ? "Localizando..." : "Cerca de mí"}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button onClick={() => { limpiarUbicacion(); if (orden === "distancia") setOrden("fecha"); }}
|
|
||||||
style={{ display: "flex", alignItems: "center", gap: 5, background: "#EAFAF1", border: "0.5px solid #A9DFBF", borderRadius: 20, padding: "5px 12px", fontSize: 12, color: "#1E8449", cursor: "pointer" }}>
|
|
||||||
<i className="ti ti-map-pin-check" aria-hidden="true"></i> Ubicación activada ✕
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{errorUbicacion && <span style={{ fontSize: 12, color: "#922B21" }}>{errorUbicacion}</span>}
|
|
||||||
<button onClick={() => setSoloFavoritos((v) => !v)} aria-pressed={soloFavoritos}
|
|
||||||
style={{ display: "flex", alignItems: "center", gap: 5, background: soloFavoritos ? "#FDEDEC" : "none", border: `0.5px solid ${soloFavoritos ? "#8B0000" : "var(--color-border-secondary)"}`, borderRadius: 20, padding: "5px 12px", fontSize: 12, color: soloFavoritos ? "#8B0000" : "var(--color-text-secondary)", cursor: "pointer" }}>
|
|
||||||
<i className={soloFavoritos ? "ti ti-heart-filled" : "ti ti-heart"} aria-hidden="true"></i> Favoritos {favoritos.length > 0 && `(${favoritos.length})`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div style={{ textAlign: "center", padding: "3rem", color: "var(--color-text-tertiary)", fontSize: 14 }}>
|
|
||||||
<i className="ti ti-loader" style={{ fontSize: 24, display: "block", marginBottom: 8 }} aria-hidden="true"></i>
|
|
||||||
Cargando locales...
|
|
||||||
</div>
|
|
||||||
) : localesFiltrados.length === 0 ? (
|
|
||||||
<div style={{ textAlign: "center", padding: "3rem", color: "var(--color-text-tertiary)" }}>
|
|
||||||
<div style={{ fontSize: 40, marginBottom: 12 }} aria-hidden="true">🏘️</div>
|
|
||||||
<p style={{ margin: 0, fontWeight: 500 }}>
|
|
||||||
{locales.length === 0 ? "Todavía no hay locales publicados" : soloFavoritos ? "Aún no tienes favoritos guardados" : "No hay locales que coincidan"}
|
|
||||||
</p>
|
|
||||||
<p style={{ margin: "6px 0 0", fontSize: 13 }}>{soloFavoritos ? "Pulsa el corazón ♥ en un local para guardarlo aquí" : "Sé el primero en proponer uno — el administrador lo revisará y publicará"}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: "grid", gap: 10 }}>
|
|
||||||
{localesFiltrados.map((local) => (
|
|
||||||
<div key={local.id} style={localDestacado === local.id ? { outline: "2px solid #8B0000", borderRadius: "var(--border-radius-lg)" } : undefined}>
|
|
||||||
<LocalCard
|
|
||||||
local={local}
|
|
||||||
distanciaKm={ubicacion && local.lat && local.lng ? calcularDistanciaKm(ubicacion.lat, ubicacion.lng, local.lat, local.lng) : null}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
{formStep === "editing" && (
|
||||||
))}
|
<SubmitForm
|
||||||
{localesFiltrados.length < locales.length && (
|
pickedLocation={pickedLocation}
|
||||||
<p style={{ textAlign: "center", fontSize: 12, color: "var(--color-text-tertiary)", margin: "4px 0" }}>
|
onSubmit={handleSubmit}
|
||||||
Mostrando {localesFiltrados.length} de {locales.length} locales
|
onCancel={handleCancel}
|
||||||
</p>
|
onRepick={() => setFormStep("picking")}
|
||||||
)}
|
/>
|
||||||
</div>
|
)}
|
||||||
)}
|
{adminOpen && (
|
||||||
</>
|
<AdminPanel
|
||||||
)}
|
onClose={() => setAdminOpen(false)}
|
||||||
|
onApproved={loadBusinesses}
|
||||||
{/* El contenedor del mapa se mantiene siempre montado (solo se oculta con
|
|
||||||
CSS) para que Leaflet no pierda su instancia al cambiar de pestaña. */}
|
|
||||||
<div style={{ display: vista === "mapa" ? "block" : "none", borderRadius: "var(--border-radius-lg)", overflow: "hidden", border: "0.5px solid var(--color-border-tertiary)" }}>
|
|
||||||
<div style={{ padding: "8px 12px", background: "var(--color-background-secondary)", borderBottom: "0.5px solid var(--color-border-tertiary)", display: "flex", flexWrap: "wrap", gap: 10 }}>
|
|
||||||
{NOMBRES_CATEGORIAS.map((cat) => (
|
|
||||||
<span key={cat} style={{ fontSize: 11, display: "flex", alignItems: "center", gap: 4, color: "var(--color-text-secondary)" }}>
|
|
||||||
<span style={{ width: 10, height: 10, borderRadius: 2, background: COLORES_MAPA[cat], display: "inline-block", flexShrink: 0 }} aria-hidden="true"></span>
|
|
||||||
{CATEGORIAS[cat].emoji} {cat}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{locales.length === 0 && <div style={{ textAlign: "center", padding: "1rem", background: "var(--color-background-secondary)", fontSize: 13, color: "var(--color-text-secondary)" }}>Todavía no hay locales publicados</div>}
|
|
||||||
{locales.length > 0 && localesFiltrados.length === 0 && <div style={{ textAlign: "center", padding: "1rem", background: "var(--color-background-secondary)", fontSize: 13, color: "var(--color-text-secondary)" }}>Ningún local coincide con el filtro seleccionado</div>}
|
|
||||||
<div ref={mapRef} style={{ height: 440, width: "100%" }} role="img" aria-label="Mapa de España con los locales publicados"></div>
|
|
||||||
<div style={{ padding: "8px 12px", background: "var(--color-background-secondary)", borderTop: "0.5px solid var(--color-border-tertiary)", display: "flex", gap: 12, alignItems: "center" }}>
|
|
||||||
<span style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
|
|
||||||
<i className="ti ti-map-pin" aria-hidden="true" style={{ marginRight: 4 }}></i>
|
|
||||||
{localesFiltrados.length} {localesFiltrados.length === 1 ? "local marcado" : "locales marcados"}
|
|
||||||
{localesFiltrados.length !== locales.length && ` de ${locales.length}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</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 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>
|
|
||||||
|
|
||||||
{/* Botones de propuesta (ambos abren la misma pasarela) y modal */}
|
|
||||||
<BotonFlotante onClick={abrirPasarela} />
|
|
||||||
<BotonProponerArriba 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 />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,383 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import { suscribirLocales, enviarPropuesta, obtenerPalabrasFiltradas, contienepalabrasProhibidas } from "./firebase.js";
|
|
||||||
|
|
||||||
const CATEGORIAS = {
|
|
||||||
"Restauración": { emoji:"🍽️", color:"#8B0000", bg:"#FFF0F0", text:"#8B0000", subcategorias:["Tapas y raciones","Paella y arroces","Pintxos","Asador / Carne a la brasa","Mariscos y pescados","Bocadillos y montaditos","Menú del día","Cocina vasca","Cocina catalana","Cocina andaluza","Cocina gallega","Cocina madrileña","Cocina mediterránea","Pizzería","Hamburguesería","Comida rápida","Cocina internacional","Cafetería / Desayunos","Heladería","Pastelería"] },
|
|
||||||
"Pequeño comercio": { emoji:"🛍️", color:"#1A5276", bg:"#EBF5FB", text:"#1A5276", subcategorias:["Alimentación / Ultramarinos","Frutería","Carnicería","Pescadería","Panadería","Farmacia","Papelería / Librería","Floristería","Joyería / Relojería","Zapatería","Ropa y moda","Juguetería","Ferretería","Bazar / Todo a 100","Estanco","Quiosco","Óptica","Ortopedia","Tienda de mascotas","Electrodomésticos"] },
|
|
||||||
"Peluquería y estética": { emoji:"✂️", color:"#76448A", bg:"#F5EEF8", text:"#76448A", subcategorias:["Peluquería señora","Peluquería caballero","Peluquería unisex","Barbería","Centro de estética","Uñas / Manicura","Depilación","Masajes y spa","Tatuajes y piercings","Centro de bronceado","Micropigmentación"] },
|
|
||||||
"Servicios del hogar": { emoji:"🔧", color:"#1E8449", bg:"#EAFAF1", text:"#1E8449", subcategorias:["Cerrajería","Fontanería / Plomería","Electricidad","Reformas y construcción","Pintura","Carpintería","Cristalería","Climatización / Aire acondicionado","Mudanzas","Limpieza","Jardinería","Instalación solar","Alarmas y seguridad","Reparación electrodomésticos"] },
|
|
||||||
"Otros": { emoji:"📌", color:"#784212", bg:"#FDF2E9", text:"#784212", subcategorias:["Taller mecánico","Lavado de coches","Academia / Clases","Gestoría / Asesoría","Inmobiliaria","Agencia de viajes","Fotografía","Informática / Reparación móviles","Copistería / Imprenta","Veterinaria","Gimnasio / Fitness","Centro médico / Clínica","Fisioterapia","Psicología","Lavandería","Tintorería","Otro"] },
|
|
||||||
};
|
|
||||||
const CATS = Object.keys(CATEGORIAS);
|
|
||||||
|
|
||||||
const PROVINCIAS = ["Álava","Albacete","Alicante","Almería","Asturias","Ávila","Badajoz","Barcelona","Burgos","Cáceres","Cádiz","Cantabria","Castellón","Ciudad Real","Córdoba","Cuenca","Gerona","Granada","Guadalajara","Guipúzcoa","Huelva","Huesca","Islas Baleares","Jaén","La Coruña","La Rioja","Las Palmas","León","Lérida","Lugo","Madrid","Málaga","Murcia","Navarra","Orense","Palencia","Pontevedra","Salamanca","Santa Cruz de Tenerife","Segovia","Sevilla","Soria","Tarragona","Teruel","Toledo","Valencia","Valladolid","Vizcaya","Zamora","Zaragoza"];
|
|
||||||
|
|
||||||
const COORDS = {"Álava":[42.85,-2.67],"Albacete":[38.99,-1.86],"Alicante":[38.35,-0.48],"Almería":[36.83,-2.46],"Asturias":[43.36,-5.86],"Ávila":[40.66,-4.68],"Badajoz":[38.88,-6.97],"Barcelona":[41.39,2.17],"Burgos":[42.34,-3.70],"Cáceres":[39.48,-6.37],"Cádiz":[36.53,-6.29],"Cantabria":[43.46,-3.81],"Castellón":[39.99,-0.05],"Ciudad Real":[38.98,-3.93],"Córdoba":[37.89,-4.78],"Cuenca":[40.07,-2.14],"Gerona":[41.98,2.82],"Granada":[37.18,-3.60],"Guadalajara":[40.63,-3.16],"Guipúzcoa":[43.32,-1.98],"Huelva":[37.26,-6.94],"Huesca":[42.14,-0.41],"Islas Baleares":[39.57,2.65],"Jaén":[37.78,-3.78],"La Coruña":[43.36,-8.41],"La Rioja":[42.46,-2.44],"Las Palmas":[28.12,-15.44],"León":[42.60,-5.57],"Lérida":[41.62,0.62],"Lugo":[43.01,-7.56],"Madrid":[40.42,-3.70],"Málaga":[36.72,-4.42],"Murcia":[37.99,-1.13],"Navarra":[42.82,-1.64],"Orense":[42.34,-7.86],"Palencia":[42.01,-4.53],"Pontevedra":[42.43,-8.65],"Salamanca":[40.97,-5.66],"Santa Cruz de Tenerife":[28.46,-16.25],"Segovia":[40.94,-4.11],"Sevilla":[37.39,-5.98],"Soria":[41.76,-2.46],"Tarragona":[41.12,1.24],"Teruel":[40.34,-1.11],"Toledo":[39.86,-4.03],"Valencia":[39.47,-0.38],"Valladolid":[41.65,-4.72],"Vizcaya":[43.26,-2.94],"Zamora":[41.50,-5.74],"Zaragoza":[41.65,-0.89]};
|
|
||||||
|
|
||||||
async function geocodificar(dir) {
|
|
||||||
try {
|
|
||||||
const q = encodeURIComponent(dir + ", España");
|
|
||||||
const r = await fetch(`https://nominatim.openstreetmap.org/search?q=${q}&format=json&limit=1&countrycodes=es`, { headers:{"Accept-Language":"es","User-Agent":"LocalesMovil/1.0"} });
|
|
||||||
const d = await r.json();
|
|
||||||
if (d?.length) return { lat: parseFloat(d[0].lat), lng: parseFloat(d[0].lon) };
|
|
||||||
} catch {}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsearGoogleMaps(url) {
|
|
||||||
try {
|
|
||||||
let m = url.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
|
|
||||||
if (m) return { lat: parseFloat(m[1]), lng: parseFloat(m[2]) };
|
|
||||||
m = url.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/);
|
|
||||||
if (m) return { lat: parseFloat(m[1]), lng: parseFloat(m[2]) };
|
|
||||||
} catch {}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Estilos base móvil ───────────────────────────────────────────────────────
|
|
||||||
const S = {
|
|
||||||
screen: { minHeight:"100dvh", display:"flex", flexDirection:"column", background:"#F8F5F0", fontFamily:"'Georgia', serif" },
|
|
||||||
header: { background:"#8B0000", color:"white", padding:"14px 16px 10px", position:"sticky", top:0, zIndex:10 },
|
|
||||||
headerTitle: { margin:0, fontSize:20, fontWeight:700, letterSpacing:"-0.3px" },
|
|
||||||
headerSub: { margin:"2px 0 0", fontSize:12, opacity:0.75 },
|
|
||||||
body: { flex:1, overflowY:"auto", padding:"12px 14px 80px" },
|
|
||||||
navBar: { position:"fixed", bottom:0, left:0, right:0, background:"white", borderTop:"1px solid #E5DDD5", display:"flex", zIndex:10, padding:"4px 0" },
|
|
||||||
navBtn: (active) => ({ flex:1, display:"flex", flexDirection:"column", alignItems:"center", gap:3, padding:"6px 0", background:"none", border:"none", cursor:"pointer", color: active ? "#8B0000" : "#999", fontSize:10, fontWeight: active ? 700 : 400 }),
|
|
||||||
navIcon: (active) => ({ fontSize:22, lineHeight:1, color: active ? "#8B0000" : "#999" }),
|
|
||||||
card: { background:"white", borderRadius:12, padding:"14px 16px", marginBottom:10, border:"1px solid #EEE8E0" },
|
|
||||||
badge: (cat) => { const c = CATEGORIAS[cat]; return { background: c?.bg||"#eee", color: c?.text||"#555", fontSize:11, padding:"3px 9px", borderRadius:20, fontWeight:500, display:"inline-block" }; },
|
|
||||||
input: { width:"100%", boxSizing:"border-box", padding:"11px 13px", borderRadius:10, border:"1px solid #DDD7CF", background:"white", fontSize:15, outline:"none", fontFamily:"inherit" },
|
|
||||||
label: { fontSize:12, color:"#888", display:"block", marginBottom:5, fontWeight:600, textTransform:"uppercase", letterSpacing:"0.4px" },
|
|
||||||
btn: (primary) => ({ width:"100%", padding:"13px", borderRadius:10, border:"none", cursor:"pointer", fontSize:15, fontWeight:700, background: primary ? "#8B0000" : "#F0EBE5", color: primary ? "white" : "#666", fontFamily:"inherit" }),
|
|
||||||
stars: (n) => "★".repeat(n)+"☆".repeat(5-n),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Pantalla: Lista ──────────────────────────────────────────────────────────
|
|
||||||
function PantallaLista({ locales, onDelete, onBuscar, busqueda, filtroCat, setFiltroCat }) {
|
|
||||||
const filtrados = locales.filter(l => {
|
|
||||||
const q = busqueda.toLowerCase();
|
|
||||||
return (!q || l.nombre.toLowerCase().includes(q) || (l.direccion||"").toLowerCase().includes(q) || l.provincia.toLowerCase().includes(q))
|
|
||||||
&& (!filtroCat || l.categoria === filtroCat);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div style={{ marginBottom:12 }}>
|
|
||||||
<input style={S.input} placeholder="🔍 Buscar nombre, ciudad..." value={busqueda} onChange={e => onBuscar(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chips categoría */}
|
|
||||||
<div style={{ display:"flex", gap:6, overflowX:"auto", paddingBottom:8, marginBottom:10, scrollbarWidth:"none" }}>
|
|
||||||
<button onClick={() => setFiltroCat("")} style={{ ...S.badge("Restauración"), background: !filtroCat?"#8B0000":"#EEE8E0", color: !filtroCat?"white":"#777", flexShrink:0, cursor:"pointer", border:"none", fontFamily:"inherit" }}>Todos</button>
|
|
||||||
{CATS.map(c => (
|
|
||||||
<button key={c} onClick={() => setFiltroCat(filtroCat===c?"":c)}
|
|
||||||
style={{ ...S.badge(c), flexShrink:0, cursor:"pointer", border: filtroCat===c?"1.5px solid "+CATEGORIAS[c].color:"1px solid transparent", fontFamily:"inherit" }}>
|
|
||||||
{CATEGORIAS[c].emoji} {c}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filtrados.length === 0 ? (
|
|
||||||
<div style={{ textAlign:"center", padding:"3rem 1rem", color:"#AAA" }}>
|
|
||||||
<div style={{ fontSize:48, marginBottom:12 }}>🏘️</div>
|
|
||||||
<p style={{ margin:0, fontSize:15, color:"#888" }}>{locales.length===0 ? "¡Sé el primero en añadir un local!" : "Sin resultados"}</p>
|
|
||||||
</div>
|
|
||||||
) : filtrados.map(local => (
|
|
||||||
<div key={local.id} style={S.card}>
|
|
||||||
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"flex-start", marginBottom:8 }}>
|
|
||||||
<div style={{ flex:1 }}>
|
|
||||||
<p style={{ margin:0, fontWeight:700, fontSize:16, color:"#1A1A1A" }}>{local.nombre}</p>
|
|
||||||
<p style={{ margin:"2px 0 0", fontSize:13, color:"#888" }}>📍 {local.provincia}</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => onDelete(local.id)} style={{ background:"none", border:"none", color:"#CCC", fontSize:18, cursor:"pointer", padding:"0 0 0 8px" }}>✕</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ display:"flex", flexWrap:"wrap", gap:5, marginBottom:8 }}>
|
|
||||||
<span style={S.badge(local.categoria)}>{CATEGORIAS[local.categoria]?.emoji} {local.categoria}</span>
|
|
||||||
{local.subcategoria && <span style={{ fontSize:11, color:"#888", padding:"3px 8px", background:"#F5F5F5", borderRadius:20 }}>{local.subcategoria}</span>}
|
|
||||||
</div>
|
|
||||||
{local.direccion && <p style={{ margin:"0 0 6px", fontSize:13, color:"#666" }}>{local.direccion}</p>}
|
|
||||||
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"center" }}>
|
|
||||||
<span style={{ color:"#D4AF37", fontSize:18, letterSpacing:1 }}>{S.stars(local.puntuacion)}</span>
|
|
||||||
{(local.direccion || local.enlaceGoogleMaps) && (
|
|
||||||
<a href={local.enlaceGoogleMaps || `https://www.google.com/maps/search/${encodeURIComponent(local.nombre+" "+local.direccion)}`}
|
|
||||||
target="_blank" rel="noopener noreferrer"
|
|
||||||
style={{ fontSize:12, color:"#1A73E8", textDecoration:"none", background:"#E8F0FE", padding:"4px 10px", borderRadius:8, fontWeight:600 }}>
|
|
||||||
Maps ↗
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{local.descripcion && <p style={{ margin:"8px 0 0", fontSize:13, color:"#666", fontStyle:"italic" }}>"{local.descripcion}"</p>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Pantalla: Añadir ─────────────────────────────────────────────────────────
|
|
||||||
function PantallaAnadir({ onGuardar }) {
|
|
||||||
const [form, setForm] = useState({ nombre:"", provincia:"", categoria:"", subcategoria:"", direccion:"", enlaceGoogleMaps:"", puntuacion:0, descripcion:"" });
|
|
||||||
const [modoUbic, setModoUbic] = useState("direccion");
|
|
||||||
const [geocodif, setGeocodif] = useState(null);
|
|
||||||
const [guardando, setGuardando] = useState(false);
|
|
||||||
const [ok, setOk] = useState(false);
|
|
||||||
|
|
||||||
const valido = form.nombre && form.provincia && form.categoria && form.puntuacion > 0 && (form.direccion || form.enlaceGoogleMaps);
|
|
||||||
|
|
||||||
const guardar = async () => {
|
|
||||||
if (!valido) return;
|
|
||||||
setGuardando(true);
|
|
||||||
let lat, lng;
|
|
||||||
if (modoUbic === "direccion" && form.direccion) {
|
|
||||||
const geo = await geocodificar(form.direccion);
|
|
||||||
if (geo) { lat = geo.lat; lng = geo.lng; }
|
|
||||||
} else if (form.enlaceGoogleMaps) {
|
|
||||||
const c = parsearGoogleMaps(form.enlaceGoogleMaps);
|
|
||||||
if (c) { lat = c.lat; lng = c.lng; }
|
|
||||||
}
|
|
||||||
if (!lat) {
|
|
||||||
const fb = COORDS[form.provincia] || [40.4,-3.7];
|
|
||||||
lat = fb[0] + (Math.random()-0.5)*0.04;
|
|
||||||
lng = fb[1] + (Math.random()-0.5)*0.04;
|
|
||||||
}
|
|
||||||
await onGuardar({ ...form, lat, lng, id: Date.now().toString(), fecha: new Date().toISOString() });
|
|
||||||
setOk(true);
|
|
||||||
setGuardando(false);
|
|
||||||
setTimeout(() => {
|
|
||||||
setOk(false);
|
|
||||||
setForm({ nombre:"", provincia:"", categoria:"", subcategoria:"", direccion:"", enlaceGoogleMaps:"", puntuacion:0, descripcion:"" });
|
|
||||||
setGeocodif(null);
|
|
||||||
}, 1500);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (ok) return (
|
|
||||||
<div style={{ display:"flex", flexDirection:"column", alignItems:"center", justifyContent:"center", minHeight:"60vh", gap:16 }}>
|
|
||||||
<div style={{ fontSize:64 }}>✅</div>
|
|
||||||
<p style={{ fontSize:18, fontWeight:700, color:"#1E8449", margin:0 }}>¡Local añadido!</p>
|
|
||||||
<p style={{ fontSize:14, color:"#888", margin:0 }}>Sincronizando con la web...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ paddingBottom:20 }}>
|
|
||||||
<div style={{ marginBottom:16 }}>
|
|
||||||
<label style={S.label}>Nombre del local *</label>
|
|
||||||
<input style={S.input} placeholder="Bar El Olivo, Clínica San José..." value={form.nombre} onChange={e => setForm(f=>({...f,nombre:e.target.value}))} />
|
|
||||||
</div>
|
|
||||||
<div style={{ marginBottom:16 }}>
|
|
||||||
<label style={S.label}>Provincia *</label>
|
|
||||||
<select style={S.input} value={form.provincia} onChange={e => setForm(f=>({...f,provincia:e.target.value}))}>
|
|
||||||
<option value="">Selecciona...</option>
|
|
||||||
{PROVINCIAS.map(p => <option key={p} value={p}>{p}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginBottom:16 }}>
|
|
||||||
<label style={S.label}>Categoría *</label>
|
|
||||||
<select style={S.input} value={form.categoria} onChange={e => setForm(f=>({...f,categoria:e.target.value,subcategoria:""}))}>
|
|
||||||
<option value="">Selecciona...</option>
|
|
||||||
{CATS.map(c => <option key={c} value={c}>{CATEGORIAS[c].emoji} {c}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{form.categoria && (
|
|
||||||
<div style={{ marginBottom:16 }}>
|
|
||||||
<label style={S.label}>Tipo específico</label>
|
|
||||||
<select style={S.input} value={form.subcategoria} onChange={e => setForm(f=>({...f,subcategoria:e.target.value}))}>
|
|
||||||
<option value="">Selecciona...</option>
|
|
||||||
{CATEGORIAS[form.categoria].subcategorias.map(s => <option key={s} value={s}>{s}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Ubicación */}
|
|
||||||
<div style={{ marginBottom:16 }}>
|
|
||||||
<label style={S.label}>Ubicación *</label>
|
|
||||||
<div style={{ display:"flex", border:"1px solid #DDD7CF", borderRadius:10, overflow:"hidden", marginBottom:8 }}>
|
|
||||||
{[{id:"direccion",label:"📍 Dirección"},{id:"enlace",label:"🔗 Google Maps"}].map(opt => (
|
|
||||||
<button key={opt.id} onClick={() => { setModoUbic(opt.id); setForm(f=>({...f,direccion:"",enlaceGoogleMaps:""})); setGeocodif(null); }}
|
|
||||||
style={{ flex:1, padding:"10px 6px", border:"none", background: modoUbic===opt.id?"#F0EBE5":"white", fontSize:13, cursor:"pointer", fontWeight: modoUbic===opt.id?700:400, color: modoUbic===opt.id?"#8B0000":"#666", fontFamily:"inherit" }}>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{modoUbic === "direccion" ? (
|
|
||||||
<input style={S.input} placeholder="Calle Mayor 5, Madrid..." value={form.direccion}
|
|
||||||
onChange={e => { setForm(f=>({...f,direccion:e.target.value})); setGeocodif(null); }} />
|
|
||||||
) : (
|
|
||||||
<input style={S.input} placeholder="Pega el enlace de Google Maps..." value={form.enlaceGoogleMaps}
|
|
||||||
onChange={e => {
|
|
||||||
const val = e.target.value;
|
|
||||||
setForm(f=>({...f,enlaceGoogleMaps:val}));
|
|
||||||
const c = parsearGoogleMaps(val);
|
|
||||||
setGeocodif(c ? "✅ Coordenadas detectadas" : val ? "⚠️ No se detectaron coordenadas" : null);
|
|
||||||
}} />
|
|
||||||
)}
|
|
||||||
{geocodif && <p style={{ fontSize:12, color:geocodif.startsWith("✅")?"#1E8449":"#784212", margin:"6px 0 0" }}>{geocodif}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Puntuación */}
|
|
||||||
<div style={{ marginBottom:16 }}>
|
|
||||||
<label style={S.label}>Puntuación *</label>
|
|
||||||
<div style={{ display:"flex", gap:8 }}>
|
|
||||||
{[1,2,3,4,5].map(n => (
|
|
||||||
<button key={n} onClick={() => setForm(f=>({...f,puntuacion:n}))}
|
|
||||||
style={{ fontSize:32, background:"none", border:"none", cursor:"pointer", color: n<=form.puntuacion?"#D4AF37":"#DDD", padding:0 }}>★</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom:24 }}>
|
|
||||||
<label style={S.label}>Descripción (opcional)</label>
|
|
||||||
<textarea style={{ ...S.input, minHeight:80, resize:"vertical" }} placeholder="Horarios, especialidades, qué lo hace especial..."
|
|
||||||
value={form.descripcion} onChange={e => setForm(f=>({...f,descripcion:e.target.value}))} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onClick={guardar} disabled={!valido||guardando} style={{ ...S.btn(true), opacity: valido?1:0.4 }}>
|
|
||||||
{guardando ? "Enviando..." : "📨 Enviar propuesta"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Pantalla: Estadísticas ───────────────────────────────────────────────────
|
|
||||||
function PantallaStats({ locales }) {
|
|
||||||
const media = locales.length ? (locales.reduce((s,l)=>s+l.puntuacion,0)/locales.length).toFixed(1) : "—";
|
|
||||||
const provincias = [...new Set(locales.map(l=>l.provincia))].length;
|
|
||||||
const porCat = CATS.map(c => ({ cat:c, n: locales.filter(l=>l.categoria===c).length })).filter(x=>x.n>0).sort((a,b)=>b.n-a.n);
|
|
||||||
const top5 = [...locales].sort((a,b)=>b.puntuacion-a.puntuacion).slice(0,5);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div style={{ display:"grid", gridTemplateColumns:"1fr 1fr", gap:10, marginBottom:14 }}>
|
|
||||||
{[{v:locales.length,l:"Locales"},{v:provincias,l:"Provincias"},{v:media,l:"Media ★"},{v:porCat.length,l:"Categorías"}].map(s=>(
|
|
||||||
<div key={s.l} style={{ background:"white", borderRadius:12, padding:"14px 16px", border:"1px solid #EEE8E0", textAlign:"center" }}>
|
|
||||||
<p style={{ margin:0, fontSize:11, color:"#AAA", textTransform:"uppercase", letterSpacing:"0.5px", fontWeight:600 }}>{s.l}</p>
|
|
||||||
<p style={{ margin:"6px 0 0", fontSize:28, fontWeight:700, color:"#1A1A1A" }}>{s.v}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{porCat.length > 0 && (
|
|
||||||
<div style={S.card}>
|
|
||||||
<p style={{ margin:"0 0 12px", fontWeight:700, fontSize:14, color:"#555", textTransform:"uppercase", letterSpacing:"0.4px" }}>Por categoría</p>
|
|
||||||
{porCat.map(({cat,n}) => {
|
|
||||||
const pct = Math.round((n/locales.length)*100);
|
|
||||||
return (
|
|
||||||
<div key={cat} style={{ marginBottom:10 }}>
|
|
||||||
<div style={{ display:"flex", justifyContent:"space-between", marginBottom:4 }}>
|
|
||||||
<span style={{ fontSize:14 }}>{CATEGORIAS[cat].emoji} {cat}</span>
|
|
||||||
<span style={{ fontSize:13, fontWeight:700, color:CATEGORIAS[cat].color }}>{n}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ background:"#F0EBE5", borderRadius:4, height:6, overflow:"hidden" }}>
|
|
||||||
<div style={{ height:"100%", background:CATEGORIAS[cat].color, width:pct+"%", borderRadius:4 }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{top5.length > 0 && (
|
|
||||||
<div style={S.card}>
|
|
||||||
<p style={{ margin:"0 0 12px", fontWeight:700, fontSize:14, color:"#555", textTransform:"uppercase", letterSpacing:"0.4px" }}>Top valorados</p>
|
|
||||||
{top5.map((l,i) => (
|
|
||||||
<div key={l.id} style={{ display:"flex", alignItems:"center", gap:10, marginBottom:10 }}>
|
|
||||||
<span style={{ fontSize:18, fontWeight:700, color:"#D4AF37", minWidth:24 }}>#{i+1}</span>
|
|
||||||
<div style={{ flex:1 }}>
|
|
||||||
<p style={{ margin:0, fontSize:14, fontWeight:600 }}>{l.nombre}</p>
|
|
||||||
<p style={{ margin:0, fontSize:12, color:"#888" }}>{l.provincia}</p>
|
|
||||||
</div>
|
|
||||||
<span style={{ color:"#D4AF37", fontSize:14 }}>{"★".repeat(l.puntuacion)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p style={{ textAlign:"center", fontSize:12, color:"#BBB", marginTop:16 }}>
|
|
||||||
Datos sincronizados con la versión web
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── App principal ────────────────────────────────────────────────────────────
|
|
||||||
export default function AppMovil() {
|
|
||||||
const [tab, setTab] = useState("lista");
|
|
||||||
const [locales, setLocales] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [busqueda, setBusqueda] = useState("");
|
|
||||||
const [filtroCat, setFiltroCat] = useState("");
|
|
||||||
|
|
||||||
const [palabrasProhibidas, setPalabrasProhibidas] = useState([]);
|
|
||||||
|
|
||||||
const cargar = useCallback(() => {}, []); // Firestore ya escucha en tiempo real
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLoading(true);
|
|
||||||
const unsub = suscribirLocales(
|
|
||||||
(lista) => { setLocales(lista); setLoading(false); },
|
|
||||||
() => { setLocales([]); setLoading(false); }
|
|
||||||
);
|
|
||||||
return () => unsub();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { obtenerPalabrasFiltradas().then(p => setPalabrasProhibidas(p)); }, []);
|
|
||||||
|
|
||||||
const addLocal = async (local) => {
|
|
||||||
// Comprobar filtro de palabras
|
|
||||||
const texto = [local.nombre, local.descripcion||"", local.direccion||""].join(" ");
|
|
||||||
if (contienepalabrasProhibidas(texto, palabrasProhibidas)) {
|
|
||||||
alert("Tu propuesta contiene palabras no permitidas. Por favor, revísala.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try { await enviarPropuesta(local); } catch(e) { console.error(e); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const delLocal = null; // Solo el admin puede eliminar locales
|
|
||||||
|
|
||||||
const TABS = [
|
|
||||||
{ id:"lista", icon:"🗂️", label:"Lista" },
|
|
||||||
{ id:"anadir", icon:"➕", label:"Añadir" },
|
|
||||||
{ id:"stats", icon:"📊", label:"Stats" },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={S.screen}>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={S.header}>
|
|
||||||
<div style={{ display:"flex", alignItems:"center", gap:10 }}>
|
|
||||||
<span style={{ fontSize:26 }}>🇪🇸</span>
|
|
||||||
<div>
|
|
||||||
<h1 style={S.headerTitle}>Locales Españoles</h1>
|
|
||||||
<p style={S.headerSub}>{loading ? "Cargando..." : `${locales.length} ${locales.length===1?"local":"locales"} · sincronizado`}</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={cargar} style={{ marginLeft:"auto", background:"rgba(255,255,255,0.2)", border:"none", borderRadius:8, padding:"6px 10px", color:"white", cursor:"pointer", fontSize:18 }}>↻</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contenido */}
|
|
||||||
<div style={S.body}>
|
|
||||||
{loading ? (
|
|
||||||
<div style={{ textAlign:"center", padding:"4rem 1rem", color:"#AAA" }}>
|
|
||||||
<p style={{ fontSize:32, margin:"0 0 12px" }}>⏳</p>
|
|
||||||
<p style={{ margin:0, fontSize:15 }}>Sincronizando con la web...</p>
|
|
||||||
</div>
|
|
||||||
) : tab === "lista" ? (
|
|
||||||
<PantallaLista locales={locales} onDelete={null} onBuscar={setBusqueda} busqueda={busqueda} filtroCat={filtroCat} setFiltroCat={setFiltroCat} />
|
|
||||||
) : tab === "anadir" ? (
|
|
||||||
<PantallaAnadir onGuardar={async (local) => { await addLocal(local); setTab("lista"); }} />
|
|
||||||
) : (
|
|
||||||
<PantallaStats locales={locales} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Nav bar inferior */}
|
|
||||||
<nav style={S.navBar}>
|
|
||||||
{TABS.map(t => (
|
|
||||||
<button key={t.id} onClick={() => setTab(t.id)} style={S.navBtn(tab===t.id)}>
|
|
||||||
<span style={S.navIcon(tab===t.id)}>{t.icon}</span>
|
|
||||||
{t.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
// Local SQLite API instead of Firebase
|
|
||||||
|
|
||||||
const API_URL = "/api";
|
|
||||||
|
|
||||||
export async function suscribirLocales(onData, onError) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_URL}/locales`);
|
|
||||||
const data = await response.json();
|
|
||||||
onData(data);
|
|
||||||
// Simulate real-time by polling every 5 seconds
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/locales`);
|
|
||||||
const newData = await res.json();
|
|
||||||
onData(newData);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
} catch (err) {
|
|
||||||
onError(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function suscribirPendientes(onData, onError) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_URL}/pendientes`);
|
|
||||||
const data = await response.json();
|
|
||||||
onData(data);
|
|
||||||
// Simulate real-time by polling every 5 seconds
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/pendientes`);
|
|
||||||
const newData = await res.json();
|
|
||||||
onData(newData);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
} catch (err) {
|
|
||||||
onError(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function enviarPropuesta(local) {
|
|
||||||
const { id: _x, ...datos } = local;
|
|
||||||
const response = await fetch(`${API_URL}/propuestas`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(datos),
|
|
||||||
});
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function comprobarDuplicado({ nombre, provincia, lat, lng }) {
|
|
||||||
const response = await fetch(`${API_URL}/check-duplicado`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ nombre, provincia, lat, lng }),
|
|
||||||
});
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function aprobarPropuesta(p) {
|
|
||||||
const response = await fetch(`${API_URL}/aprobar/${p.id}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function rechazarPropuesta(id) {
|
|
||||||
const response = await fetch(`${API_URL}/rechazar/${id}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteLocal(id) {
|
|
||||||
const response = await fetch(`${API_URL}/locales/${id}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function confirmarLocal(id) {
|
|
||||||
const response = await fetch(`${API_URL}/locales/${id}/confirmar`, { method: "POST" });
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reportarLocal(id, motivo, comentario) {
|
|
||||||
const response = await fetch(`${API_URL}/locales/${id}/reportar`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ motivo, comentario }),
|
|
||||||
});
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function obtenerReportes() {
|
|
||||||
const response = await fetch(`${API_URL}/reportes`);
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function descartarReporte(id) {
|
|
||||||
const response = await fetch(`${API_URL}/reportes/${id}/descartar`, { method: "POST" });
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function obtenerPalabrasFiltradas() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_URL}/config/filtro_palabras`);
|
|
||||||
const data = await response.json();
|
|
||||||
return data.palabras || [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function guardarPalabrasFiltradas(palabras) {
|
|
||||||
const response = await fetch(`${API_URL}/config/filtro_palabras`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ palabras }),
|
|
||||||
});
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function contienepalabrasProhibidas(texto, lista) {
|
|
||||||
if (!lista?.length) return false;
|
|
||||||
const norm = s => s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
|
|
||||||
const t = norm(texto);
|
|
||||||
return lista.some(p => t.includes(norm(p)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auth helpers (local implementation)
|
|
||||||
let currentUser = null;
|
|
||||||
|
|
||||||
export function escucharAuth(cb) {
|
|
||||||
// Simple localStorage-based auth
|
|
||||||
// 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
|
|
||||||
const handleStorageChange = () => {
|
|
||||||
const user = localStorage.getItem("user");
|
|
||||||
currentUser = user ? JSON.parse(user) : null;
|
|
||||||
cb(currentUser);
|
|
||||||
};
|
|
||||||
window.addEventListener("storage", handleStorageChange);
|
|
||||||
return () => window.removeEventListener("storage", handleStorageChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loginAdmin(email, password) {
|
|
||||||
// Simple email/password check (in production use proper auth)
|
|
||||||
if (email && password) {
|
|
||||||
const user = { email, uid: email };
|
|
||||||
localStorage.setItem("user", JSON.stringify(user));
|
|
||||||
currentUser = user;
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
throw new Error("Invalid email or password");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function registrarAdmin(email, password) {
|
|
||||||
return loginAdmin(email, password);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cerrarSesion() {
|
|
||||||
localStorage.removeItem("user");
|
|
||||||
currentUser = null;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getPending, setStatus } from "../services/businessRepository.js";
|
||||||
|
|
||||||
|
export default function AdminPanel({ onClose, onApproved }) {
|
||||||
|
const [pending, setPending] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setPending(await getPending());
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
async function handle(id, status) {
|
||||||
|
await setStatus(id, status);
|
||||||
|
await load();
|
||||||
|
onApproved();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal admin-panel" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="admin-header">
|
||||||
|
<h2>Propuestas pendientes</h2>
|
||||||
|
<button className="close" onClick={onClose} aria-label="Cerrar">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <p className="admin-empty">Cargando…</p>}
|
||||||
|
|
||||||
|
{!loading && pending.length === 0 && (
|
||||||
|
<p className="admin-empty">No hay propuestas pendientes.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && pending.map((b) => (
|
||||||
|
<div key={b.id} className="admin-item">
|
||||||
|
<div className="admin-item-info">
|
||||||
|
<strong>{b.name}</strong>
|
||||||
|
<span className="badge">{b.category}</span>
|
||||||
|
<p>{b.address}</p>
|
||||||
|
<p className="admin-desc">{b.description}</p>
|
||||||
|
{b.contact && <p className="admin-contact">{b.contact}</p>}
|
||||||
|
<p className="admin-coords">{b.lat.toFixed(4)}, {b.lng.toFixed(4)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-actions">
|
||||||
|
<button className="approve" onClick={() => handle(b.id, "approved")}>Aprobar</button>
|
||||||
|
<button className="reject" onClick={() => handle(b.id, "rejected")}>Rechazar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { buscarLugares } from "../utils/geo.js";
|
|
||||||
import { PROVINCIAS } from "../constants/provincias.js";
|
|
||||||
import { inputStyle } from "../styles/shared.js";
|
|
||||||
|
|
||||||
const normaliza = (s) => (s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim();
|
|
||||||
|
|
||||||
function emparejarProvincia(texto) {
|
|
||||||
const t = normaliza(texto);
|
|
||||||
if (!t) return "";
|
|
||||||
return PROVINCIAS.find((p) => normaliza(p) === t || t.includes(normaliza(p))) || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Campo "Nombre del local" con autocompletar: al escribir, busca lugares (OpenStreetMap)
|
|
||||||
// y permite seleccionar uno para rellenar automáticamente nombre, dirección, provincia y coordenadas.
|
|
||||||
export default function AutocompletarLocal({ form, setForm }) {
|
|
||||||
const [sugerencias, setSugerencias] = useState([]);
|
|
||||||
const [abierto, setAbierto] = useState(false);
|
|
||||||
const [buscando, setBuscando] = useState(false);
|
|
||||||
const debounceRef = useRef(null);
|
|
||||||
const cajaRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function alClicFuera(e) {
|
|
||||||
if (cajaRef.current && !cajaRef.current.contains(e.target)) setAbierto(false);
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", alClicFuera);
|
|
||||||
return () => document.removeEventListener("mousedown", alClicFuera);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleChange = (val) => {
|
|
||||||
setForm((f) => ({ ...f, nombre: val }));
|
|
||||||
setAbierto(true);
|
|
||||||
clearTimeout(debounceRef.current);
|
|
||||||
if (val.trim().length < 3) { setSugerencias([]); return; }
|
|
||||||
debounceRef.current = setTimeout(async () => {
|
|
||||||
setBuscando(true);
|
|
||||||
try {
|
|
||||||
setSugerencias(await buscarLugares(val));
|
|
||||||
} catch {
|
|
||||||
setSugerencias([]);
|
|
||||||
}
|
|
||||||
setBuscando(false);
|
|
||||||
}, 450);
|
|
||||||
};
|
|
||||||
|
|
||||||
const seleccionar = (lugar) => {
|
|
||||||
setForm((f) => ({
|
|
||||||
...f,
|
|
||||||
nombre: lugar.nombre,
|
|
||||||
direccion: lugar.direccion,
|
|
||||||
enlaceGoogleMaps: "",
|
|
||||||
lat: lugar.lat,
|
|
||||||
lng: lugar.lng,
|
|
||||||
provincia: emparejarProvincia(lugar.provincia) || f.provincia,
|
|
||||||
}));
|
|
||||||
setSugerencias([]);
|
|
||||||
setAbierto(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={cajaRef} style={{ position: "relative" }}>
|
|
||||||
<label htmlFor="campo-nombre" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Nombre del local *</label>
|
|
||||||
<input
|
|
||||||
id="campo-nombre"
|
|
||||||
style={inputStyle}
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder="Bar El Olivo, Clínica San José..."
|
|
||||||
value={form.nombre}
|
|
||||||
onChange={(e) => handleChange(e.target.value)}
|
|
||||||
onFocus={() => sugerencias.length && setAbierto(true)}
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={abierto && sugerencias.length > 0}
|
|
||||||
aria-autocomplete="list"
|
|
||||||
/>
|
|
||||||
{buscando && (
|
|
||||||
<i className="ti ti-loader" aria-hidden="true" style={{ position: "absolute", right: 10, top: 32, fontSize: 14, color: "var(--color-text-tertiary)" }}></i>
|
|
||||||
)}
|
|
||||||
{abierto && sugerencias.length > 0 && (
|
|
||||||
<div role="listbox" style={{
|
|
||||||
position: "absolute", zIndex: 20, top: "100%", left: 0, right: 0, marginTop: 4,
|
|
||||||
background: "var(--color-background-primary)", border: "0.5px solid var(--color-border-secondary)",
|
|
||||||
borderRadius: "var(--border-radius-md)", boxShadow: "0 6px 18px rgba(0,0,0,0.15)",
|
|
||||||
maxHeight: 240, overflowY: "auto",
|
|
||||||
}}>
|
|
||||||
{sugerencias.map((s) => (
|
|
||||||
<button key={s.id} type="button" role="option" onClick={() => seleccionar(s)}
|
|
||||||
style={{
|
|
||||||
display: "block", width: "100%", textAlign: "left", background: "none", border: "none",
|
|
||||||
borderBottom: "0.5px solid var(--color-border-tertiary)", padding: "8px 10px", cursor: "pointer",
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--color-text-primary)" }}>{s.nombre}</div>
|
|
||||||
<div style={{ fontSize: 11, color: "var(--color-text-secondary)", marginTop: 2 }}>{s.direccion}</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: "4px 0 0" }}>
|
|
||||||
Escribe el nombre y elige una opción para rellenar dirección y ubicación automáticamente.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import usePwaInstall from "../hooks/usePwaInstall.js";
|
|
||||||
import useEsMovil from "../hooks/useEsMovil.js";
|
|
||||||
import { ANCHO_CUERPO } from "../styles/shared.js";
|
|
||||||
|
|
||||||
const CLAVE_VISITAS = "localesp_visitas";
|
|
||||||
const CLAVE_CERRADO = "localesp_banner_cerrado_hasta";
|
|
||||||
const VISITAS_MINIMAS = 2;
|
|
||||||
const DIAS_OCULTO_TRAS_CERRAR = 14;
|
|
||||||
|
|
||||||
function registrarVisitaYContar() {
|
|
||||||
try {
|
|
||||||
const actual = parseInt(localStorage.getItem(CLAVE_VISITAS) || "0", 10) + 1;
|
|
||||||
localStorage.setItem(CLAVE_VISITAS, String(actual));
|
|
||||||
return actual;
|
|
||||||
} catch {
|
|
||||||
return VISITAS_MINIMAS; // si localStorage falla, no bloqueamos el banner
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function estaCerradoRecientemente() {
|
|
||||||
try {
|
|
||||||
const hasta = parseInt(localStorage.getItem(CLAVE_CERRADO) || "0", 10);
|
|
||||||
return Date.now() < hasta;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 aviso
|
|
||||||
// se oculta porque ya no aporta nada mostrarlo.
|
|
||||||
//
|
|
||||||
// Colocación: abajo a la IZQUIERDA en PC y móvil, alineada con el borde
|
|
||||||
// izquierdo del cuerpo central (columna de ANCHO_CUERPO px), flanqueando el
|
|
||||||
// contenido junto al FAB de "proponer local" (abajo a la derecha): en
|
|
||||||
// pantallas anchas acompaña a la columna en vez de pegarse al borde de la
|
|
||||||
// ventana; en estrechas queda a 16px. Respeta la barra inferior de la PWA
|
|
||||||
// (env(safe-area-inset-bottom)) y en PC se muestra algo más grande.
|
|
||||||
//
|
|
||||||
// Para no ser intrusivo, solo se muestra a partir de la 2ª visita del
|
|
||||||
// usuario (contador en localStorage) y, si se cierra con ✕, no vuelve a
|
|
||||||
// aparecer durante 14 días.
|
|
||||||
export default function BannerMovil() {
|
|
||||||
const [cerrado, setCerrado] = useState(estaCerradoRecientemente);
|
|
||||||
const [visitasSuficientes, setVisitasSuficientes] = useState(false);
|
|
||||||
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);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (cerrado || isInstalled || !visitasSuficientes) return null;
|
|
||||||
|
|
||||||
const handleCerrar = () => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(CLAVE_CERRADO, String(Date.now() + DIAS_OCULTO_TRAS_CERRAR * 24 * 60 * 60 * 1000));
|
|
||||||
} catch { /* localStorage no disponible: se cierra solo para esta sesión */ }
|
|
||||||
setCerrado(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInstalar = async () => {
|
|
||||||
setInstalando(true);
|
|
||||||
const outcome = await promptInstall();
|
|
||||||
setResultado(outcome);
|
|
||||||
setInstalando(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 }}>
|
|
||||||
<img src="/icon-192.png" alt="" width={30} height={30} style={{ borderRadius: 7, flexShrink: 0 }} />
|
|
||||||
<div>
|
|
||||||
<p style={{ margin: 0, fontWeight: 700, fontSize: 14 }}>App para móvil</p>
|
|
||||||
<p style={{ margin: 0, fontSize: 12, color: "#888" }}>Sincronizada en tiempo real</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{canPromptInstall && (
|
|
||||||
<button onClick={handleInstalar} disabled={instalando}
|
|
||||||
style={{ display: "block", width: "100%", textAlign: "center", padding: "9px 0", background: "#8B0000", color: "white", border: "none", borderRadius: 8, fontSize: 13, fontWeight: 700, cursor: instalando ? "default" : "pointer", opacity: instalando ? 0.7 : 1 }}>
|
|
||||||
{instalando ? "Abriendo instalación..." : "📲 Instalar app"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!canPromptInstall && isIos && (
|
|
||||||
<p style={{ margin: 0, fontSize: 12, color: "var(--color-text-secondary, #666)", lineHeight: 1.5 }}>
|
|
||||||
Pulsa <i className="ti ti-share-2" aria-hidden="true"></i> <strong>Compartir</strong> y luego <strong>"Añadir a pantalla de inicio"</strong> para instalarla.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!canPromptInstall && !isIos && (
|
|
||||||
<p style={{ margin: 0, fontSize: 12, color: "var(--color-text-secondary, #666)", lineHeight: 1.5 }}>
|
|
||||||
Instálala desde el menú de tu navegador ("Instalar app" o "Añadir a pantalla de inicio").
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{resultado === "dismissed" && (
|
|
||||||
<p style={{ margin: "8px 0 0", fontSize: 11, color: "#888" }}>Puedes instalarla más tarde cuando quieras.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Aviso abajo a la izquierda (PC y móvil), alineado con el borde del cuerpo ──
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
position: "fixed",
|
|
||||||
left: `max(16px, calc((100vw - ${ANCHO_CUERPO}px) / 2 + 16px))`,
|
|
||||||
bottom: 0,
|
|
||||||
zIndex: 999,
|
|
||||||
paddingBottom: "calc(16px + env(safe-area-inset-bottom))",
|
|
||||||
}}>
|
|
||||||
{tarjeta(handleCerrar, { width: esMovil ? 280 : 320 })}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import useEsMovil from "../hooks/useEsMovil.js";
|
|
||||||
import { ANCHO_CUERPO } from "../styles/shared.js";
|
|
||||||
|
|
||||||
const TEXTO_TOOLTIP = "Añade un nuevo local al directorio";
|
|
||||||
|
|
||||||
// Botón de acción flotante (FAB) para proponer un local: fijo abajo a la
|
|
||||||
// derecha, visible en PC y móvil, alineado con el borde derecho del cuerpo
|
|
||||||
// central (columna de ANCHO_CUERPO px) para que acompañe al contenido en
|
|
||||||
// pantallas anchas. En PC es más grande (64px) que en móvil (54px). Respeta
|
|
||||||
// la barra de la PWA con env(safe-area-inset-bottom). Muestra un tooltip
|
|
||||||
// propio al hacer hover o recibir foco (posicionado a la izquierda) y expone
|
|
||||||
// aria-label para accesibilidad sin ratón.
|
|
||||||
export default function BotonFlotante({ onClick }) {
|
|
||||||
const [tooltipVisible, setTooltipVisible] = useState(false);
|
|
||||||
const esMovil = useEsMovil();
|
|
||||||
const tamano = esMovil ? 54 : 64; // más grande en PC
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
// Abajo a la derecha, alineado con el borde derecho del cuerpo central:
|
|
||||||
// en pantallas anchas flanquea la columna de contenido; en estrechas, 16px.
|
|
||||||
right: `max(16px, calc((100vw - ${ANCHO_CUERPO}px) / 2 + 16px))`,
|
|
||||||
bottom: 0,
|
|
||||||
zIndex: 900,
|
|
||||||
paddingBottom: "env(safe-area-inset-bottom)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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: tamano, height: tamano, borderRadius: "50%",
|
|
||||||
background: "#8B0000", color: "white", border: "none",
|
|
||||||
boxShadow: esMovil ? "0 4px 14px rgba(139,0,0,0.4)" : "0 6px 20px rgba(139,0,0,0.45)",
|
|
||||||
fontSize: esMovil ? 24 : 28, cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className="ti ti-plus" aria-hidden="true"></i>
|
|
||||||
</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,33 +0,0 @@
|
|||||||
import useEsMovil from "../hooks/useEsMovil.js";
|
|
||||||
import { ANCHO_CUERPO } from "../styles/shared.js";
|
|
||||||
|
|
||||||
// Botón "Proponer local" arriba a la derecha (solo PC): hace exactamente lo
|
|
||||||
// mismo que el FAB inferior — abre la pasarela de propuesta — pero da un
|
|
||||||
// punto de acceso visible en la zona superior, alineado con el borde derecho
|
|
||||||
// del cuerpo central (columna de ANCHO_CUERPO px) para acompañar al contenido
|
|
||||||
// en pantallas anchas. En móvil no se renderiza (ahí basta el FAB inferior).
|
|
||||||
export default function BotonProponerArriba({ onClick }) {
|
|
||||||
const esMovil = useEsMovil();
|
|
||||||
if (esMovil) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
aria-label="Añade un nuevo local al directorio"
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
top: 20,
|
|
||||||
right: `max(16px, calc((100vw - ${ANCHO_CUERPO}px) / 2 + 16px))`,
|
|
||||||
zIndex: 900,
|
|
||||||
display: "flex", alignItems: "center", gap: 8,
|
|
||||||
background: "#8B0000", color: "white", border: "none", borderRadius: 999,
|
|
||||||
padding: "11px 22px", fontSize: 15, fontWeight: 600,
|
|
||||||
cursor: "pointer", fontFamily: "var(--font-sans)",
|
|
||||||
boxShadow: "0 4px 14px rgba(139,0,0,0.35)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className="ti ti-plus" aria-hidden="true"></i> Proponer local
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { obtenerCategoria } from "../constants/categorias.js";
|
|
||||||
|
|
||||||
export default function CategoriaBadge({ categoria, subcategoria }) {
|
|
||||||
if (!categoria) return null;
|
|
||||||
const cat = obtenerCategoria(categoria);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>
|
|
||||||
<span style={{ background: cat.bgLight, color: cat.textColor, fontSize: 12, padding: "3px 10px", borderRadius: "var(--border-radius-md)", fontWeight: 500 }}>
|
|
||||||
{cat.emoji} {categoria}
|
|
||||||
</span>
|
|
||||||
{subcategoria && (
|
|
||||||
<span style={{ background: "var(--color-background-secondary)", color: "var(--color-text-secondary)", fontSize: 12, padding: "3px 10px", borderRadius: "var(--border-radius-md)" }}>
|
|
||||||
{subcategoria}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import CategoriaBadge from "./CategoriaBadge.jsx";
|
|
||||||
import StarRating from "./StarRating.jsx";
|
|
||||||
import ReportarLocal from "./ReportarLocal.jsx";
|
|
||||||
import Toast from "./Toast.jsx";
|
|
||||||
import useFavoritos from "../hooks/useFavoritos.js";
|
|
||||||
import useToast from "../hooks/useToast.js";
|
|
||||||
import { confirmarLocal } from "../firebase.js";
|
|
||||||
import { enlaceGoogleMapsDesdeLocal, enlaceWazeDesdeLocal, formatearDistancia } from "../utils/geo.js";
|
|
||||||
|
|
||||||
const MESES_PARA_CADUCAR = 6;
|
|
||||||
|
|
||||||
function formatearFecha(fecha) {
|
|
||||||
if (!fecha) return null;
|
|
||||||
const date = new Date(fecha);
|
|
||||||
if (Number.isNaN(date.getTime())) return null;
|
|
||||||
return date.toLocaleDateString("es-ES", { day: "numeric", month: "long", year: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function mesesDesde(fecha) {
|
|
||||||
if (!fecha) return null;
|
|
||||||
const date = new Date(fecha);
|
|
||||||
if (Number.isNaN(date.getTime())) return null;
|
|
||||||
return (Date.now() - date.getTime()) / (1000 * 60 * 60 * 24 * 30);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LocalCard({ local, distanciaKm }) {
|
|
||||||
const { esFavorito, alternarFavorito } = useFavoritos();
|
|
||||||
const { toast, mostrarToast } = useToast();
|
|
||||||
const [confirmando, setConfirmando] = useState(false);
|
|
||||||
const [confirmado, setConfirmado] = useState(false);
|
|
||||||
const [fechaConfirmacion, setFechaConfirmacion] = useState(local.fechaConfirmacion || local.fecha);
|
|
||||||
|
|
||||||
const gmUrl = local.enlaceGoogleMaps || enlaceGoogleMapsDesdeLocal(local);
|
|
||||||
const wazeUrl = enlaceWazeDesdeLocal(local);
|
|
||||||
const fechaFormateada = formatearFecha(local.fecha);
|
|
||||||
const meses = mesesDesde(fechaConfirmacion);
|
|
||||||
const sinConfirmarReciente = meses != null && meses >= MESES_PARA_CADUCAR;
|
|
||||||
const favorito = esFavorito(local.id);
|
|
||||||
|
|
||||||
const handleConfirmar = async () => {
|
|
||||||
setConfirmando(true);
|
|
||||||
try {
|
|
||||||
const res = await confirmarLocal(local.id);
|
|
||||||
setFechaConfirmacion(res?.fechaConfirmacion || new Date().toISOString());
|
|
||||||
setConfirmado(true);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
setConfirmando(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAlternarFavorito = () => {
|
|
||||||
const nuevoEstado = !favorito;
|
|
||||||
alternarFavorito(local.id);
|
|
||||||
mostrarToast(nuevoEstado ? "❤️ Añadido a favoritos" : "Quitado de favoritos", nuevoEstado ? "exito" : "info");
|
|
||||||
};
|
|
||||||
|
|
||||||
const compartir = async () => {
|
|
||||||
const url = `${window.location.origin}${window.location.pathname}?local=${local.id}`;
|
|
||||||
const texto = `${local.nombre}${local.provincia ? ` · ${local.provincia}` : ""}`;
|
|
||||||
if (navigator.share) {
|
|
||||||
try { await navigator.share({ title: local.nombre, text: texto, url }); } catch { /* usuario canceló */ }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(url);
|
|
||||||
window.open(`https://wa.me/?text=${encodeURIComponent(`${texto} ${url}`)}`, "_blank", "noopener,noreferrer");
|
|
||||||
} catch {
|
|
||||||
window.open(`https://wa.me/?text=${encodeURIComponent(`${texto} ${url}`)}`, "_blank", "noopener,noreferrer");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id={`local-${local.id}`}
|
|
||||||
style={{ background: "var(--color-background-primary)", border: "0.5px solid var(--color-border-tertiary)", borderRadius: "var(--border-radius-lg)", padding: "1rem 1.25rem", display: "flex", flexDirection: "column", gap: 8, transition: "border-color 0.2s" }}
|
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = "var(--color-border-secondary)"; }}
|
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--color-border-tertiary)"; }}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<p style={{ fontWeight: 500, fontSize: 16, margin: 0 }}>{local.nombre}</p>
|
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-secondary)", margin: "2px 0 0" }}>
|
|
||||||
{local.provincia}
|
|
||||||
{distanciaKm != null && <span style={{ color: "var(--color-text-tertiary)" }}> · {formatearDistancia(distanciaKm)}</span>}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
|
||||||
<button type="button" onClick={compartir} title="Compartir"
|
|
||||||
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 16, color: "var(--color-text-tertiary)", padding: 4 }}>
|
|
||||||
<i className="ti ti-share-2" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={handleAlternarFavorito} title={favorito ? "Quitar de favoritos" : "Guardar en favoritos"}
|
|
||||||
aria-pressed={favorito}
|
|
||||||
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 16, color: favorito ? "#8B0000" : "var(--color-text-tertiary)", padding: 4 }}>
|
|
||||||
<i className={favorito ? "ti ti-heart-filled" : "ti ti-heart"} aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CategoriaBadge categoria={local.categoria} subcategoria={local.subcategoria} />
|
|
||||||
|
|
||||||
{local.direccion && (
|
|
||||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 6 }}>
|
|
||||||
<i className="ti ti-map-pin" aria-hidden="true" style={{ fontSize: 14, color: "var(--color-text-tertiary)", marginTop: 1, flexShrink: 0 }}></i>
|
|
||||||
{gmUrl ? (
|
|
||||||
<a href={gmUrl} target="_blank" rel="noopener noreferrer" title="Abrir dirección en Google Maps"
|
|
||||||
style={{ fontSize: 13, color: "var(--color-text-secondary)", textDecoration: "underline", textDecorationStyle: "dotted", textUnderlineOffset: 2 }}>
|
|
||||||
{local.direccion}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span style={{ fontSize: 13, color: "var(--color-text-secondary)" }}>{local.direccion}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(gmUrl || wazeUrl) && (
|
|
||||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
|
||||||
{gmUrl && (
|
|
||||||
<a href={gmUrl} target="_blank" rel="noopener noreferrer"
|
|
||||||
style={{ display: "inline-flex", alignItems: "center", gap: 5, fontSize: 12, color: "#1A73E8", textDecoration: "none", width: "fit-content", background: "#E8F0FE", padding: "4px 10px", borderRadius: "var(--border-radius-md)", fontWeight: 500 }}>
|
|
||||||
<i className="ti ti-brand-google-maps" aria-hidden="true" style={{ fontSize: 14 }}></i>
|
|
||||||
Google Maps
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{wazeUrl && (
|
|
||||||
<a href={wazeUrl} target="_blank" rel="noopener noreferrer"
|
|
||||||
style={{ display: "inline-flex", alignItems: "center", gap: 5, fontSize: 12, color: "#33CCFF", textDecoration: "none", width: "fit-content", background: "#E6FAFF", padding: "4px 10px", borderRadius: "var(--border-radius-md)", fontWeight: 500 }}>
|
|
||||||
<i className="ti ti-brand-waze" aria-hidden="true" style={{ fontSize: 14 }}></i>
|
|
||||||
Waze
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<StarRating value={local.puntuacion} readOnly />
|
|
||||||
|
|
||||||
{local.descripcion && (
|
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-secondary)", margin: 0, fontStyle: "italic" }}>"{local.descripcion}"</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{sinConfirmarReciente && !confirmado && (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, flexWrap: "wrap", background: "#FDF2E9", border: "1px solid #F5CBA7", borderRadius: "var(--border-radius-md)", padding: "6px 10px" }}>
|
|
||||||
<span style={{ fontSize: 12, color: "#784212", display: "flex", alignItems: "center", gap: 5 }}>
|
|
||||||
<i className="ti ti-clock-exclamation" aria-hidden="true"></i> Sin confirmar recientemente
|
|
||||||
</span>
|
|
||||||
<button type="button" onClick={handleConfirmar} disabled={confirmando}
|
|
||||||
style={{ fontSize: 12, background: "#1E8449", color: "white", border: "none", borderRadius: "var(--border-radius-md)", padding: "4px 10px", cursor: "pointer", fontWeight: 500 }}>
|
|
||||||
{confirmando ? "..." : "Sigue aquí ✅"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{confirmado && (
|
|
||||||
<p style={{ fontSize: 12, color: "#1E8449", margin: 0 }}>✅ Gracias por confirmar que sigue activo.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
|
||||||
{fechaFormateada && (
|
|
||||||
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: 0 }}>Publicado {fechaFormateada}</p>
|
|
||||||
)}
|
|
||||||
<ReportarLocal localId={local.id} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Toast toast={toast} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { MapContainer, TileLayer, Marker, useMapEvents } from "react-leaflet";
|
||||||
|
import L from "leaflet";
|
||||||
|
|
||||||
|
// Configuración del icono por defecto. Leaflet no encuentra sus iconos cuando
|
||||||
|
// se empaqueta con Vite; los referenciamos desde el CDN para no copiar
|
||||||
|
// archivos a public/.
|
||||||
|
const icon = new L.Icon({
|
||||||
|
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||||||
|
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||||||
|
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||||
|
iconSize: [25, 41],
|
||||||
|
iconAnchor: [12, 41],
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEFAULT_CENTER = [40.4168, -3.7038];
|
||||||
|
const DEFAULT_ZOOM = 13;
|
||||||
|
|
||||||
|
function LocationPicker({ onPick }) {
|
||||||
|
useMapEvents({
|
||||||
|
click(e) {
|
||||||
|
onPick({ lat: e.latlng.lat, lng: e.latlng.lng });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MapView({
|
||||||
|
businesses,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
pickingLocation,
|
||||||
|
onPickLocation,
|
||||||
|
pickedLocation,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="map">
|
||||||
|
<MapContainer
|
||||||
|
center={DEFAULT_CENTER}
|
||||||
|
zoom={DEFAULT_ZOOM}
|
||||||
|
style={{ height: "100%", width: "100%" }}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
{businesses.map((b) => (
|
||||||
|
<Marker
|
||||||
|
key={b.id}
|
||||||
|
position={[b.lat, b.lng]}
|
||||||
|
icon={icon}
|
||||||
|
eventHandlers={{ click: () => onSelect(b.id) }}
|
||||||
|
opacity={selectedId && selectedId !== b.id ? 0.6 : 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{pickingLocation && <LocationPicker onPick={onPickLocation} />}
|
||||||
|
{pickedLocation && (
|
||||||
|
<Marker
|
||||||
|
position={[pickedLocation.lat, pickedLocation.lng]}
|
||||||
|
icon={icon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</MapContainer>
|
||||||
|
{pickingLocation && (
|
||||||
|
<div className="map-hint">Haz clic en el mapa para fijar la ubicación</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
// Modal con la política de privacidad. Contenido genérico orientativo: como se
|
|
||||||
// guardan datos de negocios (algunos de terceros, no del propio usuario) conviene
|
|
||||||
// que un profesional lo revise antes de publicar la app.
|
|
||||||
export default function ModalPrivacidad({ onCerrar }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="dialog" aria-modal="true" aria-label="Política de privacidad"
|
|
||||||
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", zIndex: 1000, display: "flex", alignItems: "center", justifyContent: "center", padding: 16 }}
|
|
||||||
onClick={onCerrar}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
style={{ background: "var(--color-background-primary)", borderRadius: "var(--border-radius-lg)", maxWidth: 560, width: "100%", maxHeight: "85vh", overflowY: "auto", padding: "1.5rem", fontFamily: "var(--font-sans)" }}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 12 }}>
|
|
||||||
<p style={{ margin: 0, fontWeight: 600, fontSize: 17 }}>Privacidad y condiciones</p>
|
|
||||||
<button onClick={onCerrar} aria-label="Cerrar" style={{ background: "none", border: "none", cursor: "pointer", fontSize: 18, color: "var(--color-text-tertiary)" }}>✕</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ fontSize: 13, lineHeight: 1.6, color: "var(--color-text-secondary)", display: "flex", flexDirection: "column", gap: 10 }}>
|
|
||||||
<p><strong>Qué datos guardamos.</strong> Cuando propones un local guardamos el nombre del negocio, su dirección o enlace de Google Maps, categoría, una descripción opcional y una puntuación. Estos datos suelen referirse a un negocio (a menudo de terceros), no a datos personales tuyos.</p>
|
|
||||||
<p><strong>Revisión previa.</strong> Toda propuesta pasa por un administrador antes de publicarse, y puede ser rechazada si incumple las normas (contenido inapropiado, información falsa, etc.).</p>
|
|
||||||
<p><strong>Ubicación.</strong> Si usas "cerca de mí", tu posición se usa solo en tu dispositivo para calcular distancias; no se envía a nuestro servidor ni se guarda.</p>
|
|
||||||
<p><strong>Datos públicos de negocios.</strong> Los datos de los negocios listados (nombre, dirección) son públicos y equivalentes a los que aparecen en Google Maps u otros directorios similares. Cualquiera puede solicitar la corrección o eliminación de una ficha usando el botón "Reportar" en cada local.</p>
|
|
||||||
<p><strong>Favoritos.</strong> Tu lista de favoritos se guarda únicamente en tu propio dispositivo (localStorage), no en nuestros servidores.</p>
|
|
||||||
<p style={{ fontSize: 12, color: "var(--color-text-tertiary)" }}>Este texto es orientativo y no sustituye asesoría legal. Antes de publicar la app conviene adaptarlo con un profesional, especialmente en lo referente a datos de terceros (dueños de negocios) y RGPD.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { reportarLocal } from "../firebase.js";
|
|
||||||
|
|
||||||
const MOTIVOS = [
|
|
||||||
{ id: "cerrado", label: "Ha cerrado / ya no existe" },
|
|
||||||
{ id: "datos_incorrectos", label: "Los datos son incorrectos" },
|
|
||||||
{ id: "inapropiado", label: "Contenido inapropiado" },
|
|
||||||
{ id: "otro", label: "Otro motivo" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ReportarLocal({ localId }) {
|
|
||||||
const [abierto, setAbierto] = useState(false);
|
|
||||||
const [motivo, setMotivo] = useState("cerrado");
|
|
||||||
const [comentario, setComentario] = useState("");
|
|
||||||
const [enviando, setEnviando] = useState(false);
|
|
||||||
const [enviado, setEnviado] = useState(false);
|
|
||||||
|
|
||||||
if (enviado) {
|
|
||||||
return <p style={{ fontSize: 12, color: "#1E8449", margin: 0 }}>✅ Gracias, hemos recibido tu aviso.</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!abierto) {
|
|
||||||
return (
|
|
||||||
<button type="button" onClick={() => setAbierto(true)}
|
|
||||||
style={{ display: "inline-flex", alignItems: "center", gap: 4, background: "none", border: "none", padding: 0, fontSize: 12, color: "var(--color-text-tertiary)", cursor: "pointer", width: "fit-content" }}>
|
|
||||||
<i className="ti ti-flag" aria-hidden="true"></i> Reportar
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const enviar = async () => {
|
|
||||||
setEnviando(true);
|
|
||||||
try {
|
|
||||||
await reportarLocal(localId, motivo, comentario);
|
|
||||||
setEnviado(true);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
setEnviando(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ background: "var(--color-background-secondary)", borderRadius: "var(--border-radius-md)", padding: "8px 10px", display: "flex", flexDirection: "column", gap: 6 }}>
|
|
||||||
<p style={{ margin: 0, fontSize: 12, fontWeight: 500 }}>¿Qué está mal con este local?</p>
|
|
||||||
<select value={motivo} onChange={(e) => setMotivo(e.target.value)}
|
|
||||||
style={{ fontSize: 12, padding: "5px 8px", borderRadius: "var(--border-radius-md)", border: "0.5px solid var(--color-border-secondary)", background: "var(--color-background-primary)", color: "var(--color-text-primary)" }}>
|
|
||||||
{MOTIVOS.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
|
|
||||||
</select>
|
|
||||||
<textarea value={comentario} onChange={(e) => setComentario(e.target.value)} placeholder="Comentario opcional..."
|
|
||||||
style={{ fontSize: 12, padding: "5px 8px", borderRadius: "var(--border-radius-md)", border: "0.5px solid var(--color-border-secondary)", background: "var(--color-background-primary)", color: "var(--color-text-primary)", resize: "vertical", minHeight: 40 }} />
|
|
||||||
<div style={{ display: "flex", gap: 6 }}>
|
|
||||||
<button type="button" onClick={enviar} disabled={enviando}
|
|
||||||
style={{ fontSize: 12, background: "#8B0000", color: "white", border: "none", borderRadius: "var(--border-radius-md)", padding: "5px 12px", cursor: "pointer" }}>
|
|
||||||
{enviando ? "Enviando..." : "Enviar aviso"}
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => setAbierto(false)}
|
|
||||||
style={{ fontSize: 12, background: "none", border: "none", color: "var(--color-text-tertiary)", cursor: "pointer" }}>
|
|
||||||
Cancelar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export default function Sidebar({ business, onContribute, onClose, picking, onCancelPicking, onAdmin }) {
|
||||||
|
return (
|
||||||
|
<aside className="sidebar">
|
||||||
|
<header className="sidebar-header">
|
||||||
|
<h1>Business Map</h1>
|
||||||
|
<p className="tagline">Registro colaborativo de negocios</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="sidebar-body">
|
||||||
|
{picking ? (
|
||||||
|
<div className="empty">
|
||||||
|
<p>Haz clic en el mapa para fijar la ubicación del negocio.</p>
|
||||||
|
</div>
|
||||||
|
) : business ? (
|
||||||
|
<BusinessDetails business={business} onClose={onClose} />
|
||||||
|
) : (
|
||||||
|
<Empty />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="sidebar-footer">
|
||||||
|
{picking ? (
|
||||||
|
<button onClick={onCancelPicking}>Cancelar</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button className="primary" onClick={onContribute}>
|
||||||
|
+ Añadir un negocio
|
||||||
|
</button>
|
||||||
|
<button className="admin-btn" onClick={onAdmin} title="Panel de administración">
|
||||||
|
⚙ Admin
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty() {
|
||||||
|
return (
|
||||||
|
<div className="empty">
|
||||||
|
<p>Selecciona un punto del mapa para ver sus detalles.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BusinessDetails({ business, onClose }) {
|
||||||
|
return (
|
||||||
|
<article className="details">
|
||||||
|
<button className="close" onClick={onClose} aria-label="Cerrar">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<span className="badge">{business.category}</span>
|
||||||
|
<h2>{business.name}</h2>
|
||||||
|
<p className="address">{business.address}</p>
|
||||||
|
<p className="description">{business.description}</p>
|
||||||
|
{business.contact && (
|
||||||
|
<p className="contact">
|
||||||
|
<strong>Contacto:</strong> {business.contact}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
const ESTRELLAS = [1, 2, 3, 4, 5];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Muestra siempre 5 estrellas (usando el font icon de Tabler ya cargado en
|
|
||||||
* index.html, para no depender de una fuente/librería extra). Si no hay
|
|
||||||
* valoración (0, null, undefined o un valor no numérico) se muestran las
|
|
||||||
* 5 estrellas vacías en lugar de no renderizar nada.
|
|
||||||
*
|
|
||||||
* - Modo lectura (readOnly, por defecto): <div role="img"> con aria-label
|
|
||||||
* describiendo la puntuación, para lectores de pantalla.
|
|
||||||
* - Modo edición (onChange): botones reales con role="radio" para que sea
|
|
||||||
* utilizable con teclado y tenga foco visible.
|
|
||||||
*/
|
|
||||||
export default function StarRating({ value = 0, onChange, readOnly = false, size = 20 }) {
|
|
||||||
const [valorHover, setValorHover] = useState(0);
|
|
||||||
const esInteractivo = !readOnly && typeof onChange === "function";
|
|
||||||
|
|
||||||
const valorSeguro = Math.min(5, Math.max(0, Math.round(Number(value)) || 0));
|
|
||||||
const valorMostrado = esInteractivo && valorHover ? valorHover : valorSeguro;
|
|
||||||
|
|
||||||
const contenedorProps = esInteractivo
|
|
||||||
? { role: "radiogroup", "aria-label": "Selecciona una puntuación de 1 a 5 estrellas" }
|
|
||||||
: { role: "img", "aria-label": `Valoración: ${valorSeguro} de 5 estrellas` };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
{...contenedorProps}
|
|
||||||
style={{ display: "inline-flex", gap: 2 }}
|
|
||||||
onMouseLeave={() => esInteractivo && setValorHover(0)}
|
|
||||||
>
|
|
||||||
{ESTRELLAS.map((estrella) => {
|
|
||||||
const rellena = estrella <= valorMostrado;
|
|
||||||
const iconStyle = {
|
|
||||||
fontSize: size,
|
|
||||||
color: rellena ? "#D4AF37" : "var(--color-border-secondary)",
|
|
||||||
transition: "color 0.15s",
|
|
||||||
lineHeight: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!esInteractivo) {
|
|
||||||
return (
|
|
||||||
<i key={estrella} className={`ti ${rellena ? "ti-star-filled" : "ti-star"}`} style={iconStyle} aria-hidden="true" />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={estrella}
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={valorSeguro === estrella}
|
|
||||||
aria-label={`${estrella} estrella${estrella === 1 ? "" : "s"}`}
|
|
||||||
onClick={() => onChange(estrella)}
|
|
||||||
onMouseEnter={() => setValorHover(estrella)}
|
|
||||||
onFocus={() => setValorHover(estrella)}
|
|
||||||
onBlur={() => setValorHover(0)}
|
|
||||||
style={{ background: "none", border: "none", padding: 2, margin: 0, cursor: "pointer", lineHeight: 0 }}
|
|
||||||
>
|
|
||||||
<i className={`ti ${rellena ? "ti-star-filled" : "ti-star"}`} style={iconStyle} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const EMPTY = {
|
||||||
|
name: "",
|
||||||
|
category: "",
|
||||||
|
address: "",
|
||||||
|
description: "",
|
||||||
|
contact: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SubmitForm({ pickedLocation, onSubmit, onCancel, onRepick }) {
|
||||||
|
const [values, setValues] = useState(EMPTY);
|
||||||
|
|
||||||
|
function update(field) {
|
||||||
|
return (e) => setValues({ ...values, [field]: e.target.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit({ ...values, lat: pickedLocation.lat, lng: pickedLocation.lng });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onCancel}>
|
||||||
|
<form
|
||||||
|
className="modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
<h2>Proponer un negocio</h2>
|
||||||
|
<p className="hint">
|
||||||
|
Los datos se revisarán antes de aparecer en el mapa.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Nombre
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={values.name}
|
||||||
|
onChange={update("name")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Categoría
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
placeholder="Cafetería, librería, taller…"
|
||||||
|
value={values.category}
|
||||||
|
onChange={update("category")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Dirección
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={values.address}
|
||||||
|
onChange={update("address")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Descripción
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
rows={3}
|
||||||
|
value={values.description}
|
||||||
|
onChange={update("description")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Contacto (opcional)
|
||||||
|
<input
|
||||||
|
placeholder="Email, teléfono o web"
|
||||||
|
value={values.contact}
|
||||||
|
onChange={update("contact")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="location-status">
|
||||||
|
{`Ubicación: ${pickedLocation.lat.toFixed(4)}, ${pickedLocation.lng.toFixed(4)}`}
|
||||||
|
<button type="button" className="repick-link" onClick={onRepick}>
|
||||||
|
Cambiar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" onClick={onCancel}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="primary">
|
||||||
|
Enviar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
const ESTILOS = {
|
|
||||||
info: { bg: "#2C3E50", color: "white", icono: "ti-info-circle" },
|
|
||||||
exito: { bg: "#1E8449", color: "white", icono: "ti-check" },
|
|
||||||
error: { bg: "#C0392B", color: "white", icono: "ti-x" },
|
|
||||||
};
|
|
||||||
|
|
||||||
// Aviso flotante breve, de solo lectura: se controla desde el hook useToast.
|
|
||||||
export default function Toast({ toast }) {
|
|
||||||
if (!toast) return null;
|
|
||||||
const { bg, color, icono } = ESTILOS[toast.tipo] || ESTILOS.info;
|
|
||||||
return (
|
|
||||||
<div key={toast.key} role="status" aria-live="polite" style={{
|
|
||||||
position: "fixed", left: "50%", bottom: 24, transform: "translateX(-50%)",
|
|
||||||
background: bg, color, padding: "10px 18px", borderRadius: 999, fontSize: 13,
|
|
||||||
fontWeight: 500, boxShadow: "0 6px 20px rgba(0,0,0,0.18)", zIndex: 2000,
|
|
||||||
display: "flex", alignItems: "center", gap: 8, maxWidth: "90vw",
|
|
||||||
animation: "localesp-toast-in 0.25s ease-out",
|
|
||||||
}}>
|
|
||||||
<i className={`ti ${icono}`} aria-hidden="true" style={{ fontSize: 15 }}></i>
|
|
||||||
{toast.mensaje}
|
|
||||||
<style>{"@keyframes localesp-toast-in{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}}"}</style>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
// Categorías de negocio compartidas entre la web pública y el panel de administración.
|
|
||||||
// Centralizar esta lista evita que App.jsx y AdminPanel.jsx queden desincronizados.
|
|
||||||
|
|
||||||
export const CATEGORIAS = {
|
|
||||||
"Restauración": {
|
|
||||||
emoji: "🍽️",
|
|
||||||
color: "#8B0000",
|
|
||||||
bgLight: "#FFF0F0",
|
|
||||||
textColor: "#8B0000",
|
|
||||||
subcategorias: [
|
|
||||||
"Tapas y raciones", "Paella y arroces", "Pintxos", "Asador / Carne a la brasa",
|
|
||||||
"Mariscos y pescados", "Bocadillos y montaditos", "Menú del día", "Cocina vasca",
|
|
||||||
"Cocina catalana", "Cocina andaluza", "Cocina gallega", "Cocina madrileña",
|
|
||||||
"Cocina mediterránea", "Pizzería", "Hamburguesería", "Comida rápida",
|
|
||||||
"Cocina internacional", "Cafetería / Desayunos", "Heladería", "Pastelería",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"Pequeño comercio": {
|
|
||||||
emoji: "🛍️",
|
|
||||||
color: "#1A5276",
|
|
||||||
bgLight: "#EBF5FB",
|
|
||||||
textColor: "#1A5276",
|
|
||||||
subcategorias: [
|
|
||||||
"Alimentación / Ultramarinos", "Frutería", "Carnicería", "Pescadería", "Panadería",
|
|
||||||
"Farmacia", "Papelería / Librería", "Floristería", "Joyería / Relojería", "Zapatería",
|
|
||||||
"Ropa y moda", "Juguetería", "Ferretería", "Bazar / Todo a 100", "Estanco", "Quiosco",
|
|
||||||
"Óptica", "Ortopedia", "Tienda de mascotas", "Electrodomésticos",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"Peluquería y estética": {
|
|
||||||
emoji: "✂️",
|
|
||||||
color: "#76448A",
|
|
||||||
bgLight: "#F5EEF8",
|
|
||||||
textColor: "#76448A",
|
|
||||||
subcategorias: [
|
|
||||||
"Peluquería señora", "Peluquería caballero", "Peluquería unisex", "Barbería",
|
|
||||||
"Centro de estética", "Uñas / Manicura", "Depilación", "Masajes y spa",
|
|
||||||
"Tatuajes y piercings", "Centro de bronceado", "Micropigmentación",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"Servicios del hogar": {
|
|
||||||
emoji: "🔧",
|
|
||||||
color: "#1E8449",
|
|
||||||
bgLight: "#EAFAF1",
|
|
||||||
textColor: "#1E8449",
|
|
||||||
subcategorias: [
|
|
||||||
"Cerrajería", "Fontanería / Plomería", "Electricidad", "Reformas y construcción",
|
|
||||||
"Pintura", "Carpintería", "Cristalería", "Climatización / Aire acondicionado",
|
|
||||||
"Mudanzas", "Limpieza", "Jardinería", "Instalación solar", "Alarmas y seguridad",
|
|
||||||
"Reparación electrodomésticos",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"Otros": {
|
|
||||||
emoji: "📌",
|
|
||||||
color: "#784212",
|
|
||||||
bgLight: "#FDF2E9",
|
|
||||||
textColor: "#784212",
|
|
||||||
subcategorias: [
|
|
||||||
"Taller mecánico", "Lavado de coches", "Academia / Clases", "Gestoría / Asesoría",
|
|
||||||
"Inmobiliaria", "Agencia de viajes", "Fotografía", "Informática / Reparación móviles",
|
|
||||||
"Copistería / Imprenta", "Veterinaria", "Gimnasio / Fitness", "Centro médico / Clínica",
|
|
||||||
"Fisioterapia", "Psicología", "Lavandería", "Tintorería", "Otro",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const NOMBRES_CATEGORIAS = Object.keys(CATEGORIAS);
|
|
||||||
|
|
||||||
export const COLORES_MAPA = NOMBRES_CATEGORIAS.reduce((acc, nombre) => {
|
|
||||||
acc[nombre] = CATEGORIAS[nombre].color;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
// Categoría comodín usada cuando un local trae una categoría desconocida o vacía
|
|
||||||
// (por ejemplo, datos antiguos que ya no coinciden con el catálogo actual).
|
|
||||||
export const CATEGORIA_DESCONOCIDA = {
|
|
||||||
emoji: "📍",
|
|
||||||
color: "#6e6e73",
|
|
||||||
bgLight: "var(--color-background-secondary)",
|
|
||||||
textColor: "var(--color-text-secondary)",
|
|
||||||
subcategorias: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export function obtenerCategoria(nombre) {
|
|
||||||
return CATEGORIAS[nombre] || CATEGORIA_DESCONOCIDA;
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
// Provincias españolas y sus coordenadas aproximadas (capital de provincia),
|
|
||||||
// usadas como centro de referencia en el mapa cuando un local no trae lat/lng propios.
|
|
||||||
|
|
||||||
export const PROVINCIAS = [
|
|
||||||
"Álava", "Albacete", "Alicante", "Almería", "Asturias", "Ávila", "Badajoz", "Barcelona",
|
|
||||||
"Burgos", "Cáceres", "Cádiz", "Cantabria", "Castellón", "Ciudad Real", "Córdoba", "Cuenca",
|
|
||||||
"Gerona", "Granada", "Guadalajara", "Guipúzcoa", "Huelva", "Huesca", "Islas Baleares", "Jaén",
|
|
||||||
"La Coruña", "La Rioja", "Las Palmas", "León", "Lérida", "Lugo", "Madrid", "Málaga", "Murcia",
|
|
||||||
"Navarra", "Orense", "Palencia", "Pontevedra", "Salamanca", "Santa Cruz de Tenerife", "Segovia",
|
|
||||||
"Sevilla", "Soria", "Tarragona", "Teruel", "Toledo", "Valencia", "Valladolid", "Vizcaya",
|
|
||||||
"Zamora", "Zaragoza",
|
|
||||||
];
|
|
||||||
|
|
||||||
export const COORDS_PROVINCIAS = {
|
|
||||||
"Álava": [42.8467, -2.6726], "Albacete": [38.9942, -1.8585], "Alicante": [38.3452, -0.4815],
|
|
||||||
"Almería": [36.834, -2.4637], "Asturias": [43.3614, -5.8593], "Ávila": [40.6566, -4.6814],
|
|
||||||
"Badajoz": [38.8794, -6.9707], "Barcelona": [41.3851, 2.1734], "Burgos": [42.344, -3.6969],
|
|
||||||
"Cáceres": [39.4753, -6.3724], "Cádiz": [36.5271, -6.2886], "Cantabria": [43.4623, -3.8099],
|
|
||||||
"Castellón": [39.9864, -0.0513], "Ciudad Real": [38.9848, -3.9274], "Córdoba": [37.8882, -4.7794],
|
|
||||||
"Cuenca": [40.0704, -2.1374], "Gerona": [41.9794, 2.8214], "Granada": [37.1773, -3.5986],
|
|
||||||
"Guadalajara": [40.6328, -3.1614], "Guipúzcoa": [43.3183, -1.9812], "Huelva": [37.2614, -6.9447],
|
|
||||||
"Huesca": [42.1401, -0.4089], "Islas Baleares": [39.5696, 2.6502], "Jaén": [37.7796, -3.7849],
|
|
||||||
"La Coruña": [43.3623, -8.4115], "La Rioja": [42.4627, -2.4449], "Las Palmas": [28.1235, -15.4366],
|
|
||||||
"León": [42.5987, -5.5671], "Lérida": [41.6178, 0.62], "Lugo": [43.0097, -7.5567],
|
|
||||||
"Madrid": [40.4168, -3.7038], "Málaga": [36.7213, -4.4214], "Murcia": [37.9922, -1.1307],
|
|
||||||
"Navarra": [42.8169, -1.6432], "Orense": [42.3362, -7.8639], "Palencia": [42.0097, -4.5288],
|
|
||||||
"Pontevedra": [42.4333, -8.65], "Salamanca": [40.9701, -5.6635], "Santa Cruz de Tenerife": [28.4636, -16.2518],
|
|
||||||
"Segovia": [40.9429, -4.1088], "Sevilla": [37.3891, -5.9845], "Soria": [41.764, -2.464],
|
|
||||||
"Tarragona": [41.1189, 1.2445], "Teruel": [40.344, -1.1065], "Toledo": [39.8628, -4.0273],
|
|
||||||
"Valencia": [39.4699, -0.3763], "Valladolid": [41.6523, -4.7245], "Vizcaya": [43.263, -2.935],
|
|
||||||
"Zamora": [41.5034, -5.7446], "Zaragoza": [41.6488, -0.8891],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CENTRO_ESPANA = [40.4, -3.7];
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// firebase.js - Re-exports local API instead of Firebase
|
|
||||||
export {
|
|
||||||
escucharAuth,
|
|
||||||
loginAdmin,
|
|
||||||
registrarAdmin,
|
|
||||||
cerrarSesion,
|
|
||||||
suscribirLocales,
|
|
||||||
deleteLocal,
|
|
||||||
confirmarLocal,
|
|
||||||
reportarLocal,
|
|
||||||
obtenerReportes,
|
|
||||||
descartarReporte,
|
|
||||||
enviarPropuesta,
|
|
||||||
comprobarDuplicado,
|
|
||||||
suscribirPendientes,
|
|
||||||
aprobarPropuesta,
|
|
||||||
rechazarPropuesta,
|
|
||||||
obtenerPalabrasFiltradas,
|
|
||||||
guardarPalabrasFiltradas,
|
|
||||||
contienepalabrasProhibidas,
|
|
||||||
} from "./api.js";
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
const CLAVE = "localesp_favoritos";
|
|
||||||
|
|
||||||
function leerFavoritos() {
|
|
||||||
try {
|
|
||||||
const datos = JSON.parse(localStorage.getItem(CLAVE) || "[]");
|
|
||||||
return Array.isArray(datos) ? datos : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Favoritos guardados en el propio dispositivo (localStorage), sin necesidad
|
|
||||||
// de cuenta de usuario. Sincroniza entre pestañas mediante el evento "storage".
|
|
||||||
export default function useFavoritos() {
|
|
||||||
const [favoritos, setFavoritos] = useState(leerFavoritos);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const alCambiar = () => setFavoritos(leerFavoritos());
|
|
||||||
window.addEventListener("storage", alCambiar);
|
|
||||||
return () => window.removeEventListener("storage", alCambiar);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const esFavorito = useCallback((id) => favoritos.includes(id), [favoritos]);
|
|
||||||
|
|
||||||
const alternarFavorito = useCallback((id) => {
|
|
||||||
setFavoritos((actual) => {
|
|
||||||
const nuevo = actual.includes(id) ? actual.filter((x) => x !== id) : [...actual, id];
|
|
||||||
localStorage.setItem(CLAVE, JSON.stringify(nuevo));
|
|
||||||
return nuevo;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { favoritos, esFavorito, alternarFavorito };
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
// Detecta si la web se está ejecutando ya como app instalada (modo standalone),
|
|
||||||
// tanto en Chrome/Android (display-mode: standalone) como en Safari/iOS
|
|
||||||
// (navigator.standalone) o dentro de una TWA de Android (referrer android-app://).
|
|
||||||
function calcularSiEsStandalone() {
|
|
||||||
if (typeof window === "undefined") return false;
|
|
||||||
const enModoStandalone = window.matchMedia?.("(display-mode: standalone)")?.matches ?? false;
|
|
||||||
const enIosStandalone = window.navigator?.standalone === true;
|
|
||||||
const enTwaAndroid = document.referrer?.startsWith("android-app://") ?? false;
|
|
||||||
return enModoStandalone || enIosStandalone || enTwaAndroid;
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectarIos() {
|
|
||||||
if (typeof window === "undefined") return false;
|
|
||||||
return /iphone|ipad|ipod/i.test(window.navigator.userAgent) && !window.MSStream;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook que centraliza el estado de instalación de la PWA:
|
|
||||||
* - isInstalled: la app ya se está ejecutando en modo standalone (o el
|
|
||||||
* navegador confirma que está instalada mediante getInstalledRelatedApps).
|
|
||||||
* - canPromptInstall: el navegador ha ofrecido el evento nativo `beforeinstallprompt`,
|
|
||||||
* así que se puede lanzar el diálogo de instalación con promptInstall().
|
|
||||||
* - isIos: Safari/iOS no soporta beforeinstallprompt, así que ahí solo cabe
|
|
||||||
* mostrar instrucciones manuales ("Compartir → Añadir a pantalla de inicio").
|
|
||||||
*/
|
|
||||||
export default function usePwaInstall() {
|
|
||||||
const [installPromptEvent, setInstallPromptEvent] = useState(null);
|
|
||||||
const [isInstalled, setIsInstalled] = useState(calcularSiEsStandalone);
|
|
||||||
const [isIos] = useState(detectarIos);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const alCapturarPrompt = (evento) => {
|
|
||||||
evento.preventDefault();
|
|
||||||
setInstallPromptEvent(evento);
|
|
||||||
};
|
|
||||||
const alInstalar = () => {
|
|
||||||
setInstallPromptEvent(null);
|
|
||||||
setIsInstalled(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("beforeinstallprompt", alCapturarPrompt);
|
|
||||||
window.addEventListener("appinstalled", alInstalar);
|
|
||||||
|
|
||||||
const consultaDisplayMode = window.matchMedia?.("(display-mode: standalone)");
|
|
||||||
const alCambiarDisplayMode = () => setIsInstalled(calcularSiEsStandalone());
|
|
||||||
consultaDisplayMode?.addEventListener?.("change", alCambiarDisplayMode);
|
|
||||||
|
|
||||||
// Progressive enhancement: en navegadores compatibles (Chrome/Android),
|
|
||||||
// confirma si la PWA ya está instalada aunque se esté viendo en una pestaña normal.
|
|
||||||
navigator.getInstalledRelatedApps?.()
|
|
||||||
.then((apps) => { if (apps.length > 0) setIsInstalled(true); })
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("beforeinstallprompt", alCapturarPrompt);
|
|
||||||
window.removeEventListener("appinstalled", alInstalar);
|
|
||||||
consultaDisplayMode?.removeEventListener?.("change", alCambiarDisplayMode);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const promptInstall = useCallback(async () => {
|
|
||||||
if (!installPromptEvent) return "unavailable";
|
|
||||||
installPromptEvent.prompt();
|
|
||||||
const { outcome } = await installPromptEvent.userChoice;
|
|
||||||
setInstallPromptEvent(null);
|
|
||||||
return outcome; // "accepted" | "dismissed"
|
|
||||||
}, [installPromptEvent]);
|
|
||||||
|
|
||||||
return { isInstalled, canPromptInstall: Boolean(installPromptEvent), promptInstall, isIos };
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { useCallback, useRef, useState } from "react";
|
|
||||||
|
|
||||||
// Aviso breve tipo "snackbar" que aparece y desaparece solo. Uso:
|
|
||||||
// const { toast, mostrarToast } = useToast();
|
|
||||||
// mostrarToast("Añadido a favoritos", "exito");
|
|
||||||
// <Toast toast={toast} />
|
|
||||||
export default function useToast() {
|
|
||||||
const [toast, setToast] = useState(null); // { mensaje, tipo }
|
|
||||||
const timerRef = useRef(null);
|
|
||||||
|
|
||||||
const mostrarToast = useCallback((mensaje, tipo = "info", duracion = 2200) => {
|
|
||||||
clearTimeout(timerRef.current);
|
|
||||||
setToast({ mensaje, tipo, key: Date.now() });
|
|
||||||
timerRef.current = setTimeout(() => setToast(null), duracion);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { toast, mostrarToast };
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { useCallback, useState } from "react";
|
|
||||||
|
|
||||||
// Pide la ubicación del navegador solo cuando el usuario la solicita explícitamente
|
|
||||||
// (botón "Cerca de mí"), nunca automáticamente al cargar la página.
|
|
||||||
export default function useUbicacion() {
|
|
||||||
const [ubicacion, setUbicacion] = useState(null); // { lat, lng }
|
|
||||||
const [buscando, setBuscando] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
const pedirUbicacion = useCallback(() => {
|
|
||||||
if (!navigator.geolocation) {
|
|
||||||
setError("Tu navegador no permite compartir ubicación.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBuscando(true);
|
|
||||||
setError("");
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
|
||||||
(pos) => {
|
|
||||||
setUbicacion({ lat: pos.coords.latitude, lng: pos.coords.longitude });
|
|
||||||
setBuscando(false);
|
|
||||||
},
|
|
||||||
(err) => {
|
|
||||||
setError(err.code === 1 ? "Has denegado el acceso a tu ubicación." : "No se pudo obtener tu ubicación.");
|
|
||||||
setBuscando(false);
|
|
||||||
},
|
|
||||||
{ enableHighAccuracy: true, timeout: 10000 },
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const limpiarUbicacion = useCallback(() => { setUbicacion(null); setError(""); }, []);
|
|
||||||
|
|
||||||
return { ubicacion, buscando, error, pedirUbicacion, limpiarUbicacion };
|
|
||||||
}
|
|
||||||
@@ -1,26 +1,10 @@
|
|||||||
import { StrictMode } from "react";
|
import React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App.jsx";
|
import App from "./App.jsx";
|
||||||
import AdminPanel from "./AdminPanel.jsx";
|
import "./styles.css";
|
||||||
|
|
||||||
// Enrutador mínimo sin dependencias extra
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
const ruta = window.location.pathname.replace(/\/$/, "");
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
function Root() {
|
</React.StrictMode>
|
||||||
if (ruta === "/admin") return <AdminPanel />;
|
|
||||||
return <App />;
|
|
||||||
}
|
|
||||||
|
|
||||||
createRoot(document.getElementById("root")).render(
|
|
||||||
<StrictMode><Root /></StrictMode>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Registro del service worker: convierte la web en una PWA instalable
|
|
||||||
// (icono en pantalla de inicio, apertura en modo standalone, shell offline).
|
|
||||||
if ("serviceWorker" in navigator) {
|
|
||||||
window.addEventListener("load", () => {
|
|
||||||
navigator.serviceWorker.register("/sw.js").catch((err) => {
|
|
||||||
console.error("Error registrando el service worker:", err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export async function getApproved() {
|
||||||
|
const res = await fetch(`${import.meta.env.BASE_URL}api/businesses`);
|
||||||
|
if (!res.ok) throw new Error("Error cargando negocios");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submit(data) {
|
||||||
|
const res = await fetch(`${import.meta.env.BASE_URL}api/businesses`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Error enviando propuesta");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPending() {
|
||||||
|
const res = await fetch(`${import.meta.env.BASE_URL}api/admin/pending`);
|
||||||
|
if (!res.ok) throw new Error("Error cargando pendientes");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setStatus(id, status) {
|
||||||
|
const res = await fetch(`${import.meta.env.BASE_URL}api/admin/businesses/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Error actualizando estado");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #fafaf7;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--muted: #6b6b6b;
|
||||||
|
--border: #e6e6e2;
|
||||||
|
--accent: #2f6f4f;
|
||||||
|
--accent-hover: #245a3f;
|
||||||
|
--shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
--sidebar-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
background: var(--surface);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detalles */
|
||||||
|
.details {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details h2 {
|
||||||
|
margin: 12px 0 4px;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mapa */
|
||||||
|
.map {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-hint {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Botones */
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2000;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 28px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal .hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal input,
|
||||||
|
.modal textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-top: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal input:focus,
|
||||||
|
.modal textarea:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -1px;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-status {
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repick-link {
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border-color: var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repick-link:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admin */
|
||||||
|
.admin-btn {
|
||||||
|
margin-top: 8px;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel {
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header .close {
|
||||||
|
position: static;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-empty {
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-item-info {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-item-info strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-item-info p {
|
||||||
|
margin: 2px 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-desc {
|
||||||
|
color: var(--text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-contact, .admin-coords {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions .approve {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions .approve:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions .reject {
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #c0392b;
|
||||||
|
border-color: #e0b0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions .reject:hover {
|
||||||
|
background: #fff5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.app {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 45%;
|
||||||
|
border-right: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
// Estilos inline reutilizados en varios componentes de la web pública.
|
|
||||||
// Se mantiene el enfoque de estilos inline del proyecto original (sin CSS
|
|
||||||
// modules/Tailwind), pero centralizado aquí para no repetir el mismo objeto
|
|
||||||
// en cada archivo.
|
|
||||||
|
|
||||||
export 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",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ancho máximo del cuerpo central de la página (App.jsx). Los elementos fijos
|
|
||||||
// (p. ej. el aviso PWA en PC) se alinean a su borde de contenido con
|
|
||||||
// calc((100vw - ANCHO_CUERPO) / 2 + padding), de modo que respiren con la
|
|
||||||
// columna aunque la ventana sea muy ancha.
|
|
||||||
export const ANCHO_CUERPO = 720;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
// 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.
|
|
||||||
export function enlaceGoogleMapsDesdeLocal(local) {
|
|
||||||
if (local?.lat && local?.lng) return `https://www.google.com/maps/search/?api=1&query=${local.lat},${local.lng}`;
|
|
||||||
if (local?.direccion) return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(local.direccion)}`;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function enlaceWazeDesdeLocal(local) {
|
|
||||||
if (local?.lat && local?.lng) return `https://waze.com/ul?ll=${local.lat},${local.lng}&navigate=yes`;
|
|
||||||
if (local?.direccion) return `https://waze.com/ul?q=${encodeURIComponent(local.direccion)}&navigate=yes`;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Distancia en kilómetros entre dos coordenadas (fórmula de Haversine),
|
|
||||||
// usada para "cerca de mí" y para mostrar la distancia en cada tarjeta.
|
|
||||||
export function distanciaKm(lat1, lng1, lat2, lng2) {
|
|
||||||
const R = 6371;
|
|
||||||
const toRad = (d) => (d * Math.PI) / 180;
|
|
||||||
const dLat = toRad(lat2 - lat1);
|
|
||||||
const dLng = toRad(lng2 - lng1);
|
|
||||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
|
||||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatearDistancia(km) {
|
|
||||||
if (km == null) return "";
|
|
||||||
return km < 1 ? `${Math.round(km * 1000)} m` : `${km.toFixed(1)} km`;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 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
|
|
||||||
// una clave de Google Places con facturación activada) pero da el mismo resultado
|
|
||||||
// práctico: escribes un nombre y aparecen varias opciones para elegir.
|
|
||||||
export async function buscarLugares(texto) {
|
|
||||||
const q = (texto || "").trim();
|
|
||||||
if (q.length < 3) return [];
|
|
||||||
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(`${q}, España`)}&format=json&addressdetails=1&namedetails=1&limit=6&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 [];
|
|
||||||
return datos.map((d) => ({
|
|
||||||
id: d.place_id,
|
|
||||||
nombre: d.namedetails?.name || d.display_name.split(",")[0],
|
|
||||||
direccion: d.display_name,
|
|
||||||
provincia: d.address?.county || d.address?.province || d.address?.state || d.address?.city || "",
|
|
||||||
lat: parseFloat(d.lat),
|
|
||||||
lng: parseFloat(d.lon),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
// 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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
// 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 || "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
// @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: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
// En pantallas anchas la tarjeta acompaña a la columna de 720px (mismo borde
|
|
||||||
// que el contenido); en estrechas queda a 16px del borde de la ventana.
|
|
||||||
const PATRON_ALINEADO_CUERPO = /left:\s*max\(16px,\s*[^;]*100vw[^;]*720px[^;]*16px[^;]*\)/;
|
|
||||||
|
|
||||||
function contenedorFijo() {
|
|
||||||
const fijos = [...document.querySelectorAll("div")].filter((d) => d.style.position === "fixed");
|
|
||||||
expect(fijos.length).toBe(1);
|
|
||||||
return fijos[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
estadoHook.isInstalled = false;
|
|
||||||
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 (PC y móvil): abajo a la izquierda, alineado al cuerpo", () => {
|
|
||||||
it("en PC: tarjeta abajo a la izquierda alineada con el borde del cuerpo central, más ancha", () => {
|
|
||||||
vi.stubGlobal("matchMedia", matchMediaMock(false));
|
|
||||||
render(<BannerMovil />);
|
|
||||||
expect(screen.getByText("App para móvil")).toBeTruthy();
|
|
||||||
|
|
||||||
const contenedor = contenedorFijo();
|
|
||||||
expect(contenedor.style.bottom).toBe("0px");
|
|
||||||
expect(contenedor.getAttribute("style")).toMatch(PATRON_ALINEADO_CUERPO);
|
|
||||||
expect(contenedor.style.right).toBe(""); // nunca a la derecha, ahí vive el FAB
|
|
||||||
// en PC la tarjeta es más grande
|
|
||||||
const tarjeta = screen.getByRole("button", { name: "Cerrar aviso de instalación" }).parentElement;
|
|
||||||
expect(tarjeta.style.width).toBe("320px");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("en móvil: tarjeta abajo a la izquierda, a 16px en ventana estrecha, más compacta", () => {
|
|
||||||
vi.stubGlobal("matchMedia", matchMediaMock(true));
|
|
||||||
render(<BannerMovil />);
|
|
||||||
const contenedor = contenedorFijo();
|
|
||||||
// misma fórmula: con vw < 720px el navegador la evalúa a 16px
|
|
||||||
expect(contenedor.getAttribute("style")).toMatch(PATRON_ALINEADO_CUERPO);
|
|
||||||
expect(contenedor.style.bottom).toBe("0px");
|
|
||||||
expect(contenedor.style.right).toBe("");
|
|
||||||
const tarjeta = screen.getByRole("button", { name: "Cerrar aviso de instalación" }).parentElement;
|
|
||||||
expect(tarjeta.style.width).toBe("280px");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("el ✕ aplica la regla de 14 días y oculta el aviso", () => {
|
|
||||||
vi.stubGlobal("matchMedia", matchMediaMock(false));
|
|
||||||
render(<BannerMovil />);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Cerrar aviso de instalación" }));
|
|
||||||
expect(localStorage.getItem("localesp_banner_cerrado_hasta")).toBeTruthy();
|
|
||||||
expect(screen.queryByText("App para móvil")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("con diálogo nativo, el botón de la tarjeta instala directamente", async () => {
|
|
||||||
vi.stubGlobal("matchMedia", matchMediaMock(false));
|
|
||||||
estadoHook.canPromptInstall = true;
|
|
||||||
render(<BannerMovil />);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /instalar app/i }));
|
|
||||||
expect(estadoHook.promptInstall).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sin diálogo nativo muestra las instrucciones del navegador (no iOS)", () => {
|
|
||||||
vi.stubGlobal("matchMedia", matchMediaMock(false));
|
|
||||||
render(<BannerMovil />);
|
|
||||||
expect(screen.getByText(/Instálala desde el menú de tu navegador/i)).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
||||||
import BotonFlotante from "../../src/components/BotonFlotante.jsx";
|
|
||||||
|
|
||||||
const matchMediaMock = (esMovil) => (_query) => ({
|
|
||||||
matches: esMovil,
|
|
||||||
media: _query,
|
|
||||||
addEventListener: () => {},
|
|
||||||
removeEventListener: () => {},
|
|
||||||
addListener: () => {},
|
|
||||||
removeListener: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// por defecto PC (botón grande); cada prueba puede sobreescribirlo
|
|
||||||
vi.stubGlobal("matchMedia", matchMediaMock(false));
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("BotonFlotante", () => {
|
|
||||||
it("expone su propósito con aria-label (accesible sin hover)", () => {
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("está abajo a la derecha, alineado con el borde derecho del cuerpo central, y en PC es más grande", () => {
|
|
||||||
render(<BotonFlotante onClick={() => {}} />);
|
|
||||||
const boton = screen.getByRole("button");
|
|
||||||
const contenedor = boton.closest("div").parentElement; // button → relativo → fijo
|
|
||||||
expect(contenedor.style.position).toBe("fixed");
|
|
||||||
expect(contenedor.style.bottom).toBe("0px");
|
|
||||||
// en pantallas anchas acompaña a la columna de 720px; mínimo 16px
|
|
||||||
expect(contenedor.getAttribute("style")).toMatch(/right:\s*max\(16px,\s*[^;]*100vw[^;]*720px[^;]*16px[^;]*\)/);
|
|
||||||
// en PC el FAB mide 64px (en móvil 54)
|
|
||||||
expect(boton.style.width).toBe("64px");
|
|
||||||
expect(boton.style.height).toBe("64px");
|
|
||||||
expect(boton.style.fontSize).toBe("28px");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("en móvil mantiene el tamaño compacto (54px)", () => {
|
|
||||||
vi.stubGlobal("matchMedia", matchMediaMock(true));
|
|
||||||
render(<BotonFlotante onClick={() => {}} />);
|
|
||||||
const boton = screen.getByRole("button");
|
|
||||||
expect(boton.style.width).toBe("54px");
|
|
||||||
expect(boton.style.fontSize).toBe("24px");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
||||||
import BotonProponerArriba from "../../src/components/BotonProponerArriba.jsx";
|
|
||||||
|
|
||||||
const matchMediaMock = (esMovil) => (_query) => ({
|
|
||||||
matches: esMovil,
|
|
||||||
media: _query,
|
|
||||||
addEventListener: () => {},
|
|
||||||
removeEventListener: () => {},
|
|
||||||
addListener: () => {},
|
|
||||||
removeListener: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// por defecto PC; cada prueba que lo necesite lo sobreescribe
|
|
||||||
vi.stubGlobal("matchMedia", matchMediaMock(false));
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("BotonProponerArriba (solo PC)", () => {
|
|
||||||
it("en PC: píldora arriba a la derecha, alineada con el borde del cuerpo, que abre la pasarela", () => {
|
|
||||||
const onClick = vi.fn();
|
|
||||||
render(<BotonProponerArriba onClick={onClick} />);
|
|
||||||
const boton = screen.getByRole("button", { name: /añade un nuevo local al directorio/i });
|
|
||||||
expect(boton.textContent).toContain("Proponer local");
|
|
||||||
|
|
||||||
expect(boton.style.position).toBe("fixed");
|
|
||||||
expect(boton.style.top).toBe("20px");
|
|
||||||
// alineado al borde derecho de la columna de 720px; mínimo 16px
|
|
||||||
expect(boton.getAttribute("style")).toMatch(/right:\s*max\(16px,\s*[^;]*100vw[^;]*720px[^;]*16px[^;]*\)/);
|
|
||||||
|
|
||||||
fireEvent.click(boton);
|
|
||||||
expect(onClick).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("en móvil no se renderiza", () => {
|
|
||||||
vi.stubGlobal("matchMedia", matchMediaMock(true));
|
|
||||||
render(<BotonProponerArriba onClick={() => {}} />);
|
|
||||||
expect(screen.queryByRole("button")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
// @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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
// @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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
// @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
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
// @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]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
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
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// 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;
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||