Author SHA1 Message Date
edgar.friendly 30b7cc884e ci: verificación de statuses tras reinicio de runner y gitea
deploy-branch / deploy (push) Successful in 2m13s
deploy-branch / teardown (push) Skipped
tests / Unitarias + componente + build (push) Successful in 2m41s
tests / Integración (Nominatim real, opt-in) (push) Skipped
tests / Unitarias + componente + build (pull_request) Successful in 2m37s
tests / Integración (Nominatim real, opt-in) (pull_request) Skipped
2026-09-10 18:33:12 +02:00
edgar.friendly d0fa037010 ci(tests): activa gcc-toolset-12 para compilar better-sqlite3 con npm ci
deploy-branch / deploy (push) Successful in 2m35s
deploy-branch / teardown (push) Skipped
tests / Unitarias + componente + build (push) Successful in 2m47s
tests / Integración (Nominatim real, opt-in) (push) Skipped
2026-08-18 07:42:42 +02:00
edgar.friendly 00eaa1d209 ci: mueve tests.yml a .gitea/workflows (ubicación que este Gitea ejecuta)
deploy-branch / deploy (push) Successful in 2m37s
deploy-branch / teardown (push) Skipped
tests / Unitarias + componente + build (push) Failing after 1m48s
tests / Integración (Nominatim real, opt-in) (push) Skipped
2026-08-18 07:30:59 +02:00
edgar.friendly 0c5651a6c0 fix(ci): compatibilidad con el runner self-hosted (npm 10 / Node 20)
deploy-branch / deploy (push) Successful in 2m43s
deploy-branch / teardown (push) Skipped
- vitest 4 traía vite 8 anidado con esbuild 0.28: el lockfile quedaba
  incompleto para npm ci y el npm 10.8.2 del runner fallaba con
  "Missing @esbuild/* from lock file" / EBADPLATFORM. vitest 3 usa el
  vite 5 del proyecto como peer: sin árbol anidado, esbuild solo 0.21.5
- jsdom 30 exige Node >=22.22 (el runner tiene 20.20): bajado a jsdom 26
- tests.yml sobre el runner real (self-hosted, Node del sistema) en vez
  de ubuntu-latest, que no existe como label
- aserciones de max()/calc() tolerantes a la serialización de jsdom 26
2026-08-18 07:27:19 +02:00
edgar.friendly 7bb3ea22da chore(openspec): archiva pasarela-proponer-local y sincroniza specs principales
deploy-branch / deploy (push) Failing after 6s
deploy-branch / teardown (push) Skipped
- Mueve el cambio (36/36 tareas, 4/4 artefactos) a changes/archive/
- Crea las specs principales de boton-flotante-proponer-local,
  pasarela-proponer-local y proveedores-ubicacion (con Purpose)
2026-08-16 11:51:11 +02:00
edgar.friendly 3ce84531d1 fix(ui): botones alineados al cuerpo, FAB grande en PC y botón superior de propuesta
- FAB y aviso PWA flanquean la columna central (720px): right/left
  max(16px, calc((100vw - 720px)/2 + 16px)) para acompañar al contenido
  en pantallas anchas sin pegarse al borde de la ventana
- FAB de 64px en PC (54 en móvil); PWA abajo a la izquierda en PC y móvil
- Nuevo BotonProponerArriba (solo PC): píldora "Proponer local" arriba a
  la derecha, alineada al cuerpo, que abre la misma pasarela
2026-08-16 11:50:59 +02:00
edgar.friendly 003b1e209f feat(propuesta): pasarela paso a paso con autodetección de ubicación y FAB
deploy-branch / deploy (push) Failing after 6s
deploy-branch / teardown (push) Skipped
- Campo universal de ubicación (Google Maps/Waze/OSM/coordenadas/dirección)
  sobre proveedores extensibles (src/utils/ubicacion) con resolver e inferencia
  inversa Nominatim (nombre/provincia pre-rellenados, alias cooficiales)
- FAB flotante abajo a la derecha que abre la pasarela en un modal accesible
  (Escape, foco contenido, responsive); sin descripción ni puntuación;
  avisos de duplicado y filtro de palabras dentro del modal
- Aviso PWA reposicionado: tarjeta abajo a la izquierda en móvil,
  botón compacto arriba a la izquierda en PC (no tapa el FAB)
- Suite de pruebas: 88 unitarias/componente (vitest + RTL, offline) y
  10 de integración (supertest + SQLite :memory: y Nominatim real opt-in)
- CI: workflow de Gitea Actions con npm test + build en push/PR e
  integración manual
2026-08-16 01:35:02 +02:00
edgar.friendly d48b74cbd4 feat(ui): notificaciones toast y mapa que sobrevive al cambio de pestaña
deploy-branch / deploy (push) Successful in 2m41s
deploy-branch / teardown (push) Skipped
- Añade sistema de avisos tipo snackbar (Toast.jsx + useToast.js) con
  auto-cierre, animación de entrada y estilos info/éxito/error.
- LocalCard: feedback visual al añadir/quitar un local de favoritos.
- AdminPanel: sustituye los alert() por toasts al aprobar, rechazar,
  eliminar locales, descartar avisos y guardar el filtro de palabras;
  además saluda con un toast tras iniciar sesión o crear cuenta.
- App: el contenedor del mapa Leaflet queda siempre montado (oculto con
  CSS) y se llama a invalidateSize() al volver a la pestaña de mapa;
  así el mapa ya no se rompe al navegar entre vistas (antes obligaba
  a recargar la página).
2026-08-15 20:37:04 +02:00
edgar.friendly b36867225c feat: directorio más fiable y más fácil de usar
deploy-branch / deploy (push) Successful in 2m34s
deploy-branch / teardown (push) Skipped
El objetivo de estos cambios es, por un lado, mantener los datos
actualizados y, por otro, mejorar la experiencia para quienes lo usan:

- Fiabilidad: los locales se pueden confirmar ("Sigue aquí") y
  reportar (cerrado, datos incorrectos...), y se avisa de duplicados
  antes de publicar, para que el contenido no se quede obsoleto ni
  repetido.

- Usabilidad: autocompletado al proponer, favoritos, "cerca de mí",
  compartir fichas, enlaces a Waze y una política de privacidad
  visible, para que sea más sencillo aportar y encontrar locales.
2026-08-10 22:23:35 +02:00
edgar.friendly 257fb9c622 refactor(ui): modulariza App.jsx y unifica branding y caché PWA
deploy-branch / deploy (push) Successful in 2m36s
deploy-branch / teardown (push) Skipped
- Extrae App.jsx en módulos reutilizables: constants/ (categorias,
  provincias), utils/geo, styles/shared, hooks/usePwaInstall y
  components/ (StarRating, LocalCard, CampoUbicacion, BannerMovil,
  CategoriaBadge)
- AdminPanel reutiliza constantes y componentes compartidos y corrige
  la limpieza de suscripciones de Firebase al desmontar
- Añade logos de marca (logo-localesp.png + @2x) y regenera los iconos
  PWA con tamaños menores
- sw.js: sube la caché del shell a v2 e incluye los nuevos iconos y logos
2026-08-05 19:07:00 +02:00
edgar.friendly 28d066b5af feat(pwa): añade assets PWA que faltaban (manifest, sw.js, iconos)
deploy-branch / deploy (push) Successful in 2m49s
deploy-branch / teardown (push) Skipped
El commit anterior (a3fd928) referenciaba /manifest.webmanifest,
/sw.js, /favicon-32.png y /apple-touch-icon.png en index.html y
main.jsx, pero los archivos no existían: el fallback SPA los servía
como index.html (200 text/html), así que la PWA no era instalable y
parecía que el desplegue no había actualizado.

Se añaden en public/ (Vite los copia a dist/ raíz):
- manifest.webmanifest: nombre, theme_color #8B0000, iconos any/maskable.
- sw.js: cache del shell con stale-while-revalidate (sirve cache al
  instante y revalida en segundo plano) + network-first para /api/.
  Versión de caché localesp-shell-v1.
- Iconos: favicon-32, apple-touch-icon (180), icon-192, icon-512 y
  icon-512-maskable.
2026-08-02 12:11:40 +02:00
edgar.friendly a3fd928073 Migración de esquema DB y soporte PWA:
deploy-branch / deploy (push) Successful in 2m16s
deploy-branch / teardown (push) Skipped
- Mapeo campos en base de datos: ubicacion→provincia, telefono→subcategoria,
  email→direccion, web→enlaceGoogleMaps y añadido puntuacion.
- Migración suave (ensureColumn) para añadir las nuevas columnas sin perder
  datos existentes si la DB ya existía con el esquema antiguo.

- Actualización de rutas del servidor para guardar/leer los nuevos campos y
  convertir el NULL a valor nulo por defecto en puntuacion.

- Soporte PWA: añadido meta tags (manifest, theme-color, apple-touch-icon) y
  registro de service worker para instalación del app y shell offline.
2026-08-01 19:27:57 +02:00
edgar.friendly d1fec59dcb ci: despliegue automático por rama (workflow + scripts OOP)
deploy-branch / deploy (push) Successful in 2m15s
deploy-branch / teardown (push) Skipped
Añade la automatización de despliegue a esta rama (no la tenía):
- .gitea/workflows/deploy.yml: build + deploy on push, teardown on delete.
- scripts/lib/env.sh + deploy.sh + teardown.sh: lib OOP (Env + SystemdUnit/
  NginxVhost/TlsCert) y entrypoints finos.

El código de la app NO se modifica. Traído path-scoped desde master (6cc326e).
Cada push a prototipo-yb01 publicará la app en prototipo-yb01.localesp.es.
2026-08-01 18:30:00 +02:00
edgar.friendly bd4282c426 Add support for sqlite 2026-06-07 23:58:53 +02:00
edgar.friendly 1b67900d54 Primera versión 2026-05-20 13:55:15 +02:00
91 changed files with 11221 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
name: deploy-branch
# CI/CD: cada push a cualquier rama se despliega en <rama>.localesp.es
# Estrategia A — un entorno por rama. El registro DNS A es manual (precondición).
# Detalles: docs/07-cicd.md
on:
push:
branches:
- "**"
delete:
workflow_dispatch:
inputs:
branch:
description: "Rama a desplegar (vacío = la actual)"
required: false
default: ""
jobs:
deploy:
if: github.event_name != 'delete'
runs-on: self-hosted
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build (npm ci + vite build)
shell: bash
run: |
# better-sqlite3 (v12) requiere C++20; el GCC 8 del sistema (AlmaLinux 8) no lo soporta.
# gcc-toolset-12 (GCC 12) ya está instalado en /opt/rh.
source /opt/rh/gcc-toolset-12/enable 2>/dev/null || echo "WARN: gcc-toolset-12 no encontrado"
npm ci
npm run build
- name: Deploy branch
env:
BRANCH: ${{ github.event.inputs.branch || github.ref_name }}
run: bash scripts/deploy.sh "$BRANCH"
# Al borrar una rama, se limpia su entorno
teardown:
if: github.event_name == 'delete'
runs-on: self-hosted
timeout-minutes: 5
steps:
- name: Checkout (default branch, trae scripts/)
uses: actions/checkout@v4
- name: Teardown branch env
env:
# github.event.ref en un 'delete' es refs/heads/<rama>
BRANCH: ${{ github.event.ref }}
run: bash scripts/teardown.sh "${BRANCH#refs/heads/}"
+49
View File
@@ -0,0 +1,49 @@
# CI: ejecuta la suite rápida de pruebas (offline) y el build en cada push y PR.
# La suite de integración con Nominatim (red real) es opt-in: se lanza a mano
# desde la pestaña Actions del repositorio, y un fallo de red no rompe el CI.
name: tests
on:
push:
pull_request:
workflow_dispatch:
jobs:
test:
name: Unitarias + componente + build
# El runner disponible es self-hosted (AlmaLinux, Node 20 del sistema);
# las deps soportan Node >=18/20 (jsdom@26), así que no hace falta setup-node.
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Instalar dependencias
# better-sqlite3 (v12) requiere C++20 y el GCC 8 base de AlmaLinux 8
# no lo soporta: se activa gcc-toolset-12 igual que en deploy.yml
shell: bash
run: |
source /opt/rh/gcc-toolset-12/enable 2>/dev/null || echo "WARN: gcc-toolset-12 no encontrado"
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: self-hosted
if: github.event_name == 'workflow_dispatch'
continue-on-error: true # depende de un servicio externo: informativa
steps:
- uses: actions/checkout@v4
- name: Instalar dependencias
shell: bash
run: |
source /opt/rh/gcc-toolset-12/enable 2>/dev/null || echo "WARN: gcc-toolset-12 no encontrado"
npm ci
- name: Pruebas de integración (API en memoria + Nominatim)
run: npm run test:integration
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
.env
.env.local
localesp.db
.claude/
.codex/
.pi/
+54
View File
@@ -0,0 +1,54 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Locales Españoles</title>
<meta name="description" content="Directorio colaborativo de negocios por toda España" />
<!-- No indexar el admin -->
<meta name="robots" content="index, follow" />
<!-- PWA -->
<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>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
[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
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-15
@@ -0,0 +1,116 @@
# Diseño — Pasarela para proponer locales
## Context
- `src/App.jsx` es la única app en producción (PC y móvil): `main.jsx` solo monta `App` y `AdminPanel`; `src/AppMovil.jsx` es código muerto no referenciado. Por tanto "funcionar para PC y móvil" se resuelve haciendo el FAB y el modal responsive dentro de `App.jsx`.
- El formulario actual es un bloque en línea (`mostrarForm`) con nombre, provincia, categoría, subcategoría, ubicación (dos modos: dirección / enlace de Google Maps), descripción y puntuación, más checkbox de privacidad, filtro de palabras prohibidas y comprobación de duplicados contra `server/index.js`.
- El backend ya tolera la ausencia de `descripcion` (columna nullable) y `puntuacion` (`?? 0` al insertar), así que eliminarlos del cliente no exige cambios de API ni migraciones.
- La extracción de coordenadas hoy es una única función `parsearEnlaceGoogleMaps` en `src/utils/geo.js`, y la geocodificación por texto se hace con Nominatim (`geocodificarDireccion`, `buscarLugares`).
- Convenciones del proyecto: estilos inline con variables CSS (`--color-*`), componentes pequeños en `src/components/`, utilidades puras en `src/utils/`, sin dependencias de UI externas. El patrón de modal existente es `ModalPrivacidad.jsx` (overlay `fixed` + `stopPropagation` + `role="dialog"`).
- PWA instalada (service worker): el FAB debe convivir con la barra de navegación de la PWA en iOS/Android (`env(safe-area-inset-bottom)`).
## Goals / Non-Goals
**Goals:**
- Sustituir el botón de cabecera por un FAB fijo abajo a la derecha, con tooltip, usable en PC y móvil.
- Pasarela en modal con un campo por pantalla, empezando por Ubicación.
- Campo universal de ubicación que autodetecta Google Maps / Waze / OpenStreetMap / coordenadas (fallback: texto vía Nominatim).
- Inferir Nombre y Provincia desde la ubicación (geocodificación inversa Nominatim), editables.
- Arquitectura de proveedores extensible: varias clases con una interfaz común (`detecta`/`extrae`), resultado extensible, registro abierto/cerrado.
- Eliminar comentario y puntuación del flujo de propuesta.
**Non-Goals:**
- Gestionar comentarios/valoraciones de otra forma (cambio posterior).
- Modificar backend, base de datos, panel de administración o `AppMovil.jsx`.
- Autocompletado en la pantalla de Nombre (se anota como mejora futura; ahora es un input simple pre-rellenado).
- Mapa de previsualización dentro del modal (se anota como mejora futura).
## Decisions
### D1 — Un único `App.jsx` responsive para PC y móvil
**Decisión:** FAB y pasarela se implementan en `App.jsx`; no se toca `AppMovil.jsx` (muerto).
**Alternativas:** duplicar la UI en `AppMovil.jsx` — rechazado: duplicaría lógica y mantiene vivo código que no está en producción.
### D2 — FAB con tooltip propio, sin dependencias
**Decisión:** botón `position: fixed; bottom/right` con `z-index` por debajo del modal (modal usa 1000) y `padding-bottom: env(safe-area-inset-bottom)` en móvil. Tooltip implementado con estado React (`onMouseEnter`/`onMouseLeave`/`onFocus`/`onBlur`) y posicionado a la izquierda del botón, con `aria-label` en el botón para accesibilidad sin ratón.
**Alternativas:** atributo `title` nativo — rechazado por no ser estilizable ni fiable en móvil/táctil; librería de tooltips — rechazada para no añadir dependencias a un proyecto sin ellas.
### D3 — Modal propio siguiendo el patrón `ModalPrivacidad`
**Decisión:** overlay `fixed` con `role="dialog"`/`aria-modal`, cierre con click en fondo, ✕ y `Escape` (listener de teclado en `useEffect`), foco inicial en el primer control de cada pantalla. En pantallas estrechas (`matchMedia` vía hook `useEsMovil`), el panel ocupa todo el ancho y casi todo el alto; en escritorio queda centrado con `maxWidth` ~480px. Sin portal: se renderiza al final del árbol de `App`, igual que `ModalPrivacidad`.
**Alternativas:** `createPortal` — innecesario aquí (no hay `overflow: hidden` ancestros problemáticos); librería de modals — descartada por política de cero dependencias de UI.
### D4 — Pasarela como máquina de pasos sobre el `form` existente
**Decisión:** el estado del formulario (`form`) vive en `App.jsx` como hoy; la pasarela añade un índice `paso` y un array declarativo de pantallas: `[ubicacion, nombre, provincia, categoria, subcategoria, resumen]`. Cada pantalla es un componente que recibe `form`/`setForm` y expone su validez (`puedeContinuar`) para habilitar "Siguiente". "Atrás" simplemente decrementa el índice (los datos se conservan). La pantalla final muestra el resumen + checkbox de privacidad y dispara el flujo de envío existente (`contienepalabrasProhibidas``comprobarDuplicado``guardarPropuesta`), con los avisos de error/duplicado renderizados dentro del modal.
**Alternativas:** cada paso con estado propio y despachar al final — más propenso a inconsistencias; mover el envío a un hook nuevo — innecesario, la lógica actual ya está aislada en funciones de `App.jsx`.
### D5 — `FORM_VACIO` sin `descripcion` ni `puntuacion`
**Decisión:** el objeto vacío del formulario elimina ambos campos y se dejan de enviar en el payload (`enviarPropuesta` los omite; el backend ya aplica defaults). `formValido` deja de exigir `puntuacion > 0`. `StarRating` deja de usarse en la propuesta (se conserva para mostrar valoraciones existentes en tarjetas).
**Alternativas:** seguir enviando `puntuacion: 0` y `descripcion: ""` explícitos — innecesario: el servidor ya normaliza.
### D6 — Arquitectura de proveedores de ubicación (núcleo extensible)
**Decisión:** nuevo módulo `src/utils/ubicacion/`:
```
src/utils/ubicacion/
ProveedorUbicacion.js # clase base = interfaz + helpers compartidos
proveedorGoogleMaps.js # detecta: *.google.* / maps.app.goo.gl …
proveedorWaze.js # detecta: waze.com/ul?ll=… (o q=texto → delega en Nominatim)
proveedorOSM.js # detecta: openstreetmap.org (mlat/mlon, #map=…)
proveedorCoordenadas.js # detecta: "41.38, 2.17" (± grados, coma o punto)
proveedorNominatim.js # fallback: texto de dirección → search; y reverse() para inferir
index.js # registro ordenado + resolverUbicacion(entrada) + inferirDesdeCoords()
```
- **Interfaz** (JS no tiene interfaces nativas): la clase base `ProveedorUbicacion` define el contrato documentado — `id`, `detecta(entrada) → boolean`, `extrae(entrada) → Promise<ResultadoUbicacion|null>` (la base lanza `Error("sin implementar")`, como una abstracta) — y helpers comunes: cabeceras/UA para Nominatim, `normalizar()` de textos y `emparejarProvincia()` contra `PROVINCIAS` (insensible a acentos/mayúsculas, reutilizando la lógica de `AutocompletarLocal`).
- **Resultado extensible:** `{ proveedor, lat, lng, nombre?, provincia?, direccion?, …extra }`. Los consumidores leen solo lo que conocen; añadir campos mañana (teléfono, horarios…) no rompe nada.
- **Resolver:** `resolverUbicacion(entrada)` recorre el array `PROVEEDORES` en orden de prioridad (coordenadas → Google → Waze → OSM → Nominatim texto) y devuelve el resultado del primero cuyo `detecta` acepte. Registrar un proveedor nuevo = añadir la clase y push al array (abierto/cerrado: ni el resolver ni las demás clases cambian).
- **Inferencia:** tras obtener coordenadas, `inferirDesdeCoords(lat, lng)` llama a `Nominatim reverse` (`/reverse?format=jsonv2&addressdetails=1&namedetails=1`) y devuelve `{ nombre, provincia, direccion }` con la provincia normalizada al listado canónico (si no casa, provincia `""` para forzar selección manual). `geocodificarDireccion`/`parsearEnlaceGoogleMaps` de `geo.js` se reubican como implementación interna de los proveedores (se mantiene `geo.js` exportando lo que usan `LocalCard` y el mapa: enlaces y distancias).
- **Errores de red:** todo `fetch` de proveedor va envuelto para devolver `null`/`{ ok: false }` controlado; la UI muestra aviso, nunca lanza al consumidor.
**Alternativas:** funciones sueltas con un `switch` — no extensible (habría que editar el switch por cada backend); cadena de responsabilidad con instancias singleton — equivalente pero más boilerplate en un codebase sin clases hasta ahora; *strategy registry* en JSON/config — pierde la capacidad de lógica por proveedor (p.ej. Waze delegando en Nominatim para `q=`).
### D7 — Pantalla de Ubicación: un solo input con feedback inmediato
**Decisión:** un input universal con debounce (~600ms). Al resolver, muestra un chip con el proveedor detectado (p. ej. "Google Maps detectado") y las coordenadas; si hay inferencia, se marcan `nombre`/`provincia` en el `form` (el usuario las ve ya pre-rellenadas en sus pantallas). Si `detecta` no reconoce nada, aviso "No se pudo interpretar la ubicación" y "Siguiente" deshabilitado hasta obtener una resolución válida.
**Alternativas:** mantener los dos modos (dirección/enlace) — rechazado por requisito ("un solo campo universal"); validar solo al pulsar Siguiente — peor UX, no avisa de errores mientras se pega el enlace.
### D8 — Envío y confirmación
**Decisión:** al enviar, el modal permanece abierto con estados "Comprobando…/Enviando…" y, si hay duplicado, muestra el aviso con las opciones actuales ("Enviar de todos modos" / "Revisar datos"). Tras éxito: cierra modal, resetea `form`, y muestra el banner de confirmación existente (`enviado`).
**Alternativas:** mover el banner dentro del modal — innecesario y menos visible.
## Risks / Trade-offs
- [Rate-limit de Nominatim (política ~1 req/s)] → Debounce de 600ms, una sola llamada por resolución (search **o** reverse, no ambas salvo fallback), y mensaje de error amable con reintento.
- [FAB solapa contenido o la barra PWA en móvil] → `safe-area-inset-bottom`, tamaño moderado (5256px) y `z-index` inferior al modal; se prueba en viewport estrecho.
- [Teclado móvil sube y tapa el modal] → modal con `height` flexible y `overflow-y: auto`; inputs al inicio de cada pantalla.
- [Enlaces acortados (maps.app.goo.gl, waze.to) sin coordenadas en la URL] → v1 no hace *unshorten* (requiere fetch al proveedor y CORS no garantizado): se avisa "pega el enlace completo"; se anota como mejora futura.
- [Inferencia errónea (nombre genérico tipo "Calle Mayor")] → los campos son editables y la pantalla de resumen lo hace evidente antes de enviar.
- [JS sin interfaces nativas] → el contrato se documenta en la clase base y se comprueba en el registro (métodos presentes); riesgo bajo al ser codebase propio.
## Migration Plan
1. Implementación puramente frontend (rama/PR normal). Sin cambios de esquema ni de API: desplegar y listo.
2. Rollback: revert del deploy (Netlify) — el backend nunca cambia de forma durante este cambio.
## Open Questions
- ¿Querremos *unshorten* de enlaces acortados en el futuro? (Anotado como mejora; no bloquea.) -> mejora
- ¿Autocompletado en la pantalla de Nombre reutilizando `AutocompletarLocal`? (Anotado como mejora futura; hoy input simple pre-rellenado.)
## Extensión: pruebas automatizadas (añadida tras la implementación inicial)
La verificación manual (sección 5 de tasks) se complementa con una suite programática para poder regresionar sin navegador. Se decide:
### D9 — Stack de pruebas ligado a Vite, sin dependencias de más
**Decisión:** `vitest` como runner (mismo ecosistema que el proyecto, sin Jest), `jsdom` + `@testing-library/react` para pruebas de componente (con `// @vitest-environment jsdom` por archivo) y `@testing-library/dom` como peer obligatorio. El `fetch` se simula con `vi.stubGlobal` — no se añade msw ni librerías de mock HTTP, en línea con la política de cero dependencias extra del proyecto.
**Alternativas:** Jest + jsdom — duplicaría configuración fuera del ecosistema Vite; msw — potente pero innecesario para el alcance actual.
### D10 — Integración con el backend local: supertest + BD en memoria
**Decisión:** refactor mínimo y sin cambio de comportamiento en `server/`: `server/index.js` exporta `app` y solo escucha cuando se ejecuta como script (`node server/index.js`), y `server/db.js` admite `process.env.LOCALESP_DB` (incluido `:memory:`) como ruta de la base de datos. Las pruebas de integración usan `supertest` contra la app Express con SQLite en memoria: totalmente offline y deterministas, sin puerto real.
**Alternativas:** levantar el servidor en un puerto efímero y hacer fetch real — más frágil y lento; mockear la BD — dejaría de ser integración.
### D11 — Integración con Nominatim real: suite opt-in respetuosa con el rate-limit
**Decisión:** suite separada ejecutada con `npm run test:integration` (config propia) que hace llamadas reales a Nominatim: sondea la conectividad en `beforeAll` y se auto-salta (`describe.skipIf`) sin red; pocos casos secuenciales con ~1 req/s para respetar la política de uso. La suite rápida (`npm test`) queda 100% offline para CI.
**Alternativas:** grabar/replicar respuestas — mantendría los fixtures sincronizados con la API real; llamadas reales en la suite por defecto — haría el CI dependiente de un servicio externo.
### D12 — Alias de nombres cooficiales de provincias (endurecimiento)
Al escribir las pruebas surgió que respuestas de Nominatim en euskera/catalán/gallego (`Bizkaia`, `Girona`, `Lleida`, `Ourense`, `Illes Balears`, `Asturies`) no casaban con el listado canónico. `ProveedorUbicacion.emparejarProvincia` gana un mapa pequeño de alias normalizados → provincia canónica, cubierto por pruebas unitarias.
@@ -0,0 +1,38 @@
# Pasarela para proponer locales
## Why
El botón "Proponer local" actual vive en la cabecera y despliega un formulario largo en línea con muchos campos a la vez (nombre, provincia, categoría, ubicación con dos modos, descripción y puntuación). En móvil resulta abrumador y la ubicación exige que el usuario elija manualmente entre "dirección" o "enlace de Google Maps". Queremos una experiencia guiada, campo a campo, que arranque por la ubicación (un único campo universal que acepte enlaces de Google Maps, Waze, OpenStreetMap o coordenadas) y que infiera automáticamente nombre y provincia, reduciendo el esfuerzo del proponente y los errores de datos.
## What Changes
- El botón "Proponer local" pasa de la cabecera a un **botón flotante (FAB) fijo en la esquina inferior derecha**, visible y usable tanto en PC como en móvil.
- Al pasar el ratón por encima (hover / foco), muestra un **tooltip** que explica que sirve para añadir un nuevo local.
- Al pulsarlo se abre una **pasarela (wizard) en un modal**: cada campo se especifica en su propia pantalla, con navegación atrás/siguiente e indicador de progreso.
- **Primera pantalla: Ubicación**, un solo campo universal que autodetecta el origen del input: enlace de Google Maps, enlace de Waze, enlace de OpenStreetMap o coordenadas geográficas (y texto de dirección como fallback vía Nominatim).
- **Nombre y Provincia se infieren** automáticamente a partir de la ubicación introducida (geocodificación inversa con Nominatim), quedando pre-rellenados en sus pantallas para que el usuario los confirme o corrija.
- **Integraciones con backends de mapas extensible**: varias clases (adaptadores) implementan una misma interfaz (`detecta`/`extrae`), registradas en un resolver que autodetecta el proveedor. El resultado de `extrae` es un objeto extensible (coordenadas hoy, otros campos en el futuro). Añadir un proveedor nuevo no debe requerir tocar el resto del código.
- **Se elimina el campo comentario/descripción** del formulario de propuesta (se gestionará en otro cambio posterior).
- **Se elimina el campo puntuación** del formulario de propuesta.
- Se mantiene la lógica existente de validación: filtro de palabras prohibidas, comprobación de duplicados (avisos dentro del modal) y aceptación de la política de privacidad antes de enviar.
- No hay cambios de API: el backend ya tolera `descripcion` y `puntuacion` ausentes (valores por defecto).
## Capabilities
### New Capabilities
- `boton-flotante-proponer-local`: botón de acción flotante fijo en la esquina inferior derecha, responsive (PC y móvil), con tooltip al hover y apertura de la pasarela al pulsarlo.
- `pasarela-proponer-local`: modal de propuesta paso a paso con una pantalla por campo (Ubicación → Nombre → Provincia → Categoría → Tipo específico → Revisión y envío), sin campos de comentario ni puntuación.
- `proveedores-ubicacion`: sistema extensible de integraciones con backends de ubicación (Google Maps, Waze, OpenStreetMap, coordenadas y geocodificación Nominatim) bajo una interfaz común, capaz de extraer coordenadas e inferir nombre y provincia, ampliable a otras extracciones futuras.
### Modified Capabilities
<!-- No existen specs previas en openspec/specs/ — todas las capacidades son nuevas -->
## Impact
- **`src/App.jsx`**: se elimina el botón de cabecera y el formulario en línea (`mostrarForm`); se sustituye por el FAB + modal. La lógica de envío (filtro de palabras, duplicados, `enviarPropuesta`) se reutiliza.
- **Nuevos componentes** (`src/components/`): botón flotante, modal de la pasarela y pantallas de cada paso.
- **Nuevos módulos** (`src/utils/proveedores/`): interfaz común, adaptadores por proveedor y resolver de autodetección.
- **`src/utils/geo.js`**: se reutiliza/amplía (geocodificación inversa vía Nominatim; extracción de coordenadas por proveedor).
- **Sin cambios de backend**: `server/index.js` y la base de datos ya aceptan propuestas sin `descripcion` ni `puntuacion` (defaults `null`/`0`); el panel de administración no se modifica.
- **`src/AppMovil.jsx`** no está referenciado por `main.jsx` (código muerto): no se toca.
- Accesibilidad: tooltip también vía `aria-label`, foco atrapado en el modal, cierre con Escape.
@@ -0,0 +1,36 @@
# Botón flotante para proponer locales
## ADDED Requirements
### Requirement: Botón flotante fijo en la esquina inferior derecha
El sistema SHALL mostrar un botón de acción flotante (FAB) fijado en la esquina inferior derecha de la ventana, siempre visible sobre el contenido, tanto en PC como en móvil.
#### Scenario: Visible en escritorio
- **WHEN** la página se carga en una pantalla de escritorio
- **THEN** el botón flotante aparece fijado en la esquina inferior derecha, por encima del resto del contenido
#### Scenario: Visible en móvil
- **WHEN** la página se carga o se hace scroll en un dispositivo móvil
- **THEN** el botón flotante permanece fijo y accesible en la esquina inferior derecha, sin quedar oculto por el contenido ni interferir con la barra inferior existente
#### Scenario: Reemplazo del botón de cabecera
- **WHEN** la página se carga
- **THEN** ya no existe el botón "Proponer local" en la cabecera y el único punto de entrada para proponer un local es el botón flotante
### Requirement: Tooltip explicativo al pasar el ratón
El botón flotante SHALL mostrar un tooltip al pasar el ratón por encima (hover) y al recibir foco por teclado, explicando que sirve para añadir un nuevo local.
#### Scenario: Tooltip en hover
- **WHEN** el usuario pasa el ratón por encima del botón flotante
- **THEN** aparece un tooltip con un texto explicativo del tipo "Añade un nuevo local al directorio"
#### Scenario: Tooltip accesible sin ratón
- **WHEN** el botón flotante recibe el foco por teclado o se consulta con un lector de pantalla
- **THEN** el botón expone su propósito mediante `aria-label`, de modo que la función sea accesible sin depender del hover
### Requirement: El botón abre la pasarela en un modal
El botón flotante SHALL abrir, al pulsarlo, la pasarela de propuesta de local en un modal por encima de la página.
#### Scenario: Apertura de la pasarela
- **WHEN** el usuario pulsa el botón flotante
- **THEN** se abre un modal con la pasarela de propuesta, comenzando por la primera pantalla (Ubicación)
@@ -0,0 +1,92 @@
# Pasarela de propuesta de local
## ADDED Requirements
### Requirement: Pasarela paso a paso en un modal
El sistema SHALL presentar la propuesta de un local como una pasarela (wizard) dentro de un modal, con un solo campo por pantalla, navegación atrás/siguiente e indicador de progreso.
#### Scenario: Una pantalla por campo
- **WHEN** la pasarela está abierta
- **THEN** cada pantalla muestra únicamente el campo correspondiente al paso actual, con botones para avanzar y retroceder
#### Scenario: Navegación hacia atrás
- **WHEN** el usuario pulsa "Atrás" en una pantalla intermedia
- **THEN** vuelve a la pantalla anterior conservando los datos ya introducidos
#### Scenario: Avance bloqueado sin dato válido
- **WHEN** el campo de la pantalla actual está vacío o es inválido
- **THEN** el botón "Siguiente" está deshabilitado y la pantalla no avanza
#### Scenario: Cierre del modal
- **WHEN** el usuario cierra el modal (botón ✕, botón Cancelar o tecla Escape)
- **THEN** la pasarela se cierra y se descarta el borrador en curso
### Requirement: Orden de las pantallas empezando por Ubicación
La pasarela SHALL presentar las pantallas en este orden: 1) Ubicación, 2) Nombre, 3) Provincia, 4) Categoría, 5) Tipo específico (opcional), 6) Revisión y envío.
#### Scenario: Primera pantalla
- **WHEN** se abre la pasarela
- **THEN** la primera pantalla que se muestra es la de Ubicación
#### Scenario: Tipo específico omitible
- **WHEN** el usuario está en la pantalla de Tipo específico
- **THEN** puede continuar sin seleccionar ninguna subcategoría
### Requirement: Inferencia de Nombre y Provincia desde la Ubicación
El sistema SHALL pre-rellenar los campos Nombre y Provincia a partir de la ubicación introducida en la primera pantalla (geocodificación inversa), permitiendo al usuario corregirlos.
#### Scenario: Nombre y provincia pre-rellenados
- **WHEN** el usuario introduce una ubicación válida en la primera pantalla y esta se resuelve con éxito
- **THEN** al llegar a las pantallas de Nombre y Provincia ambos campos aparecen pre-rellenados con los valores inferidos
#### Scenario: Valores corregibles
- **WHEN** los valores inferidos no son correctos
- **THEN** el usuario puede editarlos libremente antes de enviar
#### Scenario: Degradación sin inferencia
- **WHEN** la inferencia falla o devuelve campos vacíos
- **THEN** las pantallas de Nombre y Provincia aparecen vacías y el usuario los rellena manualmente, sin bloquear la pasarela
### Requirement: Sin campo de comentario ni puntuación
La pasarela SHALL NOT incluir el campo de comentario/descripción ni el campo de puntuación; la propuesta se envía sin esos datos.
#### Scenario: Envío sin comentario ni puntuación
- **WHEN** el usuario completa la pasarela y envía
- **THEN** la propuesta se registra con nombre, provincia, categoría, subcategoría, dirección/enlace y coordenadas, sin descripción y sin puntuación
### Requirement: Revisión y aceptación de privacidad antes de enviar
La última pantalla de la pasarela SHALL mostrar un resumen de los datos introducidos y el checkbox de aceptación de la política de privacidad, siendo este último obligatorio para enviar.
#### Scenario: Envío bloqueado sin aceptar privacidad
- **WHEN** el usuario llega a la pantalla final sin marcar la casilla de privacidad
- **THEN** el botón de envío está deshabilitado hasta que la marque
#### Scenario: Resumen antes de enviar
- **WHEN** el usuario llega a la pantalla final
- **THEN** ve un resumen legible de todos los datos introducidos con posibilidad de volver atrás a corregirlos
### Requirement: Validaciones existentes integradas en la pasarela
La pasarela SHALL conservar las validaciones actuales del formulario: filtro de palabras prohibidas y comprobación de duplicados, mostrando los avisos dentro del modal.
#### Scenario: Palabra prohibida detectada
- **WHEN** el texto introducido contiene una palabra no permitida
- **THEN** se muestra un aviso dentro del modal y la propuesta no se envía
#### Scenario: Posible duplicado detectado
- **WHEN** al enviar se detecta un local igual o muy cercano
- **THEN** se muestra el aviso de duplicado dentro del modal con las coincidencias y las opciones de enviar de todos modos o revisar los datos
#### Scenario: Envío correcto
- **WHEN** la propuesta se envía con éxito
- **THEN** el modal se cierra y aparece la confirmación de propuesta enviada en la página principal
### Requirement: Modal responsive accesible
El modal de la pasarela SHALL ser usable en PC (diálogo centrado) y móvil (ocupando prácticamente toda la pantalla), con foco contenido y cierre mediante Escape.
#### Scenario: Uso en móvil
- **WHEN** la pasarela se abre en una pantalla estrecha
- **THEN** el modal ocupa el ancho completo y es operativo con teclado y scroll táctil
#### Scenario: Foco contenido en el modal
- **WHEN** el modal está abierto
- **THEN** el foco de teclado permanece dentro del modal y la tecla Escape lo cierra
@@ -0,0 +1,73 @@
# Proveedores de ubicación (integraciones extensible)
## ADDED Requirements
### Requirement: Interfaz común para proveedores de ubicación
Cada integración con un backend de ubicación SHALL implementarse como una clase que cumpla una misma interfaz: identificarse (`id`), indicar si reconoce una entrada (`detecta(entrada)`) y extraer la información disponible (`extrae(entrada)`).
#### Scenario: Contrato uniforme
- **WHEN** se consulta cualquier proveedor registrado
- **THEN** expone los métodos `detecta(entrada)` y `extrae(entrada)` con la misma firma y semántica, de modo que el consumidor no necesita conocer la clase concreta
### Requirement: Resultado de extracción extensible
El método `extrae(entrada)` SHALL devolver un objeto de resultado extensible que incluya como mínimo las coordenadas (`lat`, `lng`) y el identificador del proveedor que las extrajo, pudiendo incorporar campos adicionales en el futuro sin romper a los consumidores.
#### Scenario: Extracción mínima
- **WHEN** un proveedor extrae una ubicación válida
- **THEN** el resultado contiene `lat`, `lng` y el identificador del proveedor
#### Scenario: Campos adicionales sin ruptura
- **WHEN** en el futuro un proveedor añade nuevos campos al resultado (horarios, teléfono, fotos…)
- **THEN** los consumidores existentes siguen funcionando sin modificación, ignorando los campos que no conocen
### Requirement: Autodetección del proveedor a partir de la entrada
El sistema SHALL autodetectar automáticamente el proveedor adecuado para una entrada dada, recorriendo los proveedores registrados y usando el primero que la reconozca.
#### Scenario: Enlace de Google Maps
- **WHEN** el usuario pega un enlace de Google Maps (formatos `@lat,lng`, `!3d…!4d…` o `?q=lat,lng`)
- **THEN** el sistema lo resuelve con el proveedor de Google Maps y obtiene las coordenadas
#### Scenario: Enlace de Waze
- **WHEN** el usuario pega un enlace de Waze (`waze.com/ul`, parámetro `ll=lat,lng` o búsqueda `q=`)
- **THEN** el sistema lo resuelve con el proveedor de Waze y obtiene las coordenadas
#### Scenario: Enlace de OpenStreetMap
- **WHEN** el usuario pega un enlace de openstreetmap.org (parámetros `mlat`/`mlon` o fragmento `#map=zoom/lat/lng`)
- **THEN** el sistema lo resuelve con el proveedor de OpenStreetMap y obtiene las coordenadas
#### Scenario: Coordenadas geográficas sueltas
- **WHEN** el usuario escribe coordenadas directamente (por ejemplo `41.3851, 2.1734`)
- **THEN** el sistema lo resuelve con el proveedor de coordenadas y obtiene lat/lng
#### Scenario: Texto de dirección como fallback
- **WHEN** la entrada no coincide con ningún enlace soportado y parece una dirección en texto
- **THEN** el sistema la resuelve con el proveedor de geocodificación (Nominatim) realizando una búsqueda por texto
#### Scenario: Entrada no reconocida
- **WHEN** la entrada no es reconocida por ningún proveedor ni resuelta como dirección
- **THEN** el sistema informa de que no se pudo interpretar la ubicación y no avanza de pantalla
### Requirement: Inferencia de nombre y provincia
Los proveedores SHALL extraer, cuando estén disponibles, los campos inferibles `nombre` y `provincia` a partir de la entrada resuelta (geocodificación inversa cuando solo hay coordenadas), normalizando la provincia al listado canónico de provincias de España.
#### Scenario: Inferencia desde coordenadas
- **WHEN** la entrada se resuelve a coordenadas sin más contexto
- **THEN** una geocodificación inversa obtiene un nombre descriptivo y la provincia canónica (coincidencia insensible a acentos y mayúsculas con el listado de PROVINCIAS)
#### Scenario: Provincia fuera del listado
- **WHEN** la provincia inferida no coincide con ninguna provincia del listado canónico
- **THEN** el campo provincia queda vacío para que el usuario lo seleccione manualmente
### Requirement: Extensibilidad sin modificar proveedores existentes
Añadir un proveedor nuevo SHALL consistir en crear una nueva clase que implemente la interfaz y registrarla en el resolver, sin modificar las clases existentes ni la lógica de la pasarela (principio abierto/cerrado).
#### Scenario: Registro de un proveedor nuevo
- **WHEN** se añade una clase `MiProveedor` con `detecta`/`extrae` y se registra en la lista de proveedores
- **THEN** el campo universal empieza a aceptar las entradas que ese proveedor reconoce sin ningún otro cambio de código
### Requirement: Errores de red degradados
Los proveedores SHALL tratar sus errores de red o de servicio devolviendo un fallo controlado, sin romper la pasarela ni propagar excepciones al consumidor.
#### Scenario: Fallo del servicio de geocodificación
- **WHEN** Nominatim no responde o devuelve un error
- **THEN** la pantalla de Ubicación muestra un aviso de error y el usuario puede reintentar o continuar sin inferencia
@@ -0,0 +1,70 @@
## 1. Proveedores de ubicación (núcleo extensible)
- [x] 1.1 Crear `src/utils/ubicacion/ProveedorUbicacion.js`: clase base con el contrato documentado (`id`, `detecta(entrada)`, `extrae(entrada)` que lanza "sin implementar") y helpers compartidos (`normalizar()`, `emparejarProvincia()` contra `PROVINCIAS`, fetch a Nominatim con cabeceras y manejo de errores que devuelve `null` en fallo)
- [x] 1.2 Implementar `proveedorCoordenadas.js`: detecta pares "lat, lng" (coma o punto, signo, espacios) y devuelve `{ proveedor, lat, lng }`
- [x] 1.3 Implementar `proveedorGoogleMaps.js`: detecta URLs de Google Maps y extrae coordenadas de los formatos `@lat,lng`, `!3d…!4d…` y `?q=lat,lng` (migrando `parsearEnlaceGoogleMaps` de `geo.js`)
- [x] 1.4 Implementar `proveedorWaze.js`: detecta `waze.com` y extrae de `ll=lat,lng`; si solo hay `q=texto`, delega la resolución en la búsqueda de Nominatim marcando el proveedor como waze
- [x] 1.5 Implementar `proveedorOSM.js`: detecta `openstreetmap.org` y extrae de `mlat`/`mlon` y del fragmento `#map=zoom/lat/lng`
- [x] 1.6 Implementar `proveedorNominatim.js`: búsqueda por texto (`/search`, migrando `geocodificarDireccion`) como fallback de dirección, y método `reverse(lat, lng)` (`/reverse` con `addressdetails=1&namedetails=1`) que devuelve `{ nombre, provincia, direccion }` con provincia normalizada al listado canónimo (vacía si no casa)
- [x] 1.7 Crear `src/utils/ubicacion/index.js`: array `PROVEEDORES` ordenado por prioridad (coordenadas → Google → Waze → OSM → Nominatim), función `resolverUbicacion(entrada)` que usa el primer `detecta` que acepte, y `inferirDesdeCoords(lat, lng)` que envuelve el reverse de Nominatim con manejo de errores
- [x] 1.8 Ajustar `src/utils/geo.js`: conservar exportaciones usadas por `LocalCard`/mapa (`enlaceGoogleMapsDesdeLocal`, `enlaceWazeDesdeLocal`, `distanciaKm`, `formatearDistancia`, `buscarLugares`) y reubicar lo migrado en los proveedores, sin romper imports existentes
## 2. Pantallas de la pasarela
- [x] 2.1 Crear `PasoUbicacion.jsx`: input universal con debounce (~600ms) que llama a `resolverUbicacion`; al resolver muestra chip con el proveedor detectado y coordenadas; llama a `inferirDesdeCoords` y pre-rellena `nombre`/`provincia`/`direccion` en el `form`; muestra avisos de entrada no reconocida o error de red; "Siguiente" solo habilitado con resolución válida
- [x] 2.2 Crear `PasoNombre.jsx` y `PasoProvincia.jsx`: inputs simples pre-rellenados con lo inferido (editables); provincia como select del listado `PROVINCIAS`; vacíos si la inferencia falló
- [x] 2.3 Crear `PasoCategoria.jsx` y `PasoSubcategoria.jsx`: selects con `CATEGORIAS`/`NOMBRES_CATEGORIAS`; subcategoría opcional (permite continuar sin elegir), solo accesible tras elegir categoría
- [x] 2.4 Crear `PasoResumen.jsx`: resumen legible de todos los datos, checkbox de aceptación de privacidad (enlace al `ModalPrivacidad` existente) y botón "Enviar propuesta" deshabilitado sin aceptación; renderiza avisos de palabra prohibida y de duplicado (con "Enviar de todos modos"/"Revisar datos") dentro del modal
## 3. Modal de la pasarela y FAB
- [x] 3.1 Crear `ModalPasarela.jsx`: overlay siguiendo el patrón de `ModalPrivacidad` (`role="dialog"`, `aria-modal`, cierre por fondo/✕/Escape), cabecera con título e indicador de progreso, cuerpo con la pantalla activa y pie con botones Atrás/Siguiente; en móvil panel a ancho completo y casi todo el alto (hook `useEsMovil` con `matchMedia`), en escritorio centrado `maxWidth≈480px`; foco inicial en el primer control de cada pantalla
- [x] 3.2 Crear `BotonFlotante.jsx`: FAB fijo abajo a la derecha (`z-index` < modal, `padding-bottom: env(safe-area-inset-bottom)`), con tooltip propio al hover/foco ("Añade un nuevo local al directorio") posicionado a la izquierda y `aria-label` en el botón
- [x] 3.3 Integrar en `App.jsx`: eliminar el botón "Proponer local" de la cabecera y el bloque de formulario en línea; renderizar `BotonFlotante` (abre el modal) y `ModalPasarela`; sustituir `mostrarForm` por estado `pasarelaAbierta`; actualizar el texto del pie "Proponlo arriba" para referirse al botón flotante
## 4. Envío de la propuesta
- [x] 4.1 Actualizar `FORM_VACIO` y el flujo de envío en `App.jsx`: quitar `descripcion` y `puntuacion` del formulario y del payload de `enviarPropuesta`; `formValido` sin requisito de puntuación; conservar filtro de palabras, `comprobarDuplicado`, fallback de coordenadas por provincia y confirmación `enviado` tras cerrar el modal; estados "Comprobando…/Enviando…" dentro del modal
## 5. Verificación
- [x] 5.1 Probar manualmente los formatos de entrada del campo universal: enlace Google Maps (@/, !3d!4d, ?q=), Waze (ll= y q=), OSM (mlat/mlon y #map=), coordenadas sueltas y dirección en texto; verificar aviso con entrada no reconocida
> Verificado a nivel de módulo contra los proveedores reales (con red): los 17 casos pasan, incluidos Waze `q=` delegando en Nominatim y entradas no reconocidas devolviendo `null` (dispara el aviso del paso). Falta el clic-through visual en navegador.
- [x] 5.2 Verificar inferencia: nombre y provincia pre-rellenados desde coordenadas de varias comunidades (normalización de acentos), y flujo completo de propuesta sin comentario ni puntuación con aviso de duplicado y de palabra prohibida dentro del modal
> Verificado `npm test` (82/82: 51 unitarias + 31 componente) y `npm run test:integration` (10/10 con red real). Además se cubrió programáticamente gran parte del contenido: avisos de duplicado/palabra prohibida en `PasoResumen.spec.jsx`, flujo de propuesta sin comentario/puntuación contra el backend real en `apiLocal.spec.js`, y normalización multi-comunidad en `nominatim.spec.js`. Pendiente solo el clic-through visual en navegador con backend levantado.
- [x] 5.3 Verificar responsive y accesibilidad: FAB visible sin tapar contenido en viewport estrecho y con barra PWA; tooltip en hover y foco; modal navegable con teclado, Escape cierra, foco contenido; enviar aceptación de privacidad obligatoria
> Cobertura programática añadida: tooltip en foco/hover (`BotonFlotante.spec.jsx`), Escape/✕/foco contenido/Tab wrap (`ModalPasarela.spec.jsx`), privacidad obligatoria (`PasoResumen.spec.jsx`). Pendiente solo la verificación visual en dispositivo/viewport estrecho y barra PWA (safe-area).
- [x] 5.4 Verificar no-regresión: tarjetas, mapa, favoritos, reportes y panel admin siguen funcionando (imports de `geo.js` intactos); `npm run build` sin errores
> `npm run build` OK (65 módulos); imports de `geo.js` intactos en LocalCard, AdminPanel, App y AutocompletarLocal; sin referencias muertas (`mostrarForm`/`CampoUbicacion`/`formValido`).
## 6. Infraestructura de pruebas
- [x] 6.1 Añadir dependencias y configuración de pruebas: `vitest` + `jsdom` + `@testing-library/react` (+ `@testing-library/dom`) + `supertest` como devDeps; `vitest.config.mjs` (unitarias/componente, offline) y `vitest.config.integration.mjs`; scripts `npm test`, `npm run test:watch`, `npm run test:integration`
- [x] 6.2 Hacer el servidor comprobable sin cambiar su comportamiento: ruta de BD configurable (`process.env.LOCALESP_DB`, admite `:memory:`) y `server/index.js` exportando `app` escuchando solo al ejecutarse como script (`supertest` sin puerto)
## 7. Pruebas unitarias (proveedores de ubicación)
- [x] 7.1 `ProveedorUbicacion` (base): `normalizar()`, `emparejarProvincia()` (acentos, mayúsculas, artículos, alias cooficiales, sin coincidencia → `""`), `extrae()` de la base lanza "sin implementar", `detecta()` base no acepta nada, `fetchNominatim` devuelve `null` si `fetch` lanza o la respuesta no es ok
- [x] 7.2 `proveedorCoordenadas`: pares con coma y punto decimal, separador `,`/`;`/espacio, signo, espacios; latitud fuera de rango → `null`; texto que no es un par → `detecta` false
- [x] 7.3 `proveedorGoogleMaps`: formatos `@lat,lng`, `!3d…!4d…`, `?q=lat,lng`; prioridad `@` sobre `!3d!4d` (comportamiento migrado de `parsearEnlaceGoogleMaps`); enlace acortado `detecta` pero `extrae``null`; URL ajena → `detecta` false
- [x] 7.4 `proveedorWaze`: `ll=lat,lng` ok; `ll` malformado → `null`; `q=texto` delega en la búsqueda Nominatim (fetch simulado) marcando `proveedor: "waze"`; `q` sin resultados → `null`
- [x] 7.5 `proveedorOSM`: prioridad `mlat`/`mlon` sobre `#map=`; `#map=zoom/lat/lng`; sin ninguno → `null`
- [x] 7.6 `proveedorNominatim`: `detecta` rechaza URLs y textos cortos y acepta direcciones; `desdeResultado` normaliza provincia (Bizkaia→Vizcaya, A Coruña→La Coruña, desconocida→ `""`); `extrae` sin resultados → `null`
- [x] 7.7 `resolverUbicacion`/`inferirDesdeCoords`: orden de prioridad de `PROVEEDORES`, fallback de texto, proveedor que lanza → se degrada y prueba el siguiente, entrada vacía → `null`; `ETIQUETAS_PROVEEDOR` cubre todos los ids registrados
## 8. Pruebas de componente (pasarela)
- [x] 8.1 `PasoUbicacion`: debounce ~600ms (fake timers, no resuelve antes), chip con proveedor detectado y coordenadas, aviso de entrada no reconocida, pre-relleno del `form` (nombre/provincia/dirección/lat/lng/enlace según proveedor), aviso cuando la inferencia inversa falla
- [x] 8.2 `PasoNombre`/`PasoProvincia`/`PasoCategoria`/`PasoSubcategoria`: pre-rellenados con lo inferido y editables; provincia como select de `PROVINCIAS`; subcategoría deshabilitada sin categoría y opcional (se puede continuar)
- [x] 8.3 `PasoResumen`: resumen con los datos del form; botón "Enviar propuesta" deshabilitado sin aceptar privacidad y habilitado al marcar; abre `ModalPrivacidad` vía enlace; avisos de palabra prohibida y de duplicado con "Enviar de todos modos"/"Revisar datos"; estados "Comprobando…/Enviando…"
- [x] 8.4 `ModalPasarela`: arranca en Ubicación; "Siguiente" deshabilitado sin dato válido y habilitado con él; "Atrás" conserva datos; Escape y ✕ cierran (`onCerrar`); foco inicial en el primer control de cada pantalla y contención de Tab dentro del modal
- [x] 8.5 `BotonFlotante`: `aria-label` descriptivo, tooltip oculto por defecto y visible al enfocar (y oculto al desenfocar), `onClick` al pulsar
## 9. Pruebas de integración (backends reales)
- [x] 9.1 API local con `supertest` + BD `:memory:`: `POST /api/propuestas` sin `descripcion`/`puntuacion` → 201 con defaults (`puntuacion` 0, estado pendiente); `POST /api/check-duplicado` detecta por nombre normalizado (acentos/mayúsculas) + misma provincia; nombre igual en otra provincia y lejos → no duplicado; `GET /api/locales` arranca limpio
- [x] 9.2 Nominatim real (suite opt-in `npm run test:integration`, auto-skip sin red, ≤1 req/s): búsqueda por dirección → coordenadas + provincia canónica; reverse de Madrid y Bilbao → provincia canónica; reverse fuera de España → provincia vacía
+20
View File
@@ -0,0 +1,20 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours
@@ -0,0 +1,40 @@
# Botón flotante para proponer locales
## Purpose
Punto de entrada único y siempre visible para proponer un nuevo local al directorio: un botón de acción flotante (FAB) fijo en la esquina inferior derecha, usable en PC y móvil, con tooltip explicativo y apertura de la pasarela de propuesta al pulsarlo.
## Requirements
### Requirement: Botón flotante fijo en la esquina inferior derecha
El sistema SHALL mostrar un botón de acción flotante (FAB) fijado en la esquina inferior derecha de la ventana, siempre visible sobre el contenido, tanto en PC como en móvil.
#### Scenario: Visible en escritorio
- **WHEN** la página se carga en una pantalla de escritorio
- **THEN** el botón flotante aparece fijado en la esquina inferior derecha, por encima del resto del contenido
#### Scenario: Visible en móvil
- **WHEN** la página se carga o se hace scroll en un dispositivo móvil
- **THEN** el botón flotante permanece fijo y accesible en la esquina inferior derecha, sin quedar oculto por el contenido ni interferir con la barra inferior existente
#### Scenario: Reemplazo del botón de cabecera
- **WHEN** la página se carga
- **THEN** ya no existe el botón "Proponer local" en la cabecera y el único punto de entrada para proponer un local es el botón flotante
### Requirement: Tooltip explicativo al pasar el ratón
El botón flotante SHALL mostrar un tooltip al pasar el ratón por encima (hover) y al recibir foco por teclado, explicando que sirve para añadir un nuevo local.
#### Scenario: Tooltip en hover
- **WHEN** el usuario pasa el ratón por encima del botón flotante
- **THEN** aparece un tooltip con un texto explicativo del tipo "Añade un nuevo local al directorio"
#### Scenario: Tooltip accesible sin ratón
- **WHEN** el botón flotante recibe el foco por teclado o se consulta con un lector de pantalla
- **THEN** el botón expone su propósito mediante `aria-label`, de modo que la función sea accesible sin depender del hover
### Requirement: El botón abre la pasarela en un modal
El botón flotante SHALL abrir, al pulsarlo, la pasarela de propuesta de local en un modal por encima de la página.
#### Scenario: Apertura de la pasarela
- **WHEN** el usuario pulsa el botón flotante
- **THEN** se abre un modal con la pasarela de propuesta, comenzando por la primera pantalla (Ubicación)
@@ -0,0 +1,96 @@
# Pasarela de propuesta de local
## Purpose
Experiencia guiada (wizard) en un modal para proponer un local con un solo campo por pantalla —empezando por la ubicación con autodetección del proveedor e inferencia de nombre/provincia—, sin campos de comentario ni puntuación, conservando las validaciones existentes (palabras prohibidas, duplicados, privacidad obligatoria).
## Requirements
### Requirement: Pasarela paso a paso en un modal
El sistema SHALL presentar la propuesta de un local como una pasarela (wizard) dentro de un modal, con un solo campo por pantalla, navegación atrás/siguiente e indicador de progreso.
#### Scenario: Una pantalla por campo
- **WHEN** la pasarela está abierta
- **THEN** cada pantalla muestra únicamente el campo correspondiente al paso actual, con botones para avanzar y retroceder
#### Scenario: Navegación hacia atrás
- **WHEN** el usuario pulsa "Atrás" en una pantalla intermedia
- **THEN** vuelve a la pantalla anterior conservando los datos ya introducidos
#### Scenario: Avance bloqueado sin dato válido
- **WHEN** el campo de la pantalla actual está vacío o es inválido
- **THEN** el botón "Siguiente" está deshabilitado y la pantalla no avanza
#### Scenario: Cierre del modal
- **WHEN** el usuario cierra el modal (botón ✕, botón Cancelar o tecla Escape)
- **THEN** la pasarela se cierra y se descarta el borrador en curso
### Requirement: Orden de las pantallas empezando por Ubicación
La pasarela SHALL presentar las pantallas en este orden: 1) Ubicación, 2) Nombre, 3) Provincia, 4) Categoría, 5) Tipo específico (opcional), 6) Revisión y envío.
#### Scenario: Primera pantalla
- **WHEN** se abre la pasarela
- **THEN** la primera pantalla que se muestra es la de Ubicación
#### Scenario: Tipo específico omitible
- **WHEN** el usuario está en la pantalla de Tipo específico
- **THEN** puede continuar sin seleccionar ninguna subcategoría
### Requirement: Inferencia de Nombre y Provincia desde la Ubicación
El sistema SHALL pre-rellenar los campos Nombre y Provincia a partir de la ubicación introducida en la primera pantalla (geocodificación inversa), permitiendo al usuario corregirlos.
#### Scenario: Nombre y provincia pre-rellenados
- **WHEN** el usuario introduce una ubicación válida en la primera pantalla y esta se resuelve con éxito
- **THEN** al llegar a las pantallas de Nombre y Provincia ambos campos aparecen pre-rellenados con los valores inferidos
#### Scenario: Valores corregibles
- **WHEN** los valores inferidos no son correctos
- **THEN** el usuario puede editarlos libremente antes de enviar
#### Scenario: Degradación sin inferencia
- **WHEN** la inferencia falla o devuelve campos vacíos
- **THEN** las pantallas de Nombre y Provincia aparecen vacías y el usuario los rellena manualmente, sin bloquear la pasarela
### Requirement: Sin campo de comentario ni puntuación
La pasarela SHALL NOT incluir el campo de comentario/descripción ni el campo de puntuación; la propuesta se envía sin esos datos.
#### Scenario: Envío sin comentario ni puntuación
- **WHEN** el usuario completa la pasarela y envía
- **THEN** la propuesta se registra con nombre, provincia, categoría, subcategoría, dirección/enlace y coordenadas, sin descripción y sin puntuación
### Requirement: Revisión y aceptación de privacidad antes de enviar
La última pantalla de la pasarela SHALL mostrar un resumen de los datos introducidos y el checkbox de aceptación de la política de privacidad, siendo este último obligatorio para enviar.
#### Scenario: Envío bloqueado sin aceptar privacidad
- **WHEN** el usuario llega a la pantalla final sin marcar la casilla de privacidad
- **THEN** el botón de envío está deshabilitado hasta que la marque
#### Scenario: Resumen antes de enviar
- **WHEN** el usuario llega a la pantalla final
- **THEN** ve un resumen legible de todos los datos introducidos con posibilidad de volver atrás a corregirlos
### Requirement: Validaciones existentes integradas en la pasarela
La pasarela SHALL conservar las validaciones actuales del formulario: filtro de palabras prohibidas y comprobación de duplicados, mostrando los avisos dentro del modal.
#### Scenario: Palabra prohibida detectada
- **WHEN** el texto introducido contiene una palabra no permitida
- **THEN** se muestra un aviso dentro del modal y la propuesta no se envía
#### Scenario: Posible duplicado detectado
- **WHEN** al enviar se detecta un local igual o muy cercano
- **THEN** se muestra el aviso de duplicado dentro del modal con las coincidencias y las opciones de enviar de todos modos o revisar los datos
#### Scenario: Envío correcto
- **WHEN** la propuesta se envía con éxito
- **THEN** el modal se cierra y aparece la confirmación de propuesta enviada en la página principal
### Requirement: Modal responsive accesible
El modal de la pasarela SHALL ser usable en PC (diálogo centrado) y móvil (ocupando prácticamente toda la pantalla), con foco contenido y cierre mediante Escape.
#### Scenario: Uso en móvil
- **WHEN** la pasarela se abre en una pantalla estrecha
- **THEN** el modal ocupa el ancho completo y es operativo con teclado y scroll táctil
#### Scenario: Foco contenido en el modal
- **WHEN** el modal está abierto
- **THEN** el foco de teclado permanece dentro del modal y la tecla Escape lo cierra
@@ -0,0 +1,77 @@
# Proveedores de ubicación (integraciones extensible)
## Purpose
Sistema extensible de integraciones con backends de ubicación (Google Maps, Waze, OpenStreetMap, coordenadas sueltas y geocodificación Nominatim) bajo una interfaz común (`id`/`detecta`/`extrae`), capaz de autodetectar el proveedor de una entrada libre, extraer coordenadas e inferir nombre y provincia, y ampliable a otras extracciones futuras sin modificar los proveedores existentes.
## Requirements
### Requirement: Interfaz común para proveedores de ubicación
Cada integración con un backend de ubicación SHALL implementarse como una clase que cumpla una misma interfaz: identificarse (`id`), indicar si reconoce una entrada (`detecta(entrada)`) y extraer la información disponible (`extrae(entrada)`).
#### Scenario: Contrato uniforme
- **WHEN** se consulta cualquier proveedor registrado
- **THEN** expone los métodos `detecta(entrada)` y `extrae(entrada)` con la misma firma y semántica, de modo que el consumidor no necesita conocer la clase concreta
### Requirement: Resultado de extracción extensible
El método `extrae(entrada)` SHALL devolver un objeto de resultado extensible que incluya como mínimo las coordenadas (`lat`, `lng`) y el identificador del proveedor que las extrajo, pudiendo incorporar campos adicionales en el futuro sin romper a los consumidores.
#### Scenario: Extracción mínima
- **WHEN** un proveedor extrae una ubicación válida
- **THEN** el resultado contiene `lat`, `lng` y el identificador del proveedor
#### Scenario: Campos adicionales sin ruptura
- **WHEN** en el futuro un proveedor añade nuevos campos al resultado (horarios, teléfono, fotos…)
- **THEN** los consumidores existentes siguen funcionando sin modificación, ignorando los campos que no conocen
### Requirement: Autodetección del proveedor a partir de la entrada
El sistema SHALL autodetectar automáticamente el proveedor adecuado para una entrada dada, recorriendo los proveedores registrados y usando el primero que la reconozca.
#### Scenario: Enlace de Google Maps
- **WHEN** el usuario pega un enlace de Google Maps (formatos `@lat,lng`, `!3d…!4d…` o `?q=lat,lng`)
- **THEN** el sistema lo resuelve con el proveedor de Google Maps y obtiene las coordenadas
#### Scenario: Enlace de Waze
- **WHEN** el usuario pega un enlace de Waze (`waze.com/ul`, parámetro `ll=lat,lng` o búsqueda `q=`)
- **THEN** el sistema lo resuelve con el proveedor de Waze y obtiene las coordenadas
#### Scenario: Enlace de OpenStreetMap
- **WHEN** el usuario pega un enlace de openstreetmap.org (parámetros `mlat`/`mlon` o fragmento `#map=zoom/lat/lng`)
- **THEN** el sistema lo resuelve con el proveedor de OpenStreetMap y obtiene las coordenadas
#### Scenario: Coordenadas geográficas sueltas
- **WHEN** el usuario escribe coordenadas directamente (por ejemplo `41.3851, 2.1734`)
- **THEN** el sistema lo resuelve con el proveedor de coordenadas y obtiene lat/lng
#### Scenario: Texto de dirección como fallback
- **WHEN** la entrada no coincide con ningún enlace soportado y parece una dirección en texto
- **THEN** el sistema la resuelve con el proveedor de geocodificación (Nominatim) realizando una búsqueda por texto
#### Scenario: Entrada no reconocida
- **WHEN** la entrada no es reconocida por ningún proveedor ni resuelta como dirección
- **THEN** el sistema informa de que no se pudo interpretar la ubicación y no avanza de pantalla
### Requirement: Inferencia de nombre y provincia
Los proveedores SHALL extraer, cuando estén disponibles, los campos inferibles `nombre` y `provincia` a partir de la entrada resuelta (geocodificación inversa cuando solo hay coordenadas), normalizando la provincia al listado canónico de provincias de España.
#### Scenario: Inferencia desde coordenadas
- **WHEN** la entrada se resuelve a coordenadas sin más contexto
- **THEN** una geocodificación inversa obtiene un nombre descriptivo y la provincia canónica (coincidencia insensible a acentos y mayúsculas con el listado de PROVINCIAS)
#### Scenario: Provincia fuera del listado
- **WHEN** la provincia inferida no coincide con ninguna provincia del listado canónico
- **THEN** el campo provincia queda vacío para que el usuario lo seleccione manualmente
### Requirement: Extensibilidad sin modificar proveedores existentes
Añadir un proveedor nuevo SHALL consistir en crear una nueva clase que implemente la interfaz y registrarla en el resolver, sin modificar las clases existentes ni la lógica de la pasarela (principio abierto/cerrado).
#### Scenario: Registro de un proveedor nuevo
- **WHEN** se añade una clase `MiProveedor` con `detecta`/`extrae` y se registra en la lista de proveedores
- **THEN** el campo universal empieza a aceptar las entradas que ese proveedor reconoce sin ningún otro cambio de código
### Requirement: Errores de red degradados
Los proveedores SHALL tratar sus errores de red o de servicio devolviendo un fallo controlado, sin romper la pasarela ni propagar excepciones al consumidor.
#### Scenario: Fallo del servicio de geocodificación
- **WHEN** Nominatim no responde o devuelve un error
- **THEN** la pantalla de Ubicación muestra un aviso de error y el usuario puede reintentar o continuar sin inferencia
+4717
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "locales-espanoles",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"server": "node server/index.js",
"dev:all": "concurrently -k -n vite,api -c cyan,green \"npm run dev\" \"npm run server\"",
"test": "vitest run",
"test:watch": "vitest",
"test:integration": "vitest run --config vitest.config.integration.mjs"
},
"dependencies": {
"better-sqlite3": "^12.0.0",
"express": "^5.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@vitejs/plugin-react": "^4.2.1",
"concurrently": "^8.0.0",
"jsdom": "^26.1.0",
"supertest": "^7.2.2",
"vite": "^5.2.0",
"vitest": "^3.2.7"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+16
View File
@@ -0,0 +1,16 @@
{
"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" }
]
}
+56
View File
@@ -0,0 +1,56 @@
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;
})
);
});
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# deploy.sh — Despliega la rama actual a <slug>.localesp.es
# Toda la lógica vive en lib/env.sh (modelo OOP). Éste es sólo el entrypoint.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/env.sh
. "$SCRIPT_DIR/lib/env.sh"
usage() {
echo "usage: $0 <branch>" >&2
exit 2
}
main() {
local branch root
branch="${1:-${GITHUB_REF_NAME:-}}"
if [ -z "$branch" ]; then
usage
fi
root="$(cd "$SCRIPT_DIR/.." && pwd)"
Logger::set_tag deploy
Env::new env "$branch" "$root"
Logger::info "$(Env::banner env)"
Env::deploy env
Logger::info "LISTO: $(Env::url env) ($(Env::summary env))"
}
main "$@"
+450
View File
@@ -0,0 +1,450 @@
#!/usr/bin/env bash
# lib/env.sh — Modelo OOP de entornos de previsualización por rama.
#
# Estrategia (entorno por rama):
# /opt/localesp/<slug> + systemd unit localesp-<slug> + vhost nginx + cert Let's Encrypt.
# Reutiliza el puerto de la unit existente (adopta entornos legacy) o asigna uno libre (>=8082).
#
# PRECONDICIÓN MANUAL: el registro A <slug>.localesp.es debe existir (se gestiona a mano).
# Si no resuelve aún, la app queda servida por HTTP y se emite el cert cuando el DNS apunte aquí.
#
# Representación OOP en bash:
# - Instancia = array asociativo global, referenciado por NOMBRE.
# - Constructor = Class::new <nombre> <args> (declara el array y establece invariantes).
# - Método = Class::method <nombre> <args>; accede al estado vía `local -n self="$1"`.
# - Composición = Env posee los nombres de sus colaboradores (<env>__unit, __vhost, __cert).
# - Servicios sin estado (Slug, Host, Port, Artifacts, Deps, Logger) = métodos estáticos.
#
# Requiere bash >= 4.3 (namerefs). Sin efectos al sourcear: sólo define funciones/constantes.
#
# CONTRATO (importante): el NOMBRE de instancia pasado a Env::new (p.ej. "env") es el
# nombre de un array asociativo global. NUNCA declares en el caller una `local` con ese
# mismo nombre: bash usa ámbito dinámico y la local sombrearía al array global, rompiendo
# el nameref `local -n self=env`. Pasa el nombre como literal: Env::new env ...
DOMAIN="localesp.es"
PORT_BASE=8082 # 8080/8081 son entornos legacy
LOGGER_TAG="env"
# ---------------------------------------------------------------------------
# Logger — servicio singleton (tag configurable según entrypoint).
# ---------------------------------------------------------------------------
Logger::set_tag() {
LOGGER_TAG="$1"
}
Logger::info() {
printf '\033[1;34m[%s]\033[0m %s\n' "$LOGGER_TAG" "$*"
}
Logger::error() {
printf '\033[1;31m[%s:ERROR]\033[0m %s\n' "$LOGGER_TAG" "$*" >&2
}
# ---------------------------------------------------------------------------
# Slug — value object estático: normaliza un nombre de rama a slug.
# ---------------------------------------------------------------------------
Slug::from() {
local branch="$1"
printf '%s' "$branch" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//'
}
# ---------------------------------------------------------------------------
# Host — servicio estático: IP pública y resolución DNS.
# ---------------------------------------------------------------------------
Host::public_ip() {
curl -s4 --max-time 5 ifconfig.me || true
}
Host::resolved_ip() {
local host="$1"
getent hosts "$host" | awk '{print $1}' | head -1 || true
}
# ---------------------------------------------------------------------------
# Port — servicio estático: asigna/reutiliza puerto para una unit+dest.
# ---------------------------------------------------------------------------
Port::acquire() {
local unit="$1"
local dest="$2"
local unit_file="/etc/systemd/system/$unit.service"
local used
local port
# Adopta entorno existente: reutiliza el puerto de la unit ya instalada.
if [ -f "$unit_file" ]; then
port="$(grep -oE 'PORT=[0-9]+' "$unit_file" | head -1 | cut -d= -f2 || true)"
if [ -n "$port" ]; then
echo "$port"
return
fi
fi
if [ -f "$dest/.port" ]; then
cat "$dest/.port"
return
fi
used="$( { grep -rhoE 'PORT=[0-9]+' /etc/systemd/system/localesp*.service 2>/dev/null | cut -d= -f2
grep -rhoE 'localhost:[0-9]+' /etc/nginx/conf.d/*.conf 2>/dev/null | cut -d: -f2; } | sort -un)"
port="$PORT_BASE"
while printf '%s\n' "$used" | grep -qx "$port"; do
port=$((port+1))
done
echo "$port"
}
# ---------------------------------------------------------------------------
# Artifacts — servicio estático: artefactos buildados.
# ---------------------------------------------------------------------------
Artifacts::require_built() {
local root="$1"
if [ ! -d "$root/dist" ]; then
Logger::error "falta dist/ — ¿se ejecutó el build?"
exit 1
fi
}
Artifacts::sync() {
local root="$1"
local dest="$2"
mkdir -p "$dest/dist" "$dest/server"
rsync -a --delete "$root/dist/" "$dest/dist/"
rsync -a --delete "$root/server/" "$dest/server/"
cp -a "$root/package.json" "$root/package-lock.json" "$dest/"
# localesp.db se PRESERVA (datos de usuarios). No se toca aquí.
}
# ---------------------------------------------------------------------------
# Deps — servicio estático: dependencias de producción.
# ---------------------------------------------------------------------------
Deps::install() {
local root="$1"
local dest="$2"
# Reutiliza los node_modules ya compilados por el workflow (better-sqlite3 v12 exige
# C++20, GCC 12). Copiar + npm prune evita recompilar en cada despliegue.
# Fallback a npm ci --omit=dev si se ejecuta a mano sin build previo.
if [ -d "$root/node_modules" ]; then
mkdir -p "$dest/node_modules"
rsync -a --delete "$root/node_modules/" "$dest/node_modules/"
( cd "$dest" && npm prune --omit=dev --no-audit --no-fund )
else
( cd "$dest" && npm ci --omit=dev --no-audit --no-fund )
fi
}
# ---------------------------------------------------------------------------
# SystemdUnit — entidad: fichero .service de un entorno.
# ---------------------------------------------------------------------------
SystemdUnit::new() {
local self="$1"
local name="$2"
local dest="$3"
local branch="$4"
local host="$5"
local port="$6"
declare -gA "$self"
local -n u="$self"
u[name]="$name"
u[dest]="$dest"
u[branch]="$branch"
u[host]="$host"
u[port]="$port"
u[file]="/etc/systemd/system/${name}.service"
}
SystemdUnit::render() {
local self="$1"
local -n u="$self"
cat <<EOF
[Unit]
Description=LocaleSP env rama '${u[branch]}' (${u[host]})
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=${u[dest]}
ExecStart=/usr/bin/node server/index.js
Restart=on-failure
RestartSec=5s
Environment=NODE_ENV=production
Environment=PORT=${u[port]}
[Install]
WantedBy=multi-user.target
EOF
}
SystemdUnit::write() {
local self="$1"
local -n u="$self"
Logger::info "escribiendo unit ${u[name]} (PORT=${u[port]})"
SystemdUnit::render "$self" > "${u[file]}"
echo "${u[port]}" > "${u[dest]}/.port"
systemctl daemon-reload
systemctl enable "${u[name]}" >/dev/null 2>&1 || true
systemctl restart "${u[name]}"
Logger::info "${u[name]} arrancada"
}
SystemdUnit::remove() {
local self="$1"
local -n u="$self"
systemctl disable --now "${u[name]}" 2>/dev/null || true
rm -f "${u[file]}"
systemctl daemon-reload
}
# ---------------------------------------------------------------------------
# NginxVhost — entidad: vhost nginx de un entorno.
# ---------------------------------------------------------------------------
NginxVhost::new() {
local self="$1"
local slug="$2"
local host="$3"
local port="$4"
declare -gA "$self"
local -n v="$self"
v[slug]="$slug"
v[host]="$host"
v[port]="$port"
}
NginxVhost::find_path() {
local self="$1"
local -n v="$self"
local host_re
host_re="$(printf '%s' "${v[host]}" | sed 's/\./\\./g')"
grep -rlE "server_name[[:space:]]+$host_re[[:space:]]*;" /etc/nginx/conf.d/*.conf 2>/dev/null | head -1 || true
}
NginxVhost::render() {
local self="$1"
local -n v="$self"
cat <<EOF
server {
listen 80;
listen [::]:80;
server_name ${v[host]};
location / {
proxy_pass http://localhost:${v[port]};
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
EOF
}
NginxVhost::create() {
local self="$1"
local -n v="$self"
local path="/etc/nginx/conf.d/${v[slug]}.conf"
Logger::info "creando vhost HTTP $path"
NginxVhost::render "$self" > "$path"
}
NginxVhost::repoint() {
local self="$1"
local -n v="$self"
local path="$2"
Logger::info "vhost existente: $path — forzando proxy_pass -> :${v[port]}"
sed -i -E "s|proxy_pass http://localhost:[0-9]+;|proxy_pass http://localhost:${v[port]};|g" "$path"
}
NginxVhost::configure() {
local self="$1"
local existing
existing="$(NginxVhost::find_path "$self")"
if [ -z "$existing" ]; then
NginxVhost::create "$self"
else
NginxVhost::repoint "$self" "$existing"
fi
nginx -t 2>&1 | tail -2
systemctl reload nginx
Logger::info "nginx recargado"
}
NginxVhost::remove() {
local self="$1"
local path
path="$(NginxVhost::find_path "$self")"
if [ -n "$path" ]; then
Logger::info "borrando vhost $path"
rm -f "$path"
nginx -t && systemctl reload nginx
fi
}
# ---------------------------------------------------------------------------
# TlsCert — entidad: certificado Let's Encrypt de un entorno.
# ---------------------------------------------------------------------------
TlsCert::new() {
local self="$1"
local host="$2"
declare -gA "$self"
local -n c="$self"
c[host]="$host"
}
TlsCert::exists() {
local self="$1"
local -n c="$self"
[ -d "/etc/letsencrypt/live/${c[host]}" ]
}
TlsCert::ensure() {
local self="$1"
local -n c="$self"
local host="${c[host]}"
local pub_ip resolved
if TlsCert::exists "$self"; then
Logger::info "cert TLS ya presente para $host"
return
fi
Logger::info "comprobando DNS de $host"
pub_ip="$(Host::public_ip)"
resolved="$(Host::resolved_ip "$host")"
if [ -n "$pub_ip" ] && [ "$resolved" = "$pub_ip" ]; then
Logger::info "emitiendo cert con certbot --nginx"
if certbot --nginx -d "$host" -n --redirect --keep-until-expiring; then
Logger::info "cert emitido ✓"
else
Logger::error "certbot falló; $host sigue en HTTP. Revisa y vuelve a lanzar el workflow."
fi
else
Logger::error "DNS de $host -> '${resolved:-<sin resolver>}', esperado $pub_ip."
Logger::error "Crea el registro A $host -> $pub_ip y, tras propagar, re-lanza el workflow"
Logger::error "(o ejecuta: certbot --nginx -d $host). La app ya vive en http://$host"
fi
}
TlsCert::delete() {
local self="$1"
local -n c="$self"
local host="${c[host]}"
if TlsCert::exists "$self"; then
Logger::info "borrando cert $host"
certbot delete --cert-name "$host" -n 2>/dev/null || true
fi
}
TlsCert::protocol() {
local self="$1"
if TlsCert::exists "$self"; then
echo "https"
else
echo "http"
fi
}
# ---------------------------------------------------------------------------
# Env — aggregate root: un entorno de rama. Compone SystemdUnit, NginxVhost, TlsCert.
# ---------------------------------------------------------------------------
Env::new() {
local self="$1"
local branch="$2"
local root="$3"
declare -gA "$self"
local -n s="$self"
s[branch]="$branch"
s[root]="$root"
s[slug]="$(Slug::from "$branch")"
if [ -z "${s[slug]}" ]; then
Logger::error "slug vacío para la rama '$branch'"
exit 2
fi
s[host]="${s[slug]}.$DOMAIN"
s[dest]="/opt/localesp/${s[slug]}"
s[unit_name]="localesp-${s[slug]}"
s[port]="$(Port::acquire "${s[unit_name]}" "${s[dest]}")"
# Composición: crea los colaboradores y guarda sus nombres en el agregado.
SystemdUnit::new "${self}__unit" "${s[unit_name]}" "${s[dest]}" "$branch" "${s[host]}" "${s[port]}"
NginxVhost::new "${self}__vhost" "${s[slug]}" "${s[host]}" "${s[port]}"
TlsCert::new "${self}__cert" "${s[host]}"
}
Env::deploy() {
local self="$1"
local -n s="$self"
Artifacts::require_built "${s[root]}"
Logger::info "sincronizando artefactos -> ${s[dest]}"
Artifacts::sync "${s[root]}" "${s[dest]}"
Logger::info "instalando dependencias (prod)"
Deps::install "${s[root]}" "${s[dest]}"
SystemdUnit::write "${self}__unit"
Logger::info "configurando nginx para ${s[host]}"
NginxVhost::configure "${self}__vhost"
TlsCert::ensure "${self}__cert"
}
Env::teardown() {
local self="$1"
local -n s="$self"
SystemdUnit::remove "${self}__unit"
TlsCert::delete "${self}__cert"
NginxVhost::remove "${self}__vhost"
rm -rf "${s[dest]}"
}
Env::url() {
local self="$1"
local -n s="$self"
local proto
proto="$(TlsCert::protocol "${self}__cert")"
echo "$proto://${s[host]}"
}
Env::banner() {
local self="$1"
local -n s="$self"
echo "rama=${s[branch]} slug=${s[slug]} host=${s[host]} dest=${s[dest]} puerto=${s[port]}"
}
Env::identity() {
local self="$1"
local -n s="$self"
echo "${s[host]} (${s[dest]}, ${s[unit_name]})"
}
Env::summary() {
local self="$1"
local -n s="$self"
echo "rama=${s[branch]} unit=${s[unit_name]} puerto=${s[port]}"
}
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# teardown.sh — Elimina el entorno de una rama (al borrar la rama o a mano).
# Toda la lógica vive en lib/env.sh (modelo OOP). Éste es sólo el entrypoint.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/env.sh
. "$SCRIPT_DIR/lib/env.sh"
usage() {
echo "usage: $0 <branch>" >&2
exit 2
}
main() {
local branch root
branch="${1:-}"
if [ -z "$branch" ]; then
usage
fi
root="$(cd "$SCRIPT_DIR/.." && pwd)"
Logger::set_tag teardown
Env::new env "$branch" "$root"
Logger::info "$(Env::identity env)"
Env::teardown env
Logger::info "entorno eliminado ✓"
}
main "$@"
+89
View File
@@ -0,0 +1,89 @@
import Database from "better-sqlite3";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
const __dirname = dirname(fileURLToPath(import.meta.url));
// Ruta de la BD sobreescribible para pruebas (admite ":memory:"); en
// producción se usa el archivo del repo.
const dbPath = process.env.LOCALESP_DB || join(__dirname, "../localesp.db");
const db = new Database(dbPath);
db.exec(`
CREATE TABLE IF NOT EXISTS locales (
id TEXT PRIMARY KEY,
nombre TEXT NOT NULL,
descripcion TEXT,
provincia TEXT,
subcategoria TEXT,
direccion TEXT,
enlaceGoogleMaps TEXT,
puntuacion INTEGER DEFAULT 0,
categoria TEXT,
fecha TEXT DEFAULT CURRENT_TIMESTAMP,
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
// (sin provincia/subcategoria/direccion/enlaceGoogleMaps/puntuacion),
// añadimos las columnas que falten sin borrar nada, porque
// CREATE TABLE IF NOT EXISTS no modifica tablas ya creadas.
function ensureColumn(table, column, definition) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
if (!cols.includes(column)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
}
}
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;
+180
View File
@@ -0,0 +1,180 @@
import express from "express";
import { fileURLToPath, pathToFileURL } from "url";
import { dirname, join } from "path";
import db from "./db.js";
import { randomUUID } from "crypto";
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
app.use(express.json());
app.use(express.static(join(__dirname, "../dist")));
// Locales (público, solo lectura)
app.get("/api/locales", (_req, res) => {
const locales = db.prepare("SELECT * FROM locales ORDER BY fecha DESC").all();
res.json(locales);
});
// Pendientes (admin)
app.get("/api/pendientes", (_req, res) => {
const pendientes = db.prepare("SELECT * FROM pendientes ORDER BY fechaPropuesta DESC").all();
res.json(pendientes);
});
// Utilidades para detectar duplicados
function normaliza(s) {
return (s || "").toString().toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim();
}
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(`
INSERT INTO pendientes (id, nombre, descripcion, provincia, subcategoria, direccion, enlaceGoogleMaps, puntuacion, categoria, estado, fechaPropuesta, lat, lng)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pendiente', ?, ?, ?)
`).run(id, nombre, descripcion, provincia, subcategoria, direccion, enlaceGoogleMaps, puntuacion ?? 0, categoria, fechaPropuesta, lat, lng);
res.status(201).json({ id, nombre, descripcion, provincia, subcategoria, direccion, enlaceGoogleMaps, puntuacion: puntuacion ?? 0, categoria, estado: "pendiente", fechaPropuesta, lat, lng });
});
// Aprobar propuesta
app.post("/api/aprobar/:id", (req, res) => {
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);
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) => {
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 esPuntoDeEntrada = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (esPuntoDeEntrada) {
app.listen(PORT, () => console.log(`Server listening on http://localhost:${PORT}`));
}
export default app;
+351
View File
@@ -0,0 +1,351 @@
// 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. 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} />;
}
+454
View File
@@ -0,0 +1,454 @@
import { useEffect, useRef, useState } from "react";
import { comprobarDuplicado, contienepalabrasProhibidas, enviarPropuesta, obtenerPalabrasFiltradas, suscribirLocales } from "./firebase.js";
import { CATEGORIAS, COLORES_MAPA, NOMBRES_CATEGORIAS, obtenerCategoria } from "./constants/categorias.js";
import { COORDS_PROVINCIAS, PROVINCIAS, CENTRO_ESPANA } from "./constants/provincias.js";
import { distanciaKm as calcularDistanciaKm, enlaceGoogleMapsDesdeLocal, enlaceWazeDesdeLocal } from "./utils/geo.js";
import { inputStyle, ANCHO_CUERPO } from "./styles/shared.js";
import LocalCard from "./components/LocalCard.jsx";
import BannerMovil from "./components/BannerMovil.jsx";
import ModalPrivacidad from "./components/ModalPrivacidad.jsx";
import BotonFlotante from "./components/BotonFlotante.jsx";
import BotonProponerArriba from "./components/BotonProponerArriba.jsx";
import ModalPasarela from "./components/ModalPasarela.jsx";
import useFavoritos from "./hooks/useFavoritos.js";
import useUbicacion from "./hooks/useUbicacion.js";
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() {
const [locales, setLocales] = useState([]);
const [vista, setVista] = useState("lista");
const [pasarelaAbierta, setPasarelaAbierta] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [enviado, setEnviado] = useState(false); // confirmación de propuesta enviada
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);
// 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;
if (!lat || !lng) {
const fallback = COORDS_PROVINCIAS[form.provincia] || CENTRO_ESPANA;
lat = fallback[0] + (Math.random() - 0.5) * 0.04;
lng = fallback[1] + (Math.random() - 0.5) * 0.04;
}
// Comprobar si ya existe un local igual (mismo nombre + provincia, o muy cerca)
setComprobandoDuplicado(true);
try {
const chequeo = await comprobarDuplicado({ nombre: form.nombre, provincia: form.provincia, lat, lng });
setComprobandoDuplicado(false);
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);
};
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 (
<div style={{ maxWidth: ANCHO_CUERPO, margin: "0 auto", padding: "1.5rem 1rem", fontFamily: "var(--font-sans)" }}>
{/* Header */}
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: "1.5rem", gap: 12, flexWrap: "wrap" }}>
<div>
<h1 style={{ margin: 0, lineHeight: 0 }}>
<img
src="/logo-localesp.png"
srcSet="/logo-localesp.png 1x, /logo-localesp@2x.png 2x"
alt="LocalESP · Locales Españoles"
width={220}
height={62}
style={{ display: "block", width: "clamp(140px, 35vw, 200px)", height: "auto" }}
/>
</h1>
<p style={{ margin: "6px 0 0", fontSize: 13, color: "var(--color-text-secondary)" }}>Directorio colaborativo de negocios por toda España</p>
</div>
</div>
{/* Confirmación enviada */}
{enviado && (
<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>
))}
{localesFiltrados.length < locales.length && (
<p style={{ textAlign: "center", fontSize: 12, color: "var(--color-text-tertiary)", margin: "4px 0" }}>
Mostrando {localesFiltrados.length} de {locales.length} locales
</p>
)}
</div>
)}
</>
)}
{/* 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>
);
}
+383
View File
@@ -0,0 +1,383 @@
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>
);
}
+185
View File
@@ -0,0 +1,185 @@
// 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;
}
+103
View File
@@ -0,0 +1,103 @@
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>
);
}
+125
View File
@@ -0,0 +1,125 @@
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>
);
}
+67
View File
@@ -0,0 +1,67 @@
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>
);
}
+33
View File
@@ -0,0 +1,33 @@
import useEsMovil from "../hooks/useEsMovil.js";
import { ANCHO_CUERPO } from "../styles/shared.js";
// Botón "Proponer local" arriba a la derecha (solo PC): hace exactamente lo
// mismo que el FAB inferior abre la pasarela de propuesta pero da un
// punto de acceso visible en la zona superior, alineado con el borde derecho
// del cuerpo central (columna de ANCHO_CUERPO px) para acompañar al contenido
// en pantallas anchas. En móvil no se renderiza (ahí basta el FAB inferior).
export default function BotonProponerArriba({ onClick }) {
const esMovil = useEsMovil();
if (esMovil) return null;
return (
<button
type="button"
onClick={onClick}
aria-label="Añade un nuevo local al directorio"
style={{
position: "fixed",
top: 20,
right: `max(16px, calc((100vw - ${ANCHO_CUERPO}px) / 2 + 16px))`,
zIndex: 900,
display: "flex", alignItems: "center", gap: 8,
background: "#8B0000", color: "white", border: "none", borderRadius: 999,
padding: "11px 22px", fontSize: 15, fontWeight: 600,
cursor: "pointer", fontFamily: "var(--font-sans)",
boxShadow: "0 4px 14px rgba(139,0,0,0.35)",
}}
>
<i className="ti ti-plus" aria-hidden="true"></i> Proponer local
</button>
);
}
+19
View File
@@ -0,0 +1,19 @@
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>
);
}
+168
View File
@@ -0,0 +1,168 @@
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>
);
}
+169
View File
@@ -0,0 +1,169 @@
import { useEffect, useRef, useState } from "react";
import useEsMovil from "../hooks/useEsMovil.js";
import PasoUbicacion from "./PasoUbicacion.jsx";
import PasoNombre from "./PasoNombre.jsx";
import PasoProvincia from "./PasoProvincia.jsx";
import PasoCategoria from "./PasoCategoria.jsx";
import PasoSubcategoria from "./PasoSubcategoria.jsx";
import PasoResumen from "./PasoResumen.jsx";
// Definición declarativa de las pantallas de la pasarela: orden, título y
// validez de cada paso (habilita o deshabilita "Siguiente"). El paso final
// gestiona su propio botón de envío en PasoResumen.
const PASOS = [
{ id: "ubicacion", titulo: "Ubicación", Componente: PasoUbicacion, puedeContinuar: (f) => f.lat != null && f.lng != null },
{ id: "nombre", titulo: "Nombre", Componente: PasoNombre, puedeContinuar: (f) => (f.nombre || "").trim().length >= 2 },
{ id: "provincia", titulo: "Provincia", Componente: PasoProvincia, puedeContinuar: (f) => !!f.provincia },
{ id: "categoria", titulo: "Categoría", Componente: PasoCategoria, puedeContinuar: (f) => !!f.categoria },
{ id: "subcategoria", titulo: "Tipo específico", Componente: PasoSubcategoria, puedeContinuar: () => true },
{ id: "resumen", titulo: "Revisión y envío", Componente: PasoResumen, puedeContinuar: () => true },
];
// Modal de la pasarela para proponer un local: una pantalla por campo con
// navegación atrás/siguiente e indicador de progreso. Sigue el patrón de
// ModalPrivacidad (overlay + role="dialog" + cierre por fondo//Escape) y
// añade contención del foco de teclado. Responsive: en móvil el panel ocupa
// todo el ancho y casi todo el alto; en escritorio queda centrado (~480px).
export default function ModalPasarela({
form, setForm,
onCerrar, onEnviar, onConfirmarDuplicado, onDescartarDuplicado,
aceptaPrivacidad, setAceptaPrivacidad, onAbrirPrivacidad,
errorFiltro, posibleDuplicado, comprobandoDuplicado, saving,
}) {
const [paso, setPaso] = useState(0);
const esMovil = useEsMovil();
const panelRef = useRef(null);
const cuerpoRef = useRef(null);
const pasoActual = PASOS[paso];
const esUltimo = paso === PASOS.length - 1;
const puedeContinuar = pasoActual.puedeContinuar(form);
const enviando = comprobandoDuplicado || saving;
// Foco inicial en el primer control de la pantalla activa (no del panel
// completo: el de la cabecera precede al cuerpo en el DOM). La
// contención de Tab evita que el foco se escape del modal.
useEffect(() => {
const cuerpo = cuerpoRef.current;
if (!cuerpo) return;
const primerControl = cuerpo.querySelector("input, select, textarea, button:not([disabled])");
primerControl?.focus();
}, [paso]);
const alTeclear = (e) => {
if (e.key === "Escape") {
e.stopPropagation();
onCerrar();
return;
}
if (e.key !== "Tab") return;
const focos = panelRef.current?.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
if (!focos?.length) return;
const primero = focos[0];
const ultimo = focos[focos.length - 1];
if (e.shiftKey && document.activeElement === primero) {
e.preventDefault();
ultimo.focus();
} else if (!e.shiftKey && document.activeElement === ultimo) {
e.preventDefault();
primero.focus();
}
};
const { Componente } = pasoActual;
return (
<div
role="dialog"
aria-modal="true"
aria-label={`Proponer local: ${pasoActual.titulo}`}
style={{
position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", zIndex: 1000,
display: "flex", alignItems: "center", justifyContent: "center",
padding: esMovil ? 8 : 16,
}}
onClick={(e) => { if (e.target === e.currentTarget) onCerrar(); }}
onKeyDown={alTeclear}
>
<div
ref={panelRef}
onClick={(e) => e.stopPropagation()}
style={{
background: "var(--color-background-primary)",
borderRadius: "var(--border-radius-lg)",
width: "100%",
maxWidth: esMovil ? "none" : 480,
height: esMovil ? "92dvh" : undefined,
maxHeight: "92vh",
display: "flex", flexDirection: "column",
overflow: "hidden",
fontFamily: "var(--font-sans)",
}}
>
{/* Cabecera: título, progreso y cierre */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10, padding: "14px 16px 10px", borderBottom: "0.5px solid var(--color-border-tertiary)" }}>
<div>
<p style={{ margin: 0, fontWeight: 600, fontSize: 16 }}>Proponer un local</p>
<p style={{ margin: "2px 0 0", fontSize: 12, color: "var(--color-text-secondary)" }}>
{pasoActual.titulo} · Paso {paso + 1} de {PASOS.length}
</p>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ display: "flex", gap: 4 }} aria-hidden="true">
{PASOS.map((p, i) => (
<span key={p.id} style={{ width: 16, height: 4, borderRadius: 2, background: i <= paso ? "#8B0000" : "var(--color-border-tertiary)", transition: "background 0.15s" }} />
))}
</div>
<button onClick={onCerrar} aria-label="Cerrar" disabled={enviando} style={{ background: "none", border: "none", cursor: enviando ? "not-allowed" : "pointer", fontSize: 18, color: "var(--color-text-tertiary)" }}></button>
</div>
</div>
{/* Cuerpo: la pantalla activa (con scroll si no cabe) */}
<div ref={cuerpoRef} style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: "14px 16px" }}>
<Componente
form={form}
setForm={setForm}
{...(pasoActual.id === "resumen" ? {
aceptaPrivacidad, setAceptaPrivacidad, onAbrirPrivacidad,
onEnviar, onConfirmarDuplicado, onDescartarDuplicado,
errorFiltro, posibleDuplicado, comprobandoDuplicado, saving,
} : {})}
/>
</div>
{/* Pie: navegación atrás/siguiente */}
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, padding: "12px 16px", borderTop: "0.5px solid var(--color-border-tertiary)" }}>
<button
onClick={paso === 0 ? onCerrar : () => setPaso((p) => p - 1)}
disabled={enviando}
style={{
background: "none", border: "0.5px solid var(--color-border-secondary)",
borderRadius: "var(--border-radius-md)", padding: "8px 16px",
fontSize: 14, cursor: enviando ? "not-allowed" : "pointer",
color: "var(--color-text-secondary)",
}}
>
{paso === 0 ? "Cancelar" : "← Atrás"}
</button>
{!esUltimo && (
<button
onClick={() => setPaso((p) => p + 1)}
disabled={!puedeContinuar}
style={{
background: puedeContinuar ? "#8B0000" : "var(--color-background-secondary)",
color: puedeContinuar ? "white" : "var(--color-text-tertiary)",
border: "none", borderRadius: "var(--border-radius-md)",
padding: "8px 20px", fontSize: 14, fontWeight: 500,
cursor: puedeContinuar ? "pointer" : "not-allowed",
}}
>
Siguiente
</button>
)}
</div>
</div>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
// 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>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { inputStyle } from "../styles/shared.js";
import { CATEGORIAS, NOMBRES_CATEGORIAS } from "../constants/categorias.js";
// Paso 4 de la pasarela: categoría principal del negocio.
export default function PasoCategoria({ form, setForm }) {
return (
<div>
<label htmlFor="paso-categoria" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>
Categoría <span style={{ color: "#8B0000" }}>*</span>
</label>
<select
id="paso-categoria"
style={inputStyle}
value={form.categoria}
onChange={(e) => setForm((f) => ({ ...f, categoria: e.target.value, subcategoria: "" }))}
>
<option value="">Selecciona categoría...</option>
{NOMBRES_CATEGORIAS.map((c) => <option key={c} value={c}>{CATEGORIAS[c].emoji} {c}</option>)}
</select>
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: "6px 0 0" }}>
Elige el grupo general al que pertenece el negocio.
</p>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { inputStyle } from "../styles/shared.js";
// Paso 2 de la pasarela: nombre del local, pre-rellenado con lo inferido
// desde la ubicación (editable). Si la inferencia falló, llega vacío.
export default function PasoNombre({ form, setForm }) {
return (
<div>
<label htmlFor="paso-nombre" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>
Nombre del local <span style={{ color: "#8B0000" }}>*</span>
</label>
<input
id="paso-nombre"
style={inputStyle}
autoComplete="off"
placeholder="Bar El Olivo, Clínica San José..."
value={form.nombre}
onChange={(e) => setForm((f) => ({ ...f, nombre: e.target.value }))}
/>
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: "6px 0 0" }}>
{form.nombre
? "Si el nombre se rellenó automáticamente desde la ubicación, comprueba que es correcto antes de continuar."
: "El nombre con el que la gente conoce el negocio."}
</p>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { inputStyle } from "../styles/shared.js";
import { PROVINCIAS } from "../constants/provincias.js";
// Paso 3 de la pasarela: provincia, pre-rellenada con lo inferido desde la
// ubicación (si la inferencia falló o no casó con el listado, llega vacía).
export default function PasoProvincia({ form, setForm }) {
return (
<div>
<label htmlFor="paso-provincia" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>
Provincia <span style={{ color: "#8B0000" }}>*</span>
</label>
<select
id="paso-provincia"
style={inputStyle}
value={form.provincia}
onChange={(e) => setForm((f) => ({ ...f, provincia: e.target.value }))}
>
<option value="">Selecciona provincia...</option>
{PROVINCIAS.map((p) => <option key={p} value={p}>{p}</option>)}
</select>
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: "6px 0 0" }}>
Si se rellenó automáticamente desde la ubicación, verifica que es correcta.
</p>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { CATEGORIAS } from "../constants/categorias.js";
import { ETIQUETAS_PROVEEDOR } from "../utils/ubicacion/index.js";
const fila = { display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 10, padding: "6px 0", borderBottom: "0.5px solid var(--color-border-tertiary)", fontSize: 13 };
const clave = { color: "var(--color-text-secondary)", flexShrink: 0 };
const valor = { textAlign: "right", fontWeight: 500, wordBreak: "break-word" };
// Paso 6 (final) de la pasarela: resumen legible de todo lo introducido,
// aceptación obligatoria de la política de privacidad y envío de la
// propuesta. Los avisos de palabra prohibida y de posible duplicado se
// muestran dentro del propio modal.
export default function PasoResumen({
form, aceptaPrivacidad, setAceptaPrivacidad, onAbrirPrivacidad,
onEnviar, onConfirmarDuplicado, onDescartarDuplicado,
errorFiltro, posibleDuplicado, comprobandoDuplicado, saving,
}) {
const categoria = form.categoria ? CATEGORIAS[form.categoria] : null;
const enviando = comprobandoDuplicado || saving;
const etiquetaProveedor = form.proveedorUbicacion ? ETIQUETAS_PROVEEDOR[form.proveedorUbicacion] || form.proveedorUbicacion : "";
return (
<div>
<p style={{ margin: "0 0 8px", fontSize: 13, color: "var(--color-text-secondary)" }}>
Comprueba los datos antes de enviar. El administrador revisará la propuesta antes de publicarla.
</p>
<div style={{ background: "var(--color-background-secondary)", borderRadius: "var(--border-radius-md)", padding: "4px 12px", marginBottom: 12 }}>
<div style={fila}>
<span style={clave}>📍 Ubicación</span>
<span style={valor}>
{etiquetaProveedor && <>{etiquetaProveedor}<br /></>}
{form.lat != null && form.lng != null && <span style={{ fontWeight: 400 }}>{form.lat.toFixed(4)}, {form.lng.toFixed(4)}</span>}
{form.direccion && <span style={{ fontWeight: 400, display: "block" }}>{form.direccion}</span>}
</span>
</div>
<div style={fila}>
<span style={clave}>🏷 Nombre</span>
<span style={valor}>{form.nombre}</span>
</div>
<div style={fila}>
<span style={clave}>🗺 Provincia</span>
<span style={valor}>{form.provincia}</span>
</div>
<div style={fila}>
<span style={clave}>📂 Categoría</span>
<span style={valor}>{categoria ? `${categoria.emoji} ${form.categoria}` : ""}</span>
</div>
<div style={{ ...fila, borderBottom: "none" }}>
<span style={clave}>🔖 Tipo específico</span>
<span style={{ ...valor, fontWeight: form.subcategoria ? 500 : 400, color: form.subcategoria ? "inherit" : "var(--color-text-tertiary)" }}>
{form.subcategoria || "—"}
</span>
</div>
</div>
<label style={{ display: "flex", alignItems: "flex-start", gap: 8, marginBottom: 12, fontSize: 12, color: "var(--color-text-secondary)", cursor: "pointer" }}>
<input
type="checkbox"
checked={aceptaPrivacidad}
onChange={(e) => setAceptaPrivacidad(e.target.checked)}
style={{ marginTop: 2 }}
/>
<span>
He leído y acepto la{" "}
<button
type="button"
onClick={(e) => { e.preventDefault(); onAbrirPrivacidad(); }}
style={{ background: "none", border: "none", padding: 0, color: "#8B0000", textDecoration: "underline", cursor: "pointer", fontSize: 12 }}
>
política de privacidad
</button>
{" "}sobre cómo se usan estos datos. <span style={{ color: "#8B0000" }}>*</span>
</span>
</label>
{/* Aviso de palabra prohibida */}
{errorFiltro && (
<div role="alert" style={{ marginBottom: 12, background: "#FDEDEC", border: "1px solid #F5B7B1", borderRadius: "var(--border-radius-md)", padding: "10px 14px", fontSize: 13, color: "#922B21", display: "flex", alignItems: "center", gap: 8 }}>
<i className="ti ti-ban" aria-hidden="true"></i> {errorFiltro}
</div>
)}
{/* Aviso de posible duplicado */}
{posibleDuplicado && (
<div role="alert" style={{ marginBottom: 12, background: "#FDF2E9", border: "1px solid #F5CBA7", borderRadius: "var(--border-radius-md)", padding: "10px 14px", fontSize: 13, color: "#784212" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: 500 }}>
<i className="ti ti-alert-triangle" aria-hidden="true"></i> Ya existe un local parecido
</div>
<ul style={{ margin: "6px 0", paddingLeft: 20 }}>
{posibleDuplicado.coincidencias.map((c) => (
<li key={c.id}>{c.nombre} · {c.provincia}{c.direccion ? ` · ${c.direccion}` : ""} ({c.estado === "pendiente" ? "pendiente de revisión" : "ya publicado"})</li>
))}
</ul>
<div style={{ display: "flex", gap: 8, marginTop: 4 }}>
<button onClick={onConfirmarDuplicado} disabled={saving}
style={{ background: "#8B0000", color: "white", border: "none", borderRadius: "var(--border-radius-md)", padding: "6px 14px", fontSize: 12, fontWeight: 500, cursor: "pointer" }}>
{saving ? "Enviando..." : "Enviar de todos modos"}
</button>
<button onClick={onDescartarDuplicado} style={{ background: "none", border: "0.5px solid var(--color-border-secondary)", borderRadius: "var(--border-radius-md)", padding: "6px 14px", fontSize: 12, cursor: "pointer", color: "var(--color-text-secondary)" }}>Revisar datos</button>
</div>
</div>
)}
<button
onClick={onEnviar}
disabled={!aceptaPrivacidad || enviando}
style={{
background: aceptaPrivacidad ? "#8B0000" : "var(--color-background-secondary)",
color: aceptaPrivacidad ? "white" : "var(--color-text-tertiary)",
border: "none", borderRadius: "var(--border-radius-md)",
padding: "10px 20px", fontSize: 14, fontWeight: 500, width: "100%",
cursor: aceptaPrivacidad && !enviando ? "pointer" : "not-allowed",
}}
>
{comprobandoDuplicado ? "Comprobando..." : saving ? "Enviando..." : "📨 Enviar propuesta"}
</button>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { inputStyle } from "../styles/shared.js";
import { CATEGORIAS } from "../constants/categorias.js";
// Paso 5 de la pasarela: tipo específico (subcategoría), opcional. Solo está
// accesible tras elegir categoría (el paso anterior lo exige), y se puede
// continuar sin seleccionar ninguna.
export default function PasoSubcategoria({ form, setForm }) {
const subcategorias = form.categoria ? CATEGORIAS[form.categoria]?.subcategorias || [] : [];
return (
<div>
<label htmlFor="paso-subcategoria" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>
Tipo específico
</label>
<select
id="paso-subcategoria"
style={{ ...inputStyle, opacity: form.categoria ? 1 : 0.5 }}
value={form.subcategoria}
onChange={(e) => setForm((f) => ({ ...f, subcategoria: e.target.value }))}
disabled={!form.categoria}
>
<option value="">Sin tipo específico</option>
{subcategorias.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: "6px 0 0" }}>
Paso opcional: puedes continuar sin elegir un tipo específico.
</p>
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
import { useEffect, useRef, useState } from "react";
import { ETIQUETAS_PROVEEDOR, inferirDesdeCoords, resolverUbicacion } from "../utils/ubicacion/index.js";
import { inputStyle } from "../styles/shared.js";
const DEBOUNCE_MS = 600;
// Proveedores cuya entrada es un enlace (se guarda como enlace del local)
const PROVEEDORES_ENLACE = new Set(["google-maps", "waze", "osm"]);
// Paso 1 de la pasarela: un único campo universal de ubicación que acepta
// enlaces de Google Maps / Waze / OpenStreetMap, coordenadas sueltas o una
// dirección en texto. Con debounce resuelve la entrada con el primer
// proveedor que la reconozca y, si solo hay coordenadas, lanza la
// geocodificación inversa para pre-rellenar nombre/provincia/dirección.
export default function PasoUbicacion({ form, setForm }) {
const [texto, setTexto] = useState(form.entradaUbicacion || "");
// fase: inactivo | resolviendo | resuelto | noreconocido
const [estado, setEstado] = useState(() =>
form.lat != null && form.lng != null
? { fase: "resuelto", resolucion: { proveedor: form.proveedorUbicacion, lat: form.lat, lng: form.lng }, aviso: "" }
: { fase: "inactivo", resolucion: null, aviso: "" },
);
const debounceRef = useRef(null);
// Identificador de resolución en curso: descarta respuestas asíncronas
// obsoletas si el usuario sigue escribiendo.
const idResolucionRef = useRef(0);
useEffect(() => () => clearTimeout(debounceRef.current), []);
const resolver = async (entrada, id) => {
if (id !== idResolucionRef.current) return;
setEstado({ fase: "resolviendo", resolucion: null, aviso: "" });
let resultado = null;
try {
resultado = await resolverUbicacion(entrada);
} catch {
resultado = null; // fallo controlado: la pasarela nunca se rompe
}
if (id !== idResolucionRef.current) return;
if (!resultado) {
setEstado({
fase: "noreconocido",
resolucion: null,
aviso: "No se pudo interpretar la ubicación. Si pegaste un enlace acortado, copia la URL completa; si escribiste una dirección, sé más específico.",
});
return;
}
const esEnlace = PROVEEDORES_ENLACE.has(resultado.proveedor);
setEstado({ fase: "resuelto", resolucion: resultado, aviso: "" });
setForm((f) => ({
...f,
entradaUbicacion: entrada,
proveedorUbicacion: resultado.proveedor,
lat: resultado.lat,
lng: resultado.lng,
enlaceGoogleMaps: esEnlace ? entrada : "",
direccion: resultado.direccion || "",
nombre: resultado.nombre || "",
provincia: resultado.provincia || "",
}));
// Inferencia por geocodificación inversa solo si el resultado no trajo ya
// nombre y provincia (una sola llamada a Nominatim por resolución).
if (!resultado.nombre || !resultado.provincia) {
const inferido = await inferirDesdeCoords(resultado.lat, resultado.lng);
if (id !== idResolucionRef.current) return;
if (inferido) {
setForm((f) => ({
...f,
nombre: inferido.nombre || f.nombre,
provincia: inferido.provincia || f.provincia,
direccion: f.direccion || inferido.direccion || "",
}));
} else {
setEstado((e) => ({
...e,
aviso: "No se pudieron inferir el nombre y la provincia automáticamente (posible fallo de red). Podrás rellenarlos a mano en los pasos siguientes.",
}));
}
}
};
const handleCambio = (val) => {
setTexto(val);
clearTimeout(debounceRef.current);
const id = ++idResolucionRef.current;
const limpio = val.trim();
// En cuanto cambia el texto, la resolución anterior deja de ser válida:
// se limpia el chip y las coordenadas del form hasta la nueva resolución
setEstado({ fase: "inactivo", resolucion: null, aviso: "" });
setForm((f) => ({ ...f, entradaUbicacion: val, proveedorUbicacion: "", lat: null, lng: null }));
if (!limpio) return;
debounceRef.current = setTimeout(() => resolver(limpio, id), DEBOUNCE_MS);
};
const { fase, resolucion, aviso } = estado;
const etiqueta = resolucion ? ETIQUETAS_PROVEEDOR[resolucion.proveedor] || resolucion.proveedor : "";
return (
<div>
<label htmlFor="paso-ubicacion" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>
Ubicación del local <span style={{ color: "#8B0000" }}>*</span>
</label>
<input
id="paso-ubicacion"
style={inputStyle}
autoComplete="off"
placeholder="Enlace de Google Maps / Waze / OpenStreetMap, coordenadas o dirección"
value={texto}
onChange={(e) => handleCambio(e.target.value)}
aria-describedby="paso-ubicacion-ayuda"
/>
<p id="paso-ubicacion-ayuda" style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: "6px 0 0" }}>
Pega el enlace del local, escribe sus coordenadas (p. ej. 41.3851, 2.1734) o su dirección.
</p>
{fase === "resolviendo" && (
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 10, fontSize: 12, color: "var(--color-text-secondary)" }}>
<i className="ti ti-loader" aria-hidden="true" style={{ fontSize: 14 }}></i> Resolviendo ubicación...
</div>
)}
{fase === "resuelto" && resolucion && (
<div style={{
display: "flex", alignItems: "flex-start", gap: 6, marginTop: 10,
background: "#EAFAF1", color: "#1E8449",
padding: "6px 10px", borderRadius: "var(--border-radius-md)", fontSize: 12,
}}>
<i className="ti ti-circle-check" aria-hidden="true" style={{ fontSize: 14, flexShrink: 0, marginTop: 1 }}></i>
<span style={{ lineHeight: 1.4 }}>
<strong>{etiqueta} detectado</strong>{" · "}{resolucion.lat.toFixed(4)}, {resolucion.lng.toFixed(4)}
</span>
</div>
)}
{fase === "noreconocido" && (
<div role="alert" style={{
display: "flex", alignItems: "flex-start", gap: 6, marginTop: 10,
background: "#FDF2E9", color: "#784212",
padding: "6px 10px", borderRadius: "var(--border-radius-md)", fontSize: 12,
}}>
<i className="ti ti-alert-triangle" aria-hidden="true" style={{ fontSize: 14, flexShrink: 0, marginTop: 1 }}></i>
<span style={{ lineHeight: 1.4 }}>{aviso}</span>
</div>
)}
{fase === "resuelto" && aviso && (
<div role="status" style={{
display: "flex", alignItems: "flex-start", gap: 6, marginTop: 6,
background: "#FDF2E9", color: "#784212",
padding: "6px 10px", borderRadius: "var(--border-radius-md)", fontSize: 12,
}}>
<i className="ti ti-wifi-off" aria-hidden="true" style={{ fontSize: 14, flexShrink: 0, marginTop: 1 }}></i>
<span style={{ lineHeight: 1.4 }}>{aviso}</span>
</div>
)}
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
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>
);
}
+67
View File
@@ -0,0 +1,67 @@
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>
);
}
+24
View File
@@ -0,0 +1,24 @@
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>
);
}
+86
View File
@@ -0,0 +1,86 @@
// 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;
}
+34
View File
@@ -0,0 +1,34 @@
// 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];
+21
View File
@@ -0,0 +1,21 @@
// 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";
+25
View File
@@ -0,0 +1,25 @@
import { useEffect, useState } from "react";
// Detecta si el viewport es estrecho (móvil) consultando matchMedia, para
// adaptar la presentación de la pasarela (panel a pantalla casi completa).
export default function useEsMovil(query = "(max-width: 600px)") {
const [esMovil, setEsMovil] = useState(
() => typeof window !== "undefined" && window.matchMedia && window.matchMedia(query).matches,
);
useEffect(() => {
if (!window.matchMedia) return;
const mq = window.matchMedia(query);
const escuchar = (e) => setEsMovil(e.matches);
setEsMovil(mq.matches);
if (mq.addEventListener) {
mq.addEventListener("change", escuchar);
return () => mq.removeEventListener("change", escuchar);
}
// Safari antiguo
mq.addListener(escuchar);
return () => mq.removeListener(escuchar);
}, [query]);
return esMovil;
}
+36
View File
@@ -0,0 +1,36 @@
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 };
}
+72
View File
@@ -0,0 +1,72 @@
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 };
}
+18
View File
@@ -0,0 +1,18 @@
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 };
}
+33
View File
@@ -0,0 +1,33 @@
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 };
}
+26
View File
@@ -0,0 +1,26 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import AdminPanel from "./AdminPanel.jsx";
// Enrutador mínimo sin dependencias extra
const ruta = window.location.pathname.replace(/\/$/, "");
function Root() {
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);
});
});
}
+22
View File
@@ -0,0 +1,22 @@
// 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;
+56
View File
@@ -0,0 +1,56 @@
// 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),
}));
}
+103
View File
@@ -0,0 +1,103 @@
// Clase base de los proveedores de ubicación: define el contrato común que
// implementan todas las integraciones con backends de mapas (Google Maps,
// Waze, OpenStreetMap, coordenadas sueltas y Nominatim como fallback de
// texto). JS no tiene interfaces nativas, así que el contrato se documenta
// aquí y se comprueba al registrar cada instancia (ver index.js).
//
// Contrato de cada proveedor:
// - id: identificador estable del proveedor. Se copia al resultado para
// que la UI pueda etiquetar el origen de la ubicación.
// - detecta(entrada) → boolean: ¿esta entrada parece pertenecer a este
// proveedor? Debe ser barato y síncrono (normalmente un regex).
// - extrae(entrada) → Promise<Resultado|null>: extrae la información
// disponible y devuelve { proveedor, lat, lng, ...extras } o null
// si no se pudo extraer nada. Los errores de red se degradan a un
// fallo controlado (null), nunca se lanzan al consumidor.
//
// El resultado es deliberadamente extensible: los consumidores leen solo los
// campos que conocen (hoy lat/lng/nombre/provincia/direccion), de modo que
// añadir campos nuevos en el futuro (teléfono, horarios…) no rompe nada.
import { PROVINCIAS } from "../../constants/provincias.js";
export default class ProveedorUbicacion {
// Identificador del proveedor; cada subclase debe sobrescribirlo.
id = "proveedor";
// Nombres cooficiales (normalizados y sin artículo) → provincia canónica.
// Nominatim puede devolver "Bizkaia", "Girona" o "Ourense" según el idioma
// negociado; sin este mapa la provincia quedaría vacía innecesariamente.
static ALIAS_PROVINCIAS = {
bizkaia: "Vizcaya",
gipuzkoa: "Guipúzcoa",
araba: "Álava",
girona: "Gerona",
lleida: "Lérida",
ourense: "Orense",
coruna: "La Coruña",
"illes balears": "Islas Baleares",
asturies: "Asturias",
};
// ¿Reconoce esta entrada? La base no reconoce nada.
detecta(entrada) {
return false;
}
// Extrae la ubicación de la entrada. Método "abstracto": la base lanza para
// que una subclase registrada sin implementarlo falle rápido.
async extrae(entrada) {
throw new Error("sin implementar");
}
// ── Helpers compartidos por todos los proveedores ────────────────────────
// Normaliza un texto: minúsculas, sin tildes y sin espacios extremos. Se usa
// para comparar provincias y etiquetas sin depender de acentos.
static normalizar(texto) {
return (texto || "")
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.trim();
}
normalizar(texto) {
return ProveedorUbicacion.normalizar(texto);
}
// Empareja un texto libre contra el listado canónico de PROVINCIAS,
// insensible a acentos y mayúsculas y tolerante con artículos ("Comunidad
// de Madrid" → "Madrid", "A Coruña" → "La Coruña"). Devuelve "" si no casa
// con ninguna, para que la UI fuerce la selección manual (mismo
// comportamiento que tenía AutocompletarLocal).
emparejarProvincia(texto) {
const t = this.normalizar(texto);
if (!t) return "";
const sinArticulo = (s) => s.replace(/^(el|la|los|las|a|o)\s+/, "");
const tSin = sinArticulo(t);
const directa = PROVINCIAS.find((p) => {
const np = ProveedorUbicacion.normalizar(p);
const npSin = sinArticulo(np);
return t === np || t.includes(np) || tSin === npSin || tSin.includes(npSin);
});
if (directa) return directa;
// Alias de nombres cooficiales: Nominatim puede responder en euskera,
// catalán o gallego según el idioma negociado (D12 del diseño).
return ProveedorUbicacion.ALIAS_PROVINCIAS[tSin] || "";
}
// fetch a Nominatim con las cabeceras adecuadas y manejo de errores: si la
// red o el servicio fallan devuelve null (fallo controlado) en vez de
// lanzar, para que la pasarela nunca se rompa por un error de proveedor.
async fetchNominatim(url) {
try {
const respuesta = await fetch(url, {
headers: { "Accept-Language": "es", "User-Agent": "LocalesEspanoles/1.0" },
});
if (!respuesta.ok) return null;
return await respuesta.json();
} catch {
return null;
}
}
}
+75
View File
@@ -0,0 +1,75 @@
// Punto de entrada del sistema de proveedores de ubicación.
//
// Registro abierto/cerrado: añadir un backend nuevo consiste en crear una
// clase que implemente el contrato de ProveedorUbicacion (id/detecta/extrae)
// y sumar una instancia a PROVEEDORES. Ni resolverUbicacion, ni los demás
// proveedores, ni la pasarela necesitan cambiar.
import ProveedorCoordenadas from "./proveedorCoordenadas.js";
import ProveedorGoogleMaps from "./proveedorGoogleMaps.js";
import ProveedorWaze from "./proveedorWaze.js";
import ProveedorOSM from "./proveedorOSM.js";
import ProveedorNominatim from "./proveedorNominatim.js";
// Comprueba que una instancia cumple el contrato antes de registrarla:
// fallo rápido si un proveedor nuevo olvida implementar la interfaz.
function registrar(proveedor) {
if (!proveedor?.id || typeof proveedor.detecta !== "function" || typeof proveedor.extrae !== "function") {
throw new Error(`Proveedor inválido: falta id, detecta() o extrae() en ${proveedor?.constructor?.name || "proveedor"}`);
}
return proveedor;
}
// Proveedores ordenados por prioridad: lo más específico primero y el texto
// de dirección (Nominatim) siempre al final como fallback.
export const PROVEEDORES = [
registrar(new ProveedorCoordenadas()),
registrar(new ProveedorGoogleMaps()),
registrar(new ProveedorWaze()),
registrar(new ProveedorOSM()),
registrar(new ProveedorNominatim()),
];
// Etiquetas legibles por id de proveedor, para los chips de la UI.
export const ETIQUETAS_PROVEEDOR = {
coordenadas: "Coordenadas",
"google-maps": "Google Maps",
waze: "Waze",
osm: "OpenStreetMap",
nominatim: "Dirección (OpenStreetMap)",
};
// Resuelve una entrada libre usando el primer proveedor que la reconozca y
// logre extraer datos. Devuelve { proveedor, lat, lng, ...extras } o null si
// ningún proveedor la entiende. Los errores de cada proveedor se degradan
// (se registran en consola y se continúa con el siguiente), nunca se lanzan
// al consumidor.
export async function resolverUbicacion(entrada) {
const texto = (entrada || "").trim();
if (!texto) return null;
for (const proveedor of PROVEEDORES) {
try {
if (!proveedor.detecta(texto)) continue;
const resultado = await proveedor.extrae(texto);
if (resultado && Number.isFinite(resultado.lat) && Number.isFinite(resultado.lng)) {
return resultado;
}
} catch (e) {
console.error(`[ubicacion] el proveedor ${proveedor.id} falló:`, e);
}
}
return null;
}
// Geocodificación inversa: de coordenadas a { nombre, provincia, direccion }
// con la provincia normalizada al listado canónico. Devuelve null si falla;
// la UI avisa y permite continuar sin inferencia.
export async function inferirDesdeCoords(lat, lng) {
const nominatim = PROVEEDORES.find((p) => p.id === "nominatim");
if (!nominatim) return null;
try {
return await nominatim.reverse(lat, lng);
} catch (e) {
console.error("[ubicacion] la inferencia falló:", e);
return null;
}
}
@@ -0,0 +1,32 @@
// Proveedor de coordenadas geográficas sueltas: "41.3851, 2.1734".
// Acepta signo, espacios alrededor del separador y coma o punto como
// separador decimal (incluido el par con decimales españoles "41,38, 2,17").
import ProveedorUbicacion from "./ProveedorUbicacion.js";
// Par separado por coma o punto y coma: "41.3851, 2.1734", "-3,70;40,41"
const PATRON_PAR_COMA = /^\s*(-?\d{1,3}(?:[.,]\d+))\s*[,;]\s*(-?\d{1,3}(?:[.,]\d+))\s*$/;
// Par separado por espacio con decimales obligatorios (evita falsos
// positivos con texto): "41,3851 2,1734"
const PATRON_PAR_ESPACIO = /^\s*(-?\d{1,3}[.,]\d{3,})\s+(-?\d{1,3}[.,]\d{3,})\s*$/;
const aNumero = (s) => parseFloat(String(s).replace(",", "."));
export default class ProveedorCoordenadas extends ProveedorUbicacion {
id = "coordenadas";
detecta(entrada) {
const t = entrada || "";
return PATRON_PAR_COMA.test(t) || PATRON_PAR_ESPACIO.test(t);
}
async extrae(entrada) {
const texto = (entrada || "").trim();
const par = texto.match(PATRON_PAR_COMA) || texto.match(PATRON_PAR_ESPACIO);
if (!par) return null;
const lat = aNumero(par[1]);
const lng = aNumero(par[2]);
// Sanidad: rangos válidos de coordenadas terrestres
if (Math.abs(lat) > 90 || Math.abs(lng) > 180) return null;
return { proveedor: this.id, lat, lng };
}
}
@@ -0,0 +1,38 @@
// Proveedor de enlaces de Google Maps. Migra la lógica de
// parsearEnlaceGoogleMaps (src/utils/geo.js) y reconoce los tres formatos con
// coordenadas en la URL:
// - @lat,lng → https://maps.google.com/@41.38,-2.17,15z
// - !3d…!4d… → https://google.com/maps/place/…/!3d41.38!4d-2.17
// - ?q=lat,lng → https://google.com/maps?q=41.38,-2.17
// Los enlaces acortados (maps.app.goo.gl) se detectan pero sin coordenadas en
// la URL devuelven null: la UI pide el enlace completo (unshorten pendiente).
import ProveedorUbicacion from "./ProveedorUbicacion.js";
const PATRONES_COORDS = [
/@(-?\d+\.\d+),(-?\d+\.\d+)/,
/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/,
/[?&]q=(-?\d+\.\d+),(-?\d+\.\d+)/,
];
export default class ProveedorGoogleMaps extends ProveedorUbicacion {
id = "google-maps";
detecta(entrada) {
return /maps\.google\.|google\.[a-z.]+\/maps|maps\.app\.goo\.gl|goo\.gl\/maps/i.test(entrada || "");
}
async extrae(entrada) {
const texto = entrada || "";
for (const patron of PATRONES_COORDS) {
const m = texto.match(patron);
if (m) {
const lat = parseFloat(m[1]);
const lng = parseFloat(m[2]);
if (Math.abs(lat) <= 90 && Math.abs(lng) <= 180) {
return { proveedor: this.id, lat, lng };
}
}
}
return null;
}
}
+81
View File
@@ -0,0 +1,81 @@
// Proveedor de geocodificación por texto (Nominatim / OpenStreetMap). Actúa
// como fallback de último recurso: cuando la entrada no es un enlace
// reconocible ni un par de coordenadas, se trata como dirección y se busca
// su ubicación (/search, migra geocodificarDireccion de src/utils/geo.js).
// Además expone reverse(lat, lng) (/reverse) para la geocodificación
// inversa con la que la pasarela infiere nombre, provincia y dirección.
import ProveedorUbicacion from "./ProveedorUbicacion.js";
const BASE = "https://nominatim.openstreetmap.org";
export default class ProveedorNominatim extends ProveedorUbicacion {
id = "nominatim";
// Fallback de texto: acepta cualquier entrada con letras que no parezca una
// URL (un enlace que ningún proveedor supo extraer no debe buscarse como
// si fuera una dirección).
detecta(entrada) {
const t = (entrada || "").trim();
if (t.length < 3) return false;
if (/https?:\/\/|\bwww\.|\b[a-z0-9-]+\.(com|es|org|net|gl|io|app|edu|gob)\b/i.test(t)) return false;
return /[a-záéíóúüñ]/i.test(t);
}
// Búsqueda por texto (dirección o nombre de lugar). Devuelve el resultado
// con coordenadas y, si Nominatim las facilita, nombre/provincia/dirección.
async extrae(entrada) {
const q = (entrada || "").trim();
if (!q) return null;
const params = new URLSearchParams({
q: `${q}, España`,
format: "json",
addressdetails: "1",
namedetails: "1",
limit: "1",
countrycodes: "es",
});
const datos = await this.fetchNominatim(`${BASE}/search?${params}`);
if (!datos?.length) return null;
return this.desdeResultado(datos[0]);
}
// Geocodificación inversa: de coordenadas a { nombre, provincia, direccion }.
// Devuelve null si el servicio no responde (la UI avisa y sigue sin
// inferencia, sin bloquear la pasarela).
async reverse(lat, lng) {
const params = new URLSearchParams({
lat: String(lat),
lon: String(lng),
format: "jsonv2",
addressdetails: "1",
namedetails: "1",
"accept-language": "es",
});
const dato = await this.fetchNominatim(`${BASE}/reverse?${params}`);
if (!dato || dato.error) return null;
const r = this.desdeResultado(dato);
return { nombre: r.nombre, provincia: r.provincia, direccion: r.direccion };
}
// Convierte una respuesta de Nominatim (search o reverse) en el resultado
// extensible del proveedor. La provincia se normaliza al listado canónico
// de PROVINCIAS: si no casa con ninguna, queda vacía para que el usuario la
// seleccione manualmente.
desdeResultado(d) {
const addr = d.address || {};
const candidatos = [addr.county, addr.province, addr.state_district, addr.state, addr.city, addr.municipality, addr.city_district].filter(Boolean);
let provincia = "";
for (const candidato of candidatos) {
provincia = this.emparejarProvincia(candidato);
if (provincia) break;
}
return {
proveedor: this.id,
lat: parseFloat(d.lat),
lng: parseFloat(d.lon),
nombre: d.namedetails?.name || "",
provincia,
direccion: d.display_name || "",
};
}
}
+42
View File
@@ -0,0 +1,42 @@
// Proveedor de enlaces de openstreetmap.org. Extrae las coordenadas de los
// parámetros mlat/mlon (enlace de marcador) o del fragmento
// #map=zoom/lat/lng (posición actual del visor), p. ej.:
// https://www.openstreetmap.org/?mlat=41.38&mlon=2.17#map=15/41.38/2.17
import ProveedorUbicacion from "./ProveedorUbicacion.js";
const PATRON_MAPA = /#map=\d+(?:\.\d+)?\/(-?\d{1,3}\.\d+)\/(-?\d{1,3}\.\d+)/;
function comoUrl(entrada) {
const texto = (entrada || "").trim();
if (!texto) return null;
try {
return new URL(/:\/\//i.test(texto) ? texto : `https://${texto}`);
} catch {
return null;
}
}
export default class ProveedorOSM extends ProveedorUbicacion {
id = "osm";
detecta(entrada) {
return /openstreetmap\.org/i.test(entrada || "");
}
async extrae(entrada) {
const url = comoUrl(entrada);
if (!url) return null;
const mlat = parseFloat(url.searchParams.get("mlat"));
const mlon = parseFloat(url.searchParams.get("mlon"));
if (Number.isFinite(mlat) && Number.isFinite(mlon)) {
return { proveedor: this.id, lat: mlat, lng: mlon };
}
const mapa = (url.hash || "").match(PATRON_MAPA);
if (mapa) {
return { proveedor: this.id, lat: parseFloat(mapa[1]), lng: parseFloat(mapa[2]) };
}
return null;
}
}
+56
View File
@@ -0,0 +1,56 @@
// Proveedor de enlaces de Waze. Extrae las coordenadas del parámetro ll=
// (https://waze.com/ul?ll=41.38,2.17&navigate=yes). Si el enlace solo trae
// una búsqueda de texto (q=...), delega la resolución en la búsqueda por
// texto de Nominatim marcando el resultado como proveedor "waze".
import ProveedorUbicacion from "./ProveedorUbicacion.js";
import ProveedorNominatim from "./proveedorNominatim.js";
const PATRON_LL = /^(-?\d{1,3}(?:\.\d+)?),\s*(-?\d{1,3}(?:\.\d+)?)$/;
function comoUrl(entrada) {
const texto = (entrada || "").trim();
if (!texto) return null;
try {
return new URL(/:\/\//i.test(texto) ? texto : `https://${texto}`);
} catch {
return null;
}
}
export default class ProveedorWaze extends ProveedorUbicacion {
id = "waze";
// Instancia propia del resolvedor de texto (Waze no lo referencia desde el
// registro para evitar acoplamientos con index.js).
#nominatim = new ProveedorNominatim();
detecta(entrada) {
return /waze\.(com|to)/i.test(entrada || "");
}
async extrae(entrada) {
const url = comoUrl(entrada);
if (!url) return null;
const ll = url.searchParams.get("ll");
if (ll) {
const m = ll.match(PATRON_LL);
if (m) {
const lat = parseFloat(m[1]);
const lng = parseFloat(m[2]);
if (Math.abs(lat) <= 90 && Math.abs(lng) <= 180) {
return { proveedor: this.id, lat, lng };
}
}
return null; // ll= presente pero malformado
}
// Solo búsqueda de texto: delegar en Nominatim manteniendo el proveedor
const q = url.searchParams.get("q");
if (q) {
const resultado = await this.#nominatim.extrae(q);
if (resultado) return { ...resultado, proveedor: this.id };
}
return null;
}
}
+104
View File
@@ -0,0 +1,104 @@
// @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.
// (la serialización exacta de max()/calc() varía entre versiones de jsdom,
// así que se comprueba por contenido y no por sintaxis exacta)
function assertAlineadoAlCuerpo(styleAttr) {
const left = (styleAttr.match(/left:\s*([^;]+)/) || [])[1];
expect(left, "debe usar left con max()").toMatch(/max\(/);
expect(left).toContain("100vw");
expect(left).toContain("720px");
expect(left).toContain("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");
assertAlineadoAlCuerpo(contenedor.getAttribute("style"));
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
assertAlineadoAlCuerpo(contenedor.getAttribute("style"));
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();
});
});
+82
View File
@@ -0,0 +1,82 @@
// @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.
// (la serialización exacta de max()/calc() varía entre versiones de jsdom)
const right = (contenedor.getAttribute("style").match(/right:\s*([^;]+)/) || [])[1];
expect(right).toMatch(/max\(/);
expect(right).toContain("100vw");
expect(right).toContain("720px");
expect(right).toContain("16px");
// en PC el FAB mide 64px (en móvil 54)
expect(boton.style.width).toBe("64px");
expect(boton.style.height).toBe("64px");
expect(boton.style.fontSize).toBe("28px");
});
it("en móvil mantiene el tamaño compacto (54px)", () => {
vi.stubGlobal("matchMedia", matchMediaMock(true));
render(<BotonFlotante onClick={() => {}} />);
const boton = screen.getByRole("button");
expect(boton.style.width).toBe("54px");
expect(boton.style.fontSize).toBe("24px");
});
});
@@ -0,0 +1,50 @@
// @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.
// (la serialización exacta de max()/calc() varía entre versiones de jsdom)
const right = (boton.getAttribute("style").match(/right:\s*([^;]+)/) || [])[1];
expect(right).toMatch(/max\(/);
expect(right).toContain("100vw");
expect(right).toContain("720px");
expect(right).toContain("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();
});
});
+112
View File
@@ -0,0 +1,112 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import ModalPasarela from "../../src/components/ModalPasarela.jsx";
beforeEach(() => {
vi.clearAllMocks(); // los mocks de propsBase se comparten entre pruebas
});
afterEach(cleanup);
const FORM_VACIO = {
nombre: "", provincia: "", categoria: "", subcategoria: "",
direccion: "", enlaceGoogleMaps: "", lat: null, lng: null,
entradaUbicacion: "", proveedorUbicacion: "",
};
const FORM_RESUELTO = {
...FORM_VACIO,
entradaUbicacion: "41.3851, 2.1734",
proveedorUbicacion: "coordenadas",
lat: 41.3851, lng: 2.1734,
nombre: "Bar X", provincia: "Barcelona",
categoria: "Restauración", subcategoria: "Tapas y raciones",
};
const propsBase = {
setForm: vi.fn(),
onCerrar: vi.fn(),
onEnviar: vi.fn(),
onConfirmarDuplicado: vi.fn(),
onDescartarDuplicado: vi.fn(),
aceptaPrivacidad: false,
setAceptaPrivacidad: vi.fn(),
onAbrirPrivacidad: vi.fn(),
errorFiltro: "",
posibleDuplicado: null,
comprobandoDuplicado: false,
saving: false,
};
const siguiente = () => screen.getByRole("button", { name: "Siguiente" });
describe("ModalPasarela", () => {
it("arranca en la pantalla de Ubicación con las 6 pantallas declaradas", () => {
render(<ModalPasarela {...propsBase} form={FORM_VACIO} />);
expect(screen.getByLabelText(/ubicación del local/i)).toBeTruthy();
expect(screen.getByText(/paso 1 de 6/i)).toBeTruthy();
expect(screen.getAllByRole("generic", { hidden: true })).toBeTruthy();
// indicador de progreso: 6 barras
const barras = document.querySelectorAll('[aria-hidden="true"] span[style*="width: 16px"]');
expect(barras.length).toBe(6);
});
it("Siguiente deshabilitado sin resolución de ubicación válida", () => {
render(<ModalPasarela {...propsBase} form={FORM_VACIO} />);
expect(siguiente().disabled).toBe(true);
});
it("Siguiente habilitado con datos válidos y navega; Atrás conserva los datos", () => {
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
expect(siguiente().disabled).toBe(false);
fireEvent.click(siguiente());
// paso 2: nombre pre-rellenado con lo inferido
expect(screen.getByLabelText(/nombre del local/i).value).toBe("Bar X");
fireEvent.click(screen.getByRole("button", { name: /atrás/i }));
// vuelve a ubicación con la entrada conservada
expect(screen.getByLabelText(/ubicación del local/i).value).toBe("41.3851, 2.1734");
});
it("Escape cierra el modal (descarta el borrador vía onCerrar)", () => {
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
expect(propsBase.onCerrar).toHaveBeenCalledTimes(1);
});
it("✕ cierra el modal", () => {
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
fireEvent.click(screen.getByRole("button", { name: "Cerrar" }));
expect(propsBase.onCerrar).toHaveBeenCalledTimes(1);
});
it("foco inicial en el primer control de cada pantalla", () => {
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
expect(document.activeElement).toBe(screen.getByLabelText(/ubicación del local/i));
fireEvent.click(siguiente());
expect(document.activeElement).toBe(screen.getByLabelText(/nombre del local/i));
});
it("Tab queda contenido dentro del modal (wrap del primer al último focusable)", () => {
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
// primer focusable del panel (el precede al cuerpo en el DOM)
const cerrar = screen.getByRole("button", { name: "Cerrar" });
cerrar.focus();
// Shift+Tab desde el primero salta al último (Siguiente)
fireEvent.keyDown(document.activeElement, { key: "Tab", shiftKey: true });
expect(document.activeElement).toBe(siguiente());
// Tab desde el último vuelve al primero
fireEvent.keyDown(document.activeElement, { key: "Tab" });
expect(document.activeElement).toBe(cerrar);
});
it("en el paso final muestra el resumen con su propio botón de envío", () => {
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
for (let i = 0; i < 5; i++) fireEvent.click(siguiente());
expect(screen.getByRole("button", { name: /enviar propuesta/i })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Siguiente" })).toBeNull();
});
});
+102
View File
@@ -0,0 +1,102 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import PasoResumen from "../../src/components/PasoResumen.jsx";
beforeEach(() => {
vi.clearAllMocks(); // los mocks de propsBase se comparten entre pruebas
});
afterEach(cleanup);
const FORM_BASE = {
nombre: "Bar El Olivo", provincia: "Madrid", categoria: "Restauración", subcategoria: "Tapas y raciones",
direccion: "Calle Mayor 5, Madrid", enlaceGoogleMaps: "", lat: 40.4168, lng: -3.7038,
entradaUbicacion: "40.4168, -3.7038", proveedorUbicacion: "coordenadas",
};
const propsBase = {
form: FORM_BASE,
aceptaPrivacidad: false,
setAceptaPrivacidad: vi.fn(),
onAbrirPrivacidad: vi.fn(),
onEnviar: vi.fn(),
onConfirmarDuplicado: vi.fn(),
onDescartarDuplicado: vi.fn(),
errorFiltro: "",
posibleDuplicado: null,
comprobandoDuplicado: false,
saving: false,
};
describe("PasoResumen", () => {
it("muestra un resumen legible de todos los datos", () => {
render(<PasoResumen {...propsBase} />);
expect(screen.getByText("Bar El Olivo")).toBeTruthy();
expect(screen.getByText("Madrid")).toBeTruthy();
expect(screen.getByText(/🍽️ Restauración/)).toBeTruthy();
expect(screen.getByText("Tapas y raciones")).toBeTruthy();
expect(screen.getByText("Calle Mayor 5, Madrid")).toBeTruthy();
expect(screen.getByText(/Coordenadas/)).toBeTruthy();
});
it("botón de envío deshabilitado sin aceptar la privacidad", () => {
render(<PasoResumen {...propsBase} />);
const boton = screen.getByRole("button", { name: /enviar propuesta/i });
expect(boton.disabled).toBe(true);
});
it("al marcar la aceptación se habilita el envío y dispara onEnviar", () => {
render(<PasoResumen {...propsBase} />);
fireEvent.click(screen.getByRole("checkbox"));
expect(propsBase.setAceptaPrivacidad).toHaveBeenCalledWith(true);
// con aceptación ya aplicada por el padre
cleanup();
render(<PasoResumen {...propsBase} aceptaPrivacidad />);
const boton = screen.getByRole("button", { name: /enviar propuesta/i });
expect(boton.disabled).toBe(false);
fireEvent.click(boton);
expect(propsBase.onEnviar).toHaveBeenCalledTimes(1);
});
it("el enlace de privacidad abre el modal sin marcar el checkbox", () => {
render(<PasoResumen {...propsBase} />);
fireEvent.click(screen.getByRole("button", { name: /política de privacidad/i }));
expect(propsBase.onAbrirPrivacidad).toHaveBeenCalledTimes(1);
expect(propsBase.setAceptaPrivacidad).not.toHaveBeenCalled();
});
it("renderiza el aviso de palabra prohibida dentro del modal", () => {
render(<PasoResumen {...propsBase} errorFiltro="Tu propuesta contiene palabras no permitidas." />);
expect(screen.getByRole("alert").textContent).toContain("palabras no permitidas");
});
it("renderiza el aviso de duplicado con las opciones del flujo", () => {
render(
<PasoResumen
{...propsBase}
posibleDuplicado={{
coincidencias: [{ id: "1", nombre: "Bar El Olivo", provincia: "Madrid", direccion: "", estado: "publicado" }],
lat: 40.4168, lng: -3.7038,
}}
/>,
);
const aviso = screen.getByRole("alert");
expect(aviso.textContent).toContain("Ya existe un local parecido");
expect(aviso.textContent).toContain("Bar El Olivo");
fireEvent.click(screen.getByRole("button", { name: /enviar de todos modos/i }));
expect(propsBase.onConfirmarDuplicado).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: /revisar datos/i }));
expect(propsBase.onDescartarDuplicado).toHaveBeenCalledTimes(1);
});
it("estados Comprobando…/Enviando… deshabilitan el envío", () => {
const { rerender } = render(<PasoResumen {...propsBase} aceptaPrivacidad comprobandoDuplicado />);
let boton = screen.getByRole("button", { name: /comprobando/i });
expect(boton.disabled).toBe(true);
rerender(<PasoResumen {...propsBase} aceptaPrivacidad saving />);
boton = screen.getByRole("button", { name: /enviando/i });
expect(boton.disabled).toBe(true);
});
});
+127
View File
@@ -0,0 +1,127 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
vi.mock("../../src/utils/ubicacion/index.js", () => ({
ETIQUETAS_PROVEEDOR: {
"google-maps": "Google Maps",
coordenadas: "Coordenadas",
nominatim: "Dirección (OpenStreetMap)",
},
resolverUbicacion: vi.fn(),
inferirDesdeCoords: vi.fn(),
}));
import PasoUbicacion from "../../src/components/PasoUbicacion.jsx";
import { resolverUbicacion, inferirDesdeCoords } from "../../src/utils/ubicacion/index.js";
const FORM = {
nombre: "", provincia: "", categoria: "", subcategoria: "",
direccion: "", enlaceGoogleMaps: "", lat: null, lng: null,
entradaUbicacion: "", proveedorUbicacion: "",
};
// setForm que aplica los updater sobre un objeto (como haría useState) sin
// provocar re-render: suficiente para inspeccionar lo que el paso escribe.
function renderPaso(formInicial = { ...FORM }) {
const estado = { form: formInicial };
const setForm = (updater) => {
estado.form = typeof updater === "function" ? updater(estado.form) : updater;
};
render(<PasoUbicacion form={estado.form} setForm={setForm} />);
return estado;
}
const escribir = (texto) =>
fireEvent.change(screen.getByLabelText(/ubicación del local/i), { target: { value: texto } });
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
cleanup();
});
describe("PasoUbicacion", () => {
it("no resuelve antes del debounce (~600ms)", async () => {
resolverUbicacion.mockResolvedValue(null);
renderPaso();
escribir("https://maps.google.com/@41.3851,2.1734");
await act(async () => { await vi.advanceTimersByTimeAsync(300); });
expect(resolverUbicacion).not.toHaveBeenCalled();
await act(async () => { await vi.advanceTimersByTimeAsync(400); });
expect(resolverUbicacion).toHaveBeenCalledWith("https://maps.google.com/@41.3851,2.1734");
});
it("al resolver muestra el chip con el proveedor y coordenadas y pre-rellena el form", async () => {
resolverUbicacion.mockResolvedValue({ proveedor: "google-maps", lat: 41.3851, lng: 2.1734, nombre: "Bar X", provincia: "Barcelona", direccion: "Bar X, Barcelona" });
inferirDesdeCoords.mockResolvedValue(null); // ya trae nombre+provincia: no debe llamarse
const estado = renderPaso();
escribir("https://maps.google.com/@41.3851,2.1734");
await act(async () => { await vi.advanceTimersByTimeAsync(601); });
expect(screen.getByText(/Google Maps detectado/)).toBeTruthy();
expect(screen.getByText(/· 41\.3851, 2\.1734/)).toBeTruthy();
expect(estado.form.lat).toBe(41.3851);
expect(estado.form.lng).toBe(2.1734);
expect(estado.form.proveedorUbicacion).toBe("google-maps");
expect(estado.form.nombre).toBe("Bar X");
expect(estado.form.provincia).toBe("Barcelona");
expect(estado.form.direccion).toBe("Bar X, Barcelona");
// es un enlace: se guarda como enlaceGoogleMaps del local
expect(estado.form.enlaceGoogleMaps).toBe("https://maps.google.com/@41.3851,2.1734");
expect(inferirDesdeCoords).not.toHaveBeenCalled();
});
it("con solo coordenadas lanza la inferencia inversa y pre-rellena lo inferido", async () => {
resolverUbicacion.mockResolvedValue({ proveedor: "coordenadas", lat: 40.4168, lng: -3.7038, nombre: "", provincia: "" });
inferirDesdeCoords.mockResolvedValue({ nombre: "Puerta del Sol", provincia: "Madrid", direccion: "Puerta del Sol, Madrid" });
const estado = renderPaso();
escribir("40.4168, -3.7038");
await act(async () => { await vi.advanceTimersByTimeAsync(601); });
expect(inferirDesdeCoords).toHaveBeenCalledWith(40.4168, -3.7038);
expect(estado.form.nombre).toBe("Puerta del Sol");
expect(estado.form.provincia).toBe("Madrid");
// coordenadas sueltas: no es un enlace
expect(estado.form.enlaceGoogleMaps).toBe("");
expect(screen.queryByRole("alert")).toBeNull();
});
it("entrada no reconocida → aviso y sin coordenadas en el form", async () => {
resolverUbicacion.mockResolvedValue(null);
const estado = renderPaso();
escribir("https://maps.app.goo.gl/xyz");
await act(async () => { await vi.advanceTimersByTimeAsync(601); });
const aviso = screen.getByRole("alert");
expect(aviso.textContent).toContain("No se pudo interpretar la ubicación");
expect(estado.form.lat).toBeNull();
});
it("si la inferencia inversa falla, avisa pero mantiene la resolución válida", async () => {
resolverUbicacion.mockResolvedValue({ proveedor: "coordenadas", lat: 1, lng: 2, nombre: "", provincia: "" });
inferirDesdeCoords.mockResolvedValue(null); // fallo controlado
renderPaso();
escribir("1, 2");
await act(async () => { await vi.advanceTimersByTimeAsync(601); });
expect(screen.getByText(/No se pudieron inferir el nombre y la provincia/i)).toBeTruthy();
expect(screen.getByText(/Coordenadas detectado/)).toBeTruthy();
});
it("al volver a editar invalida la resolución anterior", async () => {
resolverUbicacion.mockResolvedValueOnce({ proveedor: "coordenadas", lat: 1, lng: 2 });
const estado = renderPaso();
escribir("1, 2");
await act(async () => { await vi.advanceTimersByTimeAsync(601); });
expect(estado.form.lat).toBe(1);
expect(screen.getByText(/Coordenadas detectado/)).toBeTruthy();
escribir("1, 2 y algo más");
expect(estado.form.lat).toBeNull(); // se limpia inmediatamente
expect(screen.queryByText(/detectado/)).toBeNull(); // el chip desaparece
});
});
+85
View File
@@ -0,0 +1,85 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { useState } from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import PasoNombre from "../../src/components/PasoNombre.jsx";
import PasoProvincia from "../../src/components/PasoProvincia.jsx";
import PasoCategoria from "../../src/components/PasoCategoria.jsx";
import PasoSubcategoria from "../../src/components/PasoSubcategoria.jsx";
import { PROVINCIAS } from "../../src/constants/provincias.js";
import { CATEGORIAS, NOMBRES_CATEGORIAS } from "../../src/constants/categorias.js";
afterEach(cleanup);
// Harness con useState real: los pasos escriben a través de setForm y el
// estado resultante se lee desde fuera (más fiel que espiar el updater,
// cuya evaluación diferida pierde el valor del evento en React 18).
function renderPaso(Componente, inicial) {
const estado = { form: inicial };
function Wrapper() {
const [form, setForm] = useState(inicial);
estado.form = form;
return <Componente form={form} setForm={setForm} />;
}
render(<Wrapper />);
return estado;
}
describe("PasoNombre", () => {
it("muestra el nombre inferido y es editable", () => {
const estado = renderPaso(PasoNombre, { nombre: "Bar El Olivo" });
const input = screen.getByLabelText(/nombre del local/i);
expect(input.value).toBe("Bar El Olivo");
fireEvent.change(input, { target: { value: "Bar La Plaza" } });
expect(estado.form.nombre).toBe("Bar La Plaza");
});
it("vacío si la inferencia falló", () => {
renderPaso(PasoNombre, { nombre: "" });
expect(screen.getByLabelText(/nombre del local/i).value).toBe("");
});
});
describe("PasoProvincia", () => {
it("es un select con el listado PROVINCIAS y la provincia pre-rellenada", () => {
renderPaso(PasoProvincia, { provincia: "La Coruña" });
const select = screen.getByLabelText(/provincia/i);
expect(select.value).toBe("La Coruña");
const opciones = [...select.options].map((o) => o.value);
expect(opciones).toEqual(["", ...PROVINCIAS]);
fireEvent.change(select, { target: { value: "Madrid" } });
// (la aserción de estado se cubre con el harness en el resto de pasos)
});
it("sin coincidencia queda vacío (selección manual)", () => {
renderPaso(PasoProvincia, { provincia: "" });
expect(screen.getByLabelText(/provincia/i).value).toBe("");
});
});
describe("PasoCategoria", () => {
it("ofrece todas las categorías y al elegir limpia la subcategoría", () => {
const estado = renderPaso(PasoCategoria, { categoria: "Pequeño comercio", subcategoria: "Farmacia" });
const select = screen.getByLabelText(/categoría/i);
const opciones = [...select.options].map((o) => o.value);
expect(opciones).toEqual(["", ...NOMBRES_CATEGORIAS]);
fireEvent.change(select, { target: { value: "Restauración" } });
expect(estado.form.categoria).toBe("Restauración");
expect(estado.form.subcategoria).toBe("");
});
});
describe("PasoSubcategoria", () => {
it("deshabilitado sin categoría y opcional con ella", () => {
const { rerender } = render(<PasoSubcategoria form={{ categoria: "", subcategoria: "" }} setForm={() => {}} />);
const select = screen.getByLabelText(/tipo específico/i);
expect(select.disabled).toBe(true);
expect([...select.options][0].textContent).toMatch(/sin tipo específico/i); // se puede continuar
rerender(<PasoSubcategoria form={{ categoria: "Restauración", subcategoria: "" }} setForm={() => {}} />);
expect(select.disabled).toBe(false);
const opciones = [...select.options].map((o) => o.value);
expect(opciones).toEqual(["", ...CATEGORIAS["Restauración"].subcategorias]);
});
});
+92
View File
@@ -0,0 +1,92 @@
import { beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
// BD en memoria: aislada, offline y determinista. Debe configurarse ANTES de
// importar la app (server/db.js resuelve la ruta en la carga del módulo).
process.env.LOCALESP_DB = ":memory:";
let app;
beforeAll(async () => {
({ default: app } = await import("../../server/index.js"));
});
// Payload equivalente al que envía la pasarela tras este cambio: sin
// descripcion y sin puntuacion (el backend aplica sus defaults).
const PROPUESTA_PASARELA = {
nombre: "Bar El Olivo",
provincia: "Madrid",
categoria: "Restauración",
subcategoria: "Tapas y raciones",
direccion: "Calle Mayor 5, Alcalá de Henares",
enlaceGoogleMaps: "",
lat: 40.4828,
lng: -3.3652,
};
describe("Integración API local (Express + SQLite en memoria)", () => {
it("GET /api/locales arranca limpio", async () => {
const r = await request(app).get("/api/locales");
expect(r.status).toBe(200);
expect(r.body).toEqual([]);
});
it("POST /api/propuestas acepta una propuesta de la pasarela sin descripcion ni puntuacion (defaults)", async () => {
const r = await request(app).post("/api/propuestas").send(PROPUESTA_PASARELA);
expect(r.status).toBe(201);
expect(r.body.nombre).toBe("Bar El Olivo");
expect(r.body.puntuacion).toBe(0); // default del backend
expect(r.body.estado).toBe("pendiente");
expect(r.body.id).toBeTruthy();
// y aparece en pendientes (vista admin)
const pend = await request(app).get("/api/pendientes");
expect(pend.body.map((p) => p.nombre)).toContain("Bar El Olivo");
});
it("POST /api/check-duplicado detecta mismo nombre normalizado en la misma provincia", async () => {
const r = await request(app)
.post("/api/check-duplicado")
.send({ nombre: "bar el olivo", provincia: "Madrid", lat: 41.0, lng: -4.0 }); // lejos: cuenta la provincia
expect(r.body.duplicado).toBe(true);
expect(r.body.coincidencias[0]).toMatchObject({
nombre: "Bar El Olivo",
provincia: "Madrid",
estado: "pendiente", // aún no publicado
});
});
it("POST /api/check-duplicado detecta por cercanía aunque cambie la provincia", async () => {
const r = await request(app)
.post("/api/check-duplicado")
.send({ nombre: "BAR EL OLIVO", provincia: "Barcelona", lat: 40.48281, lng: -3.36521 }); // ~1-2 m
expect(r.body.duplicado).toBe(true);
});
it("POST /api/check-duplicado no marca nombres distintos ni mismos nombres lejos y en otra provincia", async () => {
const distinto = await request(app)
.post("/api/check-duplicado")
.send({ nombre: "Cafetería Luna", provincia: "Madrid", lat: 40.48, lng: -3.36 });
expect(distinto.body.duplicado).toBe(false);
const lejosOtraProvincia = await request(app)
.post("/api/check-duplicado")
.send({ nombre: "Bar El Olivo", provincia: "Sevilla", lat: 37.38, lng: -5.98 });
expect(lejosOtraProvincia.body.duplicado).toBe(false);
});
it("flujo admin completo: aprobar publica el local con los datos de la propuesta", async () => {
const propuesta = (await request(app).get("/api/pendientes")).body[0];
const aprobado = await request(app).post(`/api/aprobar/${propuesta.id}`);
expect(aprobado.body.ok).toBe(true);
const locales = (await request(app).get("/api/locales")).body;
const publicado = locales.find((l) => l.nombre === "Bar El Olivo");
expect(publicado).toMatchObject({
provincia: "Madrid",
categoria: "Restauración",
subcategoria: "Tapas y raciones",
direccion: "Calle Mayor 5, Alcalá de Henares",
puntuacion: 0, // default aplicado en cascada
});
});
});
+53
View File
@@ -0,0 +1,53 @@
import { beforeAll, describe, expect, it } from "vitest";
import { inferirDesdeCoords, resolverUbicacion } from "../../src/utils/ubicacion/index.js";
// Integración real con Nominatim (OpenStreetMap). Suite opt-in
// (`npm run test:integration`): se auto-salta sin conectividad y mantiene un
// ritmo respetuoso con la política de uso (~1 req/s) esperando entre pruebas.
const PAUSA_MS = 1100;
const pausar = () => new Promise((r) => setTimeout(r, PAUSA_MS));
const conRed = await fetch("https://nominatim.openstreetmap.org/status", {
headers: { "User-Agent": "LocalesEspanoles/1.0" },
signal: AbortSignal.timeout(5000),
})
.then((r) => r.ok)
.catch(() => false);
describe.skipIf(!conRed)("Integración Nominatim (red real)", () => {
beforeAll(() => {
if (!conRed) console.warn("Sin conectividad a Nominatim: suite saltada.");
});
it("busca una dirección en texto y devuelve coordenadas + provincia canónica", async () => {
const r = await resolverUbicacion("Calle Mayor 5, Alcalá de Henares");
expect(r.proveedor).toBe("nominatim");
expect(r.lat).toBeGreaterThan(40.4);
expect(r.lat).toBeLessThan(40.6);
expect(r.lng).toBeGreaterThan(-3.5);
expect(r.lng).toBeLessThan(-3.2);
expect(r.provincia).toBe("Madrid");
});
it("geocodificación inversa de Madrid devuelve la provincia canónica", async () => {
await pausar();
const r = await inferirDesdeCoords(40.4168, -3.7038);
expect(r).not.toBeNull();
expect(r.provincia).toBe("Madrid");
expect(r.nombre).toBeTruthy();
expect(r.direccion).toBeTruthy();
});
it("geocodificación inversa de Bilbao normaliza Bizkaia → Vizcaya (acentos/cooficial)", async () => {
await pausar();
const r = await inferirDesdeCoords(43.263, -2.935);
expect(r.provincia).toBe("Vizcaya");
});
it("geocodificación inversa fuera de España deja la provincia vacía (selección manual)", async () => {
await pausar();
const r = await inferirDesdeCoords(48.8584, 2.2945); // Torre Eiffel
expect(r).not.toBeNull();
expect(r.provincia).toBe("");
});
});
+3
View File
@@ -0,0 +1,3 @@
// Setup común: React 18 exige este flag para act() fuera de Jest.
// Inofensivo en las pruebas de Node puro.
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from "vitest";
import ProveedorUbicacion from "../../src/utils/ubicacion/ProveedorUbicacion.js";
describe("ProveedorUbicacion (clase base)", () => {
it("normaliza: minúsculas, sin tildes, sin espacios extremos", () => {
const p = new ProveedorUbicacion();
expect(p.normalizar(" ÁLAVA ")).toBe("alava");
expect(p.normalizar("Guipúzcoa")).toBe("guipuzcoa");
expect(ProveedorUbicacion.normalizar("MA DRID")).toBe("ma drid");
expect(p.normalizar(null)).toBe("");
});
describe("emparejarProvincia", () => {
const p = new ProveedorUbicacion();
it("coincidencia exacta insensible a acentos y mayúsculas", () => {
expect(p.emparejarProvincia("ALAVA")).toBe("Álava");
expect(p.emparejarProvincia("Guipuzcoa")).toBe("Guipúzcoa");
});
it("coincidencia parcial dentro de un texto (Comunidad de Madrid)", () => {
expect(p.emparejarProvincia("Comunidad de Madrid")).toBe("Madrid");
expect(p.emparejarProvincia("Santiago, A Coruña, Galicia")).toBe("La Coruña");
});
it("tolera artículos anteuestos (A Coruña → La Coruña)", () => {
expect(p.emparejarProvincia("A Coruña")).toBe("La Coruña");
expect(p.emparejarProvincia("Ourense")).toBe("Orense");
});
it("alias de nombres cooficiales (D12)", () => {
expect(p.emparejarProvincia("Bizkaia")).toBe("Vizcaya");
expect(p.emparejarProvincia("Gipuzkoa")).toBe("Guipúzcoa");
expect(p.emparejarProvincia("Girona")).toBe("Gerona");
expect(p.emparejarProvincia("Lleida")).toBe("Lérida");
expect(p.emparejarProvincia("Illes Balears")).toBe("Islas Baleares");
expect(p.emparejarProvincia("Asturies")).toBe("Asturias");
});
it("devuelve \"\" cuando no casa con ninguna provincia", () => {
expect(p.emparejarProvincia("Île-de-France")).toBe("");
expect(p.emparejarProvincia("")).toBe("");
expect(p.emparejarProvincia("Lisboa")).toBe("");
});
});
it("el contrato base no acepta ninguna entrada", () => {
const p = new ProveedorUbicacion();
expect(p.detecta("41.38, 2.17")).toBe(false);
expect(p.detecta("https://maps.google.com")).toBe(false);
});
it("extrae() de la base lanza \"sin implementar\" (método abstracto)", async () => {
const p = new ProveedorUbicacion();
await expect(p.extrae("x")).rejects.toThrow("sin implementar");
});
describe("fetchNominatim degrada errores a null", () => {
it("devuelve null si fetch lanza (sin red)", async () => {
const p = new ProveedorUbicacion();
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new Error("sin red"))));
expect(await p.fetchNominatim("https://nominatim.openstreetmap.org/x")).toBeNull();
vi.unstubAllGlobals();
});
it("devuelve null si la respuesta HTTP no es ok", async () => {
const p = new ProveedorUbicacion();
vi.stubGlobal("fetch", vi.fn(() => Promise.resolve({ ok: false, status: 503 })));
expect(await p.fetchNominatim("https://nominatim.openstreetmap.org/x")).toBeNull();
vi.unstubAllGlobals();
});
it("devuelve el JSON cuando todo va bien y manda cabeceras", async () => {
const p = new ProveedorUbicacion();
const fetchMock = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve([{ lat: "1" }]) }));
vi.stubGlobal("fetch", fetchMock);
expect(await p.fetchNominatim("https://nominatim.openstreetmap.org/x")).toEqual([{ lat: "1" }]);
const [, init] = fetchMock.mock.calls[0];
expect(init.headers["Accept-Language"]).toBe("es");
expect(init.headers["User-Agent"]).toBe("LocalesEspanoles/1.0");
vi.unstubAllGlobals();
});
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import ProveedorCoordenadas from "../../src/utils/ubicacion/proveedorCoordenadas.js";
describe("ProveedorCoordenadas", () => {
const p = new ProveedorCoordenadas();
it("detecta pares con coma y punto decimal, signo y espacios", async () => {
for (const [entrada, lat, lng] of [
["41.3851, 2.1734", 41.3851, 2.1734],
["41,3851,2,1734", 41.3851, 2.1734], // decimales españoles con coma
["-3.7038, 40.4168", -3.7038, 40.4168], // orden invertido con signo
[" 41.38 ; 2.17 ", 41.38, 2.17], // separador ; y espacios
["41,3851 2,1734", 41.3851, 2.1734], // separado por espacio
]) {
expect(p.detecta(entrada), entrada).toBe(true);
expect(await p.extrae(entrada), entrada).toEqual({ proveedor: "coordenadas", lat, lng });
}
});
it("latitud fuera de rango → null", async () => {
expect(p.detecta("91.234, 2.17")).toBe(true); // sintácticamente es un par…
expect(await p.extrae("91.234, 2.17")).toBeNull(); // …pero se descarta por rango
expect(await p.extrae("41.38, 181.5")).toBeNull();
});
it("no detecta textos ni URLs ni números sueltos", () => {
expect(p.detecta("Calle Mayor 5, Madrid")).toBe(false);
expect(p.detecta("https://maps.google.com/@41.38,2.17")).toBe(false);
expect(p.detecta("41.3851")).toBe(false);
expect(p.detecta("")).toBe(false);
});
it("entrada no par → extrae null", async () => {
expect(await p.extrae("hola mundo")).toBeNull();
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import ProveedorGoogleMaps from "../../src/utils/ubicacion/proveedorGoogleMaps.js";
describe("ProveedorGoogleMaps", () => {
const p = new ProveedorGoogleMaps();
it("detecta URLs de Google Maps (dominios y subdominios)", () => {
expect(p.detecta("https://maps.google.com/@41.38,2.17")).toBe(true);
expect(p.detecta("https://www.google.es/maps?q=41.38,2.17")).toBe(true);
expect(p.detecta("https://maps.app.goo.gl/abc")).toBe(true);
expect(p.detecta("https://waze.com/ul?ll=41.38,2.17")).toBe(false);
expect(p.detecta("41.38, 2.17")).toBe(false);
});
it("extrae del formato @lat,lng", async () => {
expect(await p.extrae("https://www.google.com/maps/@41.3851,2.1734,15z"))
.toEqual({ proveedor: "google-maps", lat: 41.3851, lng: 2.1734 });
});
it("extrae del formato !3d…!4d… cuando no hay @", async () => {
expect(await p.extrae("https://www.google.com/maps/place/Bar/data=!4m2!3m1!1s0x0:0x0?hl=es"))
.toBeNull(); // sin coordenadas en absoluto
expect(await p.extrae("https://www.google.com/maps/place/Bar/data=!3d41.3851!4d2.1734"))
.toEqual({ proveedor: "google-maps", lat: 41.3851, lng: 2.1734 });
});
it("extrae del formato ?q=lat,lng", async () => {
expect(await p.extrae("https://www.google.com/maps?q=41.3851,-2.1734"))
.toEqual({ proveedor: "google-maps", lat: 41.3851, lng: -2.1734 });
});
it("prioriza @ sobre !3d!4d (comportamiento migrado de parsearEnlaceGoogleMaps)", async () => {
const r = await p.extrae("https://www.google.com/maps/place/X/@40.4168,-3.7038,17z/data=!3d40.4178!4d-3.7028");
expect(r).toEqual({ proveedor: "google-maps", lat: 40.4168, lng: -3.7038 });
});
it("enlace acortado: detecta pero no puede extraer → null", async () => {
expect(await p.extrae("https://maps.app.goo.gl/AbCdEf123")).toBeNull();
});
});
+94
View File
@@ -0,0 +1,94 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import ProveedorNominatim from "../../src/utils/ubicacion/proveedorNominatim.js";
afterEach(() => vi.unstubAllGlobals());
describe("ProveedorNominatim", () => {
const p = new ProveedorNominatim();
describe("detecta (fallback de texto)", () => {
it("acepta direcciones y nombres con letras", () => {
expect(p.detecta("Calle Mayor 5, Alcalá de Henares, Madrid")).toBe(true);
expect(p.detecta("Bar El Olivo Sevilla")).toBe(true);
});
it("rechaza URLs (las gestiona otro proveedor)", () => {
expect(p.detecta("https://ejemplo.com/calle")).toBe(false);
expect(p.detecta("www.ejemplo.com")).toBe(false);
expect(p.detecta("https://maps.app.goo.gl/abc")).toBe(false);
});
it("rechaza textos demasiado cortos o sin letras", () => {
expect(p.detecta("ab")).toBe(false);
expect(p.detecta("")).toBe(false);
expect(p.detecta("12345")).toBe(false);
});
});
describe("desdeResultado (normalización de provincia)", () => {
it("normaliza provincia canónica insensible a acentos", () => {
const r = p.desdeResultado({
lat: "40.4", lon: "-3.7",
display_name: "X, Madrid, España",
address: { state: "Comunidad de Madrid" },
namedetails: { name: "X" },
});
expect(r.provincia).toBe("Madrid");
});
it("alias cooficiales: Bizkaia → Vizcaya, A Coruña → La Coruña", () => {
const r = p.desdeResultado({ lat: "1", lon: "2", display_name: "d", address: { province: "Bizkaia" }, namedetails: {} });
expect(r.provincia).toBe("Vizcaya");
const r2 = p.desdeResultado({ lat: "1", lon: "2", display_name: "d", address: { city: "A Coruña" }, namedetails: {} });
expect(r2.provincia).toBe("La Coruña");
});
it("provincia fuera del listado → \"\" (selección manual)", () => {
const r = p.desdeResultado({ lat: "1", lon: "2", display_name: "d", address: { state: "Île-de-France" }, namedetails: {} });
expect(r.provincia).toBe("");
});
it("resultado extensible: lat/lng/proveedor/nombre/direccion", () => {
const r = p.desdeResultado({
lat: "41.5", lon: "2.1",
display_name: "X, Girona, España",
address: { province: "Girona" },
namedetails: { name: "X" },
});
expect(r).toEqual({ proveedor: "nominatim", lat: 41.5, lng: 2.1, nombre: "X", provincia: "Gerona", direccion: "X, Girona, España" });
});
});
describe("extrae (búsqueda por texto)", () => {
it("devuelve el primer resultado normalizado", async () => {
const fetchMock = vi.fn(() => ({
ok: true,
json: async () => [{
lat: "40.4828632", lon: "-3.3652613",
display_name: "5, Calle Mayor, Alcalá de Henares, España",
address: { city: "Alcalá de Henares", state: "Comunidad de Madrid" },
namedetails: { name: "Casa de la Panadería" },
}],
}));
vi.stubGlobal("fetch", fetchMock);
const r = await p.extrae("Calle Mayor 5, Alcalá de Henares");
expect(r.proveedor).toBe("nominatim");
expect(r.lat).toBeCloseTo(40.4828632, 6);
expect(r.provincia).toBe("Madrid");
expect(r.nombre).toBe("Casa de la Panadería");
// la búsqueda acota a España
const [url] = fetchMock.mock.calls[0];
expect(decodeURIComponent(String(url).replace(/\+/g, " "))).toContain("Calle Mayor 5, Alcalá de Henares, España");
});
it("sin resultados → null", async () => {
vi.stubGlobal("fetch", vi.fn(() => ({ ok: true, json: async () => [] })));
expect(await p.extrae("calle inventada zzzz")).toBeNull();
});
it("fallo de red → null (degradación controlada)", async () => {
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new Error("sin red"))));
expect(await p.extrae("Calle Mayor 5, Madrid")).toBeNull();
});
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import ProveedorOSM from "../../src/utils/ubicacion/proveedorOSM.js";
describe("ProveedorOSM", () => {
const p = new ProveedorOSM();
it("detecta URLs de openstreetmap.org", () => {
expect(p.detecta("https://www.openstreetmap.org/#map=12/39.4699/-0.3763")).toBe(true);
expect(p.detecta("https://openstreetmap.org/?mlat=41.38&mlon=2.17")).toBe(true);
expect(p.detecta("https://www.google.com/maps")).toBe(false);
});
it("extrae de mlat/mlon (enlace de marcador)", async () => {
expect(await p.extrae("https://www.openstreetmap.org/?mlat=41.9794&mlon=2.8214"))
.toEqual({ proveedor: "osm", lat: 41.9794, lng: 2.8214 });
});
it("prioriza mlat/mlon sobre el fragmento #map=", async () => {
const r = await p.extrae("https://www.openstreetmap.org/?mlat=41.9794&mlon=2.8214#map=15/39.4699/-0.3763");
expect(r).toEqual({ proveedor: "osm", lat: 41.9794, lng: 2.8214 });
});
it("extrae del fragmento #map=zoom/lat/lng cuando no hay mlat/mlon", async () => {
expect(await p.extrae("https://www.openstreetmap.org/#map=12/39.4699/-0.3763"))
.toEqual({ proveedor: "osm", lat: 39.4699, lng: -0.3763 });
expect(await p.extrae("https://www.openstreet.org/#map=16.5/43.5321/-5.6556")) // zoom decimal
.toEqual({ proveedor: "osm", lat: 43.5321, lng: -5.6556 });
});
it("sin mlat/mlon ni #map= → null", async () => {
expect(await p.extrae("https://www.openstreetmap.org/way/123456")).toBeNull();
});
});
+67
View File
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import ProveedorWaze from "../../src/utils/ubicacion/proveedorWaze.js";
const respuestaNominatim = (resultados) => ({
ok: true,
json: async () => resultados,
});
afterEach(() => vi.unstubAllGlobals());
describe("ProveedorWaze", () => {
const p = new ProveedorWaze();
it("detecta URLs de Waze", () => {
expect(p.detecta("https://waze.com/ul?ll=41.38,2.17&navigate=yes")).toBe(true);
expect(p.detecta("https://www.waze.com/live-map/")).toBe(true);
expect(p.detecta("waze.to/abc")).toBe(true);
expect(p.detecta("https://google.com/maps")).toBe(false);
});
it("extrae de ll=lat,lng", async () => {
expect(await p.extrae("https://waze.com/ul?ll=41.3851,2.1734&navigate=yes"))
.toEqual({ proveedor: "waze", lat: 41.3851, lng: 2.1734 });
// sin esquema https://
expect(await p.extrae("waze.com/ul?ll=43.263,-2.935"))
.toEqual({ proveedor: "waze", lat: 43.263, lng: -2.935 });
});
it("ll malformado → null sin consultar Nominatim", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
expect(await p.extrae("https://waze.com/ul?ll=abc,def")).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it("q=texto delega en la búsqueda de Nominatim marcando proveedor waze", async () => {
const fetchMock = vi.fn(() => respuestaNominatim([{
lat: "40.4828632", lon: "-3.3652613",
display_name: "5, Calle Mayor, Alcalá de Henares, Comunidad de Madrid, 28801, España",
address: { city: "Alcalá de Henares", state: "Comunidad de Madrid" },
namedetails: { name: "" },
}]));
vi.stubGlobal("fetch", fetchMock);
const r = await p.extrae("https://waze.com/ul?q=Calle%20Mayor%205%2C%20Alcal%C3%A1%20de%20Henares");
expect(r).toEqual({
proveedor: "waze",
lat: 40.4828632,
lng: -3.3652613,
nombre: "",
provincia: "Madrid",
direccion: "5, Calle Mayor, Alcalá de Henares, Comunidad de Madrid, 28801, España",
});
// la petición lleva countrycodes=es y las cabeceras de Nominatim
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toContain("countrycodes=es");
expect(init.headers["User-Agent"]).toBe("LocalesEspanoles/1.0");
});
it("q=texto sin resultados en Nominatim → null", async () => {
vi.stubGlobal("fetch", vi.fn(() => respuestaNominatim([])));
expect(await p.extrae("https://waze.com/ul?q=calle%20inexistente%20zzzz")).toBeNull();
});
it("URL de Waze sin ll ni q → null", async () => {
expect(await p.extrae("https://waze.com/live-map/")).toBeNull();
});
});
+90
View File
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ETIQUETAS_PROVEEDOR, PROVEEDORES, inferirDesdeCoords, resolverUbicacion } from "../../src/utils/ubicacion/index.js";
afterEach(() => vi.unstubAllGlobals());
describe("resolverUbicacion", () => {
it("respeta el orden de prioridad de PROVEEDORES (coordenadas → google → waze → osm → nominatim)", () => {
expect(PROVEEDORES.map((p) => p.id)).toEqual(["coordenadas", "google-maps", "waze", "osm", "nominatim"]);
});
it("resuelve un enlace de Google Maps sin tocar la red", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const r = await resolverUbicacion("https://maps.google.com/@41.3851,2.1734,15z");
expect(r).toEqual({ proveedor: "google-maps", lat: 41.3851, lng: 2.1734 });
expect(fetchMock).not.toHaveBeenCalled();
});
it("resuelve coordenadas sueltas con el proveedor de coordenadas", async () => {
const r = await resolverUbicacion("41.3851, 2.1734");
expect(r.proveedor).toBe("coordenadas");
});
it("hace fallback a Nominatim con texto de dirección (fetch simulado)", async () => {
vi.stubGlobal("fetch", vi.fn(() => ({
ok: true,
json: async () => [{ lat: "40.1", lon: "-3.2", display_name: "X, Madrid", address: { state: "Comunidad de Madrid" }, namedetails: {} }],
})));
const r = await resolverUbicacion("Calle Mayor 5, Madrid");
expect(r.proveedor).toBe("nominatim");
expect(r.provincia).toBe("Madrid");
});
it("un proveedor que lanza se degrada y continúa con el siguiente", async () => {
// se inyecta temporalmente un proveedor roto con máxima prioridad
const roto = { id: "roto", detecta: () => true, extrae: async () => { throw new Error("boom"); } };
PROVEEDORES.unshift(roto);
const error = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const r = await resolverUbicacion("https://maps.google.com/@41.3851,2.1734");
expect(r.proveedor).toBe("google-maps");
expect(error).toHaveBeenCalled();
} finally {
PROVEEDORES.shift();
error.mockRestore();
}
});
it("entrada no reconocida por nadie → null", async () => {
expect(await resolverUbicacion("https://maps.app.goo.gl/abc123")).toBeNull();
expect(await resolverUbicacion("")).toBeNull();
expect(await resolverUbicacion(null)).toBeNull();
});
});
describe("inferirDesdeCoords", () => {
it("usa el reverse de Nominatim y normaliza la provincia", async () => {
const fetchMock = vi.fn(() => ({
ok: true,
json: async () => ({
lat: "40.4168", lon: "-3.7038",
display_name: "Puerta del Sol, Madrid, España",
address: { state: "Comunidad de Madrid" },
namedetails: { name: "Puerta del Sol" },
}),
}));
vi.stubGlobal("fetch", fetchMock);
const r = await inferirDesdeCoords(40.4168, -3.7038);
expect(r).toEqual({ nombre: "Puerta del Sol", provincia: "Madrid", direccion: "Puerta del Sol, Madrid, España" });
const [url] = fetchMock.mock.calls[0];
expect(String(url)).toContain("/reverse");
expect(String(url)).toContain("addressdetails=1");
expect(String(url)).toContain("namedetails=1");
});
it("fallo de red → null sin lanzar", async () => {
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new Error("sin red"))));
const error = vi.spyOn(console, "error").mockImplementation(() => {});
expect(await inferirDesdeCoords(1, 1)).toBeNull();
error.mockRestore();
});
});
describe("ETIQUETAS_PROVEEDOR", () => {
it("cubre todos los ids registrados (para los chips de la UI)", () => {
for (const p of PROVEEDORES) {
expect(ETIQUETAS_PROVEEDOR[p.id], `falta etiqueta para ${p.id}`).toBeTruthy();
}
});
});
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
}
}
}
})
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vitest/config";
// Suite de integración (opt-in, `npm run test:integration`): habla con los
// backends reales — la API Express con BD en memoria y Nominatim con red de
// verdad (se auto-salta sin conectividad). Timeouts amplios por las llamadas
// de red y el ritmo ~1 req/s impuesto a Nominatim.
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["tests/integration/**/*.spec.js"],
testTimeout: 30000,
hookTimeout: 15000,
},
});
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "vitest/config";
// Suite rápida (unitarias + componente): 100% offline y determinista para CI.
// Las pruebas de componente usan jsdom vía docblock // @vitest-environment jsdom
// al inicio de cada archivo; el resto corre en Node puro (más rápido).
export default defineConfig({
esbuild: { jsx: "automatic" },
test: {
globals: true,
environment: "node",
include: ["tests/unit/**/*.spec.{js,jsx}", "tests/component/**/*.spec.jsx"],
setupFiles: ["tests/setup.js"],
},
});