2 Commits
Author SHA1 Message Date
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
36 changed files with 1937 additions and 2259 deletions
-54
View File
@@ -1,54 +0,0 @@
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/}"
-18
View File
@@ -1,18 +0,0 @@
---
name: Bug
about: Reportar un error
labels: bug
---
**Qué pasa**
Descripción breve.
**Pasos para reproducir**
1.
2.
**Qué esperabas**
**Entorno**
- Navegador:
- Sistema operativo:
-14
View File
@@ -1,14 +0,0 @@
---
name: Propuesta de mejora
about: Sugerir una funcionalidad o mejora
labels: enhancement
---
**Problema o necesidad**
¿Qué te falta? ¿Quién la usaría?
**Propuesta**
Cómo lo resolverías. Mantenlo simple: si la propuesta requiere muchas piezas
nuevas, conviene discutirla antes en Discussions.
**Alternativas que has considerado**
-13
View File
@@ -1,13 +0,0 @@
## Qué cambia
<!-- En una o dos frases. -->
## Por qué
<!-- Issue relacionado o motivación. -->
## Notas para el revisor
- [ ] La app arranca con `npm run dev`.
- [ ] No he añadido dependencias innecesarias.
- [ ] He seguido la estructura existente.
-8
View File
@@ -1,12 +1,4 @@
node_modules
dist
.DS_Store
*.log
.vscode
.idea
.env
.env.local
# artefactos de despliegue manual / datos locales
*.tgz
localesp.db
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) [AÑO] [ORGANIZACIÓN]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+2 -39
View File
@@ -1,39 +1,2 @@
# Business Map
Registro colaborativo de negocios, en un mapa abierto.
Prototipo open source mantenido por **[ORGANIZACIÓN]**. Cualquiera puede ver
los negocios registrados y proponer nuevos; las propuestas son revisadas
antes de aparecer públicamente.
## Demo local
```bash
npm install
npm run dev
```
Abre la URL que indique Vite (normalmente `http://localhost:5173`).
## Stack
- React 18 + Vite
- Leaflet + OpenStreetMap (mapa)
- CSS plano
Sin backend en el prototipo: los datos vienen de un mock en memoria. Ver
`docs/02-implementation-alternatives.md` para la decisión de backend.
## Documentación
- [`docs/01-use-cases.md`](docs/01-use-cases.md) qué hace y qué no.
- [`docs/02-implementation-alternatives.md`](docs/02-implementation-alternatives.md) decisiones técnicas.
- [`docs/03-decisions.md`](docs/03-decisions.md) ADRs.
- [`docs/04-governance.md`](docs/04-governance.md) cómo se gobierna el proyecto.
- [`docs/05-contributing.md`](docs/05-contributing.md) cómo contribuir.
- [`docs/06-github-setup.md`](docs/06-github-setup.md) configuración inicial del repo.
- [`docs/07-cicd.md`](docs/07-cicd.md) CI/CD: despliegue por rama en `<rama>.localesp.es`.
## Licencia
MIT. Ver [LICENSE](LICENSE).
# LocalESP
Registro colaborativo de negocios españoles
-51
View File
@@ -1,51 +0,0 @@
# Casos de uso
Este documento describe los casos de uso del prototipo. El objetivo es mantener
el alcance acotado para minimizar el coste de mantenimiento.
## Actores
- **Visitante**: cualquier persona que accede a la aplicación. No requiere
autenticación. Puede consultar el mapa y contribuir con nuevas entradas.
- **Revisor**: persona designada por la organización que aprueba o rechaza las
contribuciones. Su flujo está fuera del frontend del prototipo (ver
alternativas de implementación).
## Casos de uso del prototipo
### CU-01: Explorar negocios en el mapa
- El visitante abre la aplicación.
- Ve un mapa centrado en una ubicación por defecto con marcadores de los
negocios aprobados.
- Puede hacer zoom y desplazarse libremente.
### CU-02: Ver detalles de un negocio
- El visitante selecciona un marcador en el mapa.
- La barra lateral muestra los detalles del negocio: nombre, categoría,
dirección, descripción corta y enlace/contacto si existe.
- Al seleccionar otro marcador, la barra lateral se actualiza.
### CU-03: Contribuir con un nuevo negocio
- El visitante abre el formulario de contribución desde la interfaz principal.
- Rellena los campos mínimos: nombre, categoría, dirección, descripción y
contacto opcional. La ubicación se obtiene geocodificando la dirección o
haciendo clic en el mapa.
- Envía el formulario. La entrada queda en estado "pendiente de revisión" y no
aparece en el mapa hasta ser aprobada.
- El visitante recibe confirmación visual del envío.
## Fuera de alcance del prototipo
Decisiones tomadas para mantener el proyecto simple (ver
`02-implementation-alternatives.md` para el razonamiento):
- Autenticación de usuarios.
- Panel de moderación dentro de la aplicación. La revisión se hace en el
backend (por ejemplo, en el dashboard del proveedor de datos elegido).
- Edición o eliminación de negocios por el visitante.
- Búsqueda avanzada, filtros complejos o categorías jerárquicas.
- Comentarios, valoraciones, fotos múltiples o multimedia.
- Internacionalización. El prototipo usa un único idioma.
Estos casos pueden añadirse después si la comunidad lo pide, pero no forman
parte del prototipo.
-85
View File
@@ -1,85 +0,0 @@
# Alternativas de implementación
Este documento registra las decisiones técnicas tomadas y por qué. El criterio
guía es **mínimo coste de mantenimiento** para una organización sin ánimo de
lucro, siguiendo KISS y evitando YAGNI.
## 1. Framework frontend
| Opción | Pros | Contras |
|---|---|---|
| **React + Vite** (elegido) | Ecosistema enorme, fácil encontrar contribuidores, build rápido. | Requiere build step. |
| Next.js | SSR, rutas, imágenes. | Demasiado para una SPA de una página; aumenta complejidad y coste de hosting. |
| Vanilla JS | Sin dependencias. | Más código manual, menos contribuidores familiarizados. |
**Decisión**: React + Vite. Es el stack más común y los contribuidores
potenciales lo conocen. Vite mantiene la configuración mínima.
## 2. Mapa
| Opción | Pros | Contras |
|---|---|---|
| **Leaflet + OpenStreetMap** (elegido) | Open source, sin claves API, sin coste, tiles gratuitos. | Menos pulido que Mapbox. |
| Mapbox GL | Más bonito, mejor rendimiento. | Requiere API key y tiene cuotas de pago. |
| Google Maps | Familiar. | Requiere facturación activada, cuotas estrictas, no encaja con espíritu open source. |
**Decisión**: Leaflet con tiles de OpenStreetMap. Cero coste, sin claves, y la
librería `react-leaflet` lo hace trivial de integrar.
## 3. Backend y almacenamiento de datos
| Opción | Pros | Contras |
|---|---|---|
| **Backend-as-a-Service** (Supabase o similar) elegido | Sin servidor que mantener, base de datos gestionada, panel de moderación incluido en su dashboard. Plan gratuito generoso. | Dependencia de un proveedor. |
| Backend propio (Node + Postgres) | Control total. | Hay que mantenerlo, pagarlo, securizarlo. Alto coste para una ONG. |
| Archivo JSON en el repositorio | Sin backend, máxima simplicidad. | Las contribuciones tendrían que llegar como Pull Requests, fricción alta para no-técnicos. |
**Decisión**: BaaS tipo Supabase. Para el prototipo, la capa de datos se
abstrae en `src/services/businessRepository.js`, así que cambiar de proveedor
después es localizado. El prototipo entregado usa un **mock en memoria** para
no acoplar a un proveedor concreto antes de que la organización lo decida.
## 4. Moderación
| Opción | Pros | Contras |
|---|---|---|
| **Aprobación desde el dashboard del BaaS** (elegido) | Cero código extra. El revisor cambia un campo `status` de `pending` a `approved`. | Requiere acceso al dashboard. |
| Panel de moderación dentro de la app | Más cómodo. | Requiere autenticación, roles, vistas extra: explosión de complejidad. |
**Decisión**: La moderación vive fuera del frontend público. El modelo de
datos incluye un campo `status` (`pending` / `approved` / `rejected`). El
frontend solo lee los `approved`.
## 5. Estilos
| Opción | Pros | Contras |
|---|---|---|
| **CSS plano con variables** (elegido) | Cero dependencias, fácil de leer, los contribuidores con HTML/CSS básico pueden colaborar. | Sin utilidades. |
| Tailwind | Productivo. | Cadena de build extra, curva de aprendizaje. |
| CSS-in-JS | Componentes encapsulados. | Runtime extra, peor accesibilidad para nuevos contribuidores. |
**Decisión**: CSS plano. La app es de una página; no se justifica más.
## 6. Gestión de estado
**Decisión**: `useState` y `useEffect` de React. No hay Redux, Zustand ni
Context global. La app es pequeña; añadir una librería de estado sería YAGNI.
## 7. Geocodificación de direcciones del formulario
**Decisión para el prototipo**: el usuario hace clic en el mapa para fijar la
ubicación, o introduce lat/lng. Geocodificación automática vía Nominatim
(OpenStreetMap) queda como mejora futura con bajo coste de añadir.
## 8. Tests
**Decisión**: el prototipo no incluye suite de tests para no inflarlo, pero la
estructura (servicios separados de componentes, funciones puras donde posible)
facilita añadirlos. Cuando se incorporen, **Vitest** es la elección natural por
integrarse con Vite sin configuración.
## 9. Despliegue
**Decisión recomendada**: **GitHub Pages** o **Cloudflare Pages** (gratuitos
para open source). Una SPA estática construida con `vite build` se despliega
sin coste y sin servidor. Ver `docs/06-github-setup.md`.
-49
View File
@@ -1,49 +0,0 @@
# Registro de decisiones
Lista corta y cronológica de decisiones que merecen ser revisadas más
adelante. Formato ADR ligero.
## D-001: SPA de una sola página
**Contexto**: el uso principal es ver un mapa y unos detalles.
**Decisión**: una sola página, sin router. El formulario es un panel/modal en
la misma vista.
**Consecuencia**: si en el futuro hay vistas adicionales (estadísticas, perfil
del negocio en URL propia), habrá que introducir React Router. Reevaluar
cuando aparezca el segundo caso de uso navegable.
## D-002: Capa de servicios
**Decisión**: todo acceso a datos pasa por `src/services/businessRepository.js`.
**Razón**: cumplir Dependency Inversion (SOLID). Los componentes no saben si
los datos vienen de un mock, de Supabase o de un JSON.
**Consecuencia**: cambiar de proveedor es modificar un único archivo.
## D-003: Modelo de datos mínimo
Campos del negocio:
- `id` (string)
- `name` (string)
- `category` (string)
- `address` (string)
- `description` (string)
- `contact` (string, opcional)
- `lat` (number)
- `lng` (number)
- `status` (`pending` | `approved` | `rejected`)
- `createdAt` (ISO string)
Sin slugs, sin fotos, sin tags. Reevaluar tras feedback de la comunidad.
## D-004: Sin autenticación en el prototipo
**Razón**: el visitante no necesita cuenta. El moderador opera en el panel del
backend.
**Reevaluar si**: aparecen casos de uso que requieran identidad (favoritos,
edición por el dueño del negocio).
## D-005: Idioma
El prototipo está en español, sin i18n. Si la organización quiere expandirse,
añadir `react-i18next` cuando exista una segunda lengua real, no antes.
## D-006: Mock de datos en el prototipo
El repositorio entregado usa un mock en memoria con varios negocios de
ejemplo. La organización debe decidir el backend antes de pasar a producción.
La interfaz del repositorio (`getApproved`, `submit`) está pensada para
mapearse 1:1 a Supabase u otro BaaS.
-40
View File
@@ -1,40 +0,0 @@
# Gobernanza
Este proyecto es mantenido por **[NOMBRE DE LA ORGANIZACIÓN]**, una entidad
sin ánimo de lucro. Estas pautas son deliberadamente breves; queremos invertir
el esfuerzo en código y comunidad, no en burocracia.
## Roles
- **Mantenedores**: personas con permisos de merge en `main`. Son nombradas
por la organización. Responsables de revisar PRs, etiquetar issues y publicar
releases.
- **Contribuidores**: cualquiera que envíe un Pull Request, abra un issue o
participe en las discusiones.
- **Moderadores de contenido**: personas con acceso al panel del backend para
aprobar o rechazar los negocios enviados por los visitantes. No
necesariamente coinciden con los mantenedores del código.
## Toma de decisiones
- Decisiones pequeñas (bugs, mejoras menores): las toma quien revisa el PR.
- Decisiones de diseño o que afecten al modelo de datos: discusión pública en
un issue durante al menos 7 días. Cierra el debate un mantenedor buscando
consenso. Si no lo hay, decide la organización.
- Las decisiones relevantes se registran en `docs/03-decisions.md`.
## Código de conducta
Adoptamos el [Contributor Covenant](https://www.contributor-covenant.org/). En
resumen: trata a las personas con respeto. Las violaciones se reportan al
correo `[CORREO_CONTACTO]` y las gestiona la organización.
## Licencia
El proyecto se publica bajo **MIT**. Las contribuciones se entienden
licenciadas bajo los mismos términos.
## Releases
Versionado semántico (semver). Los mantenedores publican versiones cuando
acumulan cambios suficientes. No hay calendario fijo.
-46
View File
@@ -1,46 +0,0 @@
# Cómo contribuir
¡Gracias por querer ayudar! El proyecto se mantiene gracias a la comunidad.
## Antes de empezar
1. Mira los [issues abiertos](../../issues), sobre todo los marcados
`good first issue`.
2. Si tu cambio es grande, abre un issue antes de programar para discutirlo.
## Flujo
1. Haz fork del repositorio.
2. Crea una rama desde `main`: `git checkout -b mi-cambio`.
3. Escribe el código siguiendo las pautas de abajo.
4. Asegúrate de que la app arranca: `npm install && npm run dev`.
5. Abre un Pull Request hacia `main` describiendo el cambio.
## Pautas de código
- **Mantén la app simple.** Si dudas entre dos soluciones, elige la más corta
y obvia. Este proyecto evita la complejidad a propósito.
- **Sigue la estructura existente.** Componentes en `src/components`, acceso a
datos en `src/services`.
- **No añadas dependencias sin necesidad.** Cada nueva librería es coste de
mantenimiento.
- **SOLID y Clean Code aplicados con sentido común.** Nombres claros,
funciones cortas, una responsabilidad por archivo.
- **Estilo**: 2 espacios de indentación, comillas dobles en JSX y simples en JS.
## Tipos de contribución
- **Código**: bugs, mejoras, nuevas funciones acordadas en issues.
- **Documentación**: cualquier archivo de `docs/` o el README.
- **Diseño**: propuestas en issues con mockups.
- **Reportar negocios**: usa la propia aplicación; no es necesario un PR.
## Revisión
Un mantenedor revisará tu PR. Puede pedir cambios. Una vez aprobado, hará
merge. Sé paciente: somos voluntarios.
## Reconocimiento
Todas las personas que han contribuido aparecen automáticamente en la pestaña
de Contributors del repositorio.
-76
View File
@@ -1,76 +0,0 @@
# Configuración del repositorio en GitHub
Pasos para dejar listo el repositorio. Pensado para que lo hagas una sola vez.
## 1. Crear el repositorio
1. En GitHub, crea un repositorio en la cuenta de la organización.
Recomendado: nombre corto, kebab-case, por ejemplo `business-map`.
2. Marca **Public**.
3. **No** inicialices con README, .gitignore ni licencia (ya vienen en este
prototipo).
4. Sube el código:
```bash
git init
git add .
git commit -m "Prototipo inicial"
git branch -M main
git remote add origin git@github.com:ORG/business-map.git
git push -u origin main
```
## 2. Ajustes básicos (Settings → General)
- **Features**: deja activos Issues y Discussions. Desactiva Wikis y Projects
hasta que se usen.
- **Pull Requests**: marca "Allow squash merging" como única opción y "Always
suggest updating pull request branches".
- **Archive**: deja desactivado.
## 3. Protección de la rama `main` (Settings → Branches)
Añade una regla para `main`:
- Require a pull request before merging.
- Require approvals: 1.
- Dismiss stale approvals when new commits are pushed.
- Do not allow bypassing the above settings.
## 4. Plantillas
Las plantillas ya están en `.github/` del prototipo:
- `ISSUE_TEMPLATE/bug.md`
- `ISSUE_TEMPLATE/feature.md`
- `PULL_REQUEST_TEMPLATE.md`
## 5. Documentos visibles en GitHub
Para que GitHub los muestre como insignias del repositorio:
- `README.md` (raíz).
- `LICENSE` (raíz).
- `docs/05-contributing.md`: súbelo además como `CONTRIBUTING.md` en la raíz o
enlázalo desde el README.
- `CODE_OF_CONDUCT.md` recomendado en la raíz (Contributor Covenant oficial).
## 6. Despliegue gratuito
Opción recomendada: **Cloudflare Pages** o **GitHub Pages**.
Para GitHub Pages con Vite:
1. Settings → Pages → Build and deployment → Source: **GitHub Actions**.
2. Añade un workflow en `.github/workflows/deploy.yml` (no incluido en el
prototipo para mantenerlo mínimo; añadirlo cuando se decida la URL final).
3. Ajusta `base` en `vite.config.js` al path del repo.
## 7. Etiquetas iniciales sugeridas
Crea en Issues → Labels:
- `good first issue` (verde)
- `bug` (rojo)
- `enhancement` (azul)
- `documentation` (gris)
- `discussion` (morado)
## 8. Discussions
Activa Discussions con las categorías: Announcements, Ideas, Q&A. Sirven como
foro de baja fricción para la comunidad.
-113
View File
@@ -1,113 +0,0 @@
# CI/CD — despliegue por rama
Cada rama se despliega automáticamente en `https://<rama>.localesp.es`.
## Cómo funciona
```
push a rama X
│ Gitea Actions (workflow: .gitea/workflows/deploy.yml)
runner self-hosted (host executor, en el propio VPS)
│ npm ci → vite build → scripts/deploy.sh <rama>
/opt/localesp/<slug>/ código + dist/ + node_modules (prod)
systemd localesp-<slug>.service node server/index.js (PORT=80xx)
nginx <slug>.localesp.es proxy_pass → localhost:80xx
certbot *.localesp.es Let's Encrypt (si el DNS ya apunta)
```
El **slug** se deriva del nombre de rama (`Feature/Foo``feature-foo`),
válido como hostname DNS. Un entorno = un subdirectorio bajo `/opt/localesp/`,
un servicio, un vhost y un puerto. La base de datos `localesp.db` de cada
entorno **se conserva** entre despliegues (no se sobreescribe).
Puertos: los históricos son `8080` (prototipo-yb01) y `8081` (master); los
nuevos se asignan desde `8082` hacia arriba. Si la rama ya tiene su
`localesp-<slug>.service`, se reutiliza su puerto (adopta el entorno existente).
## Precondición manual: el registro DNS
El registro A de `<rama>.localesp.es` **se crea a mano** (no está automatizado).
1. En el proveedor DNS de `localesp.es`, añade un registro A:
`<rama>.localesp.es A <IP-pública-del-VPS>`
2. Espera a que propague (`dig +short <rama>.localesp.es`).
3. Lanza el workflow (push a la rama, o *Actions → Run workflow* en Gitea).
Mientras el DNS no apunte al VPS, `deploy.sh` despliega la app igualmente y la
sirve por **HTTP**; emite el cert Let's Encrypt en cuanto detecta que el DNS ya
resuelve. No hay que tocar nada más.
## Añadir una rama nueva
```bash
git checkout -b mi-feature
# ... cambios ...
git push -u origin mi-feature
```
El workflow corre solo. Si quieres URL pública: crea el registro A (arriba).
Si no, el entorno existe pero no es alcanzable por hostname (útil para tests internos
si añades el host a `/etc/hosts`).
## Borrar una rama
Al borrar la rama en Gitea, el job `teardown` limpia el entorno (unit systemd,
vhost, cert y `/opt/localesp-<slug>`). También manual:
```bash
ssh localesp.jumpingcrab.com 'bash /opt/localesp-master/scripts/teardown.sh mi-feature'
```
## Entornos existentes (ya migrados al layout unificado)
Los entornos legacy ya están reubicados bajo `/opt/localesp/<rama>` con sus
systemd units renombradas al esquema `localesp-<slug>.service`, de modo que el
CI los **adopta** sin cambios (reutiliza puerto y vhost):
| Rama | Dir | Unit | Puerto | Hosts |
|---|---|---|---|---|
| `master` | `/opt/localesp/master` | `localesp-master.service` | 8081 | `localesp.es`, `master.localesp.es` |
| `prototipo-yb01` | `/opt/localesp/prototipo-yb01` | `localesp-prototipo-yb01.service` | 8080 | `prototipo-yb01.localesp.es` |
El antiguo `localesp.service` (sin slug) se retiró; ahora todas las units
siguen el patrón `localesp-<slug>.service`. La db de cada entorno se preservó.
## Seguridad
El runner (`gitea-runner`) se ejecuta como **root en el VPS de producción** y en
modo **host** (sin contenedor): cualquier workflow que se ejecute corresponde a
código del repo corriendo con privilegios de root en la caja. Esto es aceptable
mientras el repo sea propio / de baja colaboración. Si entran colaboradores
externos, conviene migrar a:
- runner en contenedor + despliegue por SSH (clave como secret de Gitea), o
- *ephemeral runners* (`gitea-runner register --ephemeral`).
## Evolución: estrategia B (wildcard)
Para eliminar la precondición manual del DNS y el cert por rama, más adelante:
1. Registro **wildcard `*.localesp.es`** (DNS).
2. **Wildcard cert** `*.localesp.es` vía challenge DNS-01 (una sola vez).
3. Un único vhost `*.localesp.es` que enruta por `Host` con un `map` rama→puerto.
`deploy.sh` apenas cambia: se caen los pasos de creación de vhost y certbot.
Nada de lo hecho aquí se pierde.
## Infra del runner (referencia)
Instalado en el VPS (`localesp.jumpingcrab.com`):
- Binario: `/usr/local/bin/gitea-runner` (v2.2.0), host executor.
**Cadena de suministro verificada**: el binario coincide (sha256 `d6b3f5bb…`)
con el descomprimido del release oficial `gitea-runner-2.2.0-linux-amd64.xz`, cuya suma
`13357e0d…` está publicada en el release. Gitea no publica firma GPG del runner; la suma
es la garantía máxima disponible. No hay paquete `dnf` oficial (solo binario o Docker image).
- Config: `/etc/gitea-runner/config.yaml` — etiquetas `self-hosted:host`, `linux:host`.
- Registro: `/var/lib/gitea-runner/.runner` (instance-level, `https://git.localesp.es`).
- Servicio: `gitea-runner.service` (systemd, `User=root`, `WorkingDirectory=/var/lib/gitea-runner`).
Comprobar estado: `systemctl status gitea-runner` · `journalctl -u gitea-runner -f`.
Ver runners en Gitea: *Site administration → Actions → Runners*.
+35 -6
View File
@@ -3,12 +3,41 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Business Map</title>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
crossorigin=""
/>
<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" />
<!-- 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>
+9
View File
@@ -0,0 +1,9 @@
[build]
command = "npm run build"
publish = "dist"
# SPA: todas las rutas van al index.html (React gestiona /admin y /movil)
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
+320 -258
View File
@@ -1,24 +1,22 @@
{
"name": "business-map",
"version": "0.1.0",
"name": "locales-espanoles",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "business-map",
"version": "0.1.0",
"name": "locales-espanoles",
"version": "1.0.0",
"dependencies": {
"better-sqlite3": "^12.10.0",
"express": "^5.2.1",
"leaflet": "^1.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1"
"better-sqlite3": "^12.0.0",
"express": "^5.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"concurrently": "^10.0.0",
"vite": "^5.4.0"
"@vitejs/plugin-react": "^4.2.1",
"concurrently": "^8.0.0",
"vite": "^5.2.0"
}
},
"node_modules/@babel/code-frame": {
@@ -255,6 +253,16 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -744,17 +752,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@react-leaflet/core": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
"license": "Hippocratic-2.1",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -763,9 +760,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
"integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz",
"integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==",
"cpu": [
"arm"
],
@@ -777,9 +774,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
"integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz",
"integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==",
"cpu": [
"arm64"
],
@@ -791,9 +788,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
"integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz",
"integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==",
"cpu": [
"arm64"
],
@@ -805,9 +802,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
"integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz",
"integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==",
"cpu": [
"x64"
],
@@ -819,9 +816,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz",
"integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz",
"integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==",
"cpu": [
"arm64"
],
@@ -833,9 +830,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz",
"integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz",
"integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==",
"cpu": [
"x64"
],
@@ -847,9 +844,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz",
"integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz",
"integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==",
"cpu": [
"arm"
],
@@ -864,9 +861,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz",
"integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz",
"integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==",
"cpu": [
"arm"
],
@@ -881,9 +878,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz",
"integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz",
"integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==",
"cpu": [
"arm64"
],
@@ -898,9 +895,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz",
"integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz",
"integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==",
"cpu": [
"arm64"
],
@@ -915,9 +912,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz",
"integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz",
"integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==",
"cpu": [
"loong64"
],
@@ -932,9 +929,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz",
"integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz",
"integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==",
"cpu": [
"loong64"
],
@@ -949,9 +946,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz",
"integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz",
"integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==",
"cpu": [
"ppc64"
],
@@ -966,9 +963,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz",
"integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz",
"integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==",
"cpu": [
"ppc64"
],
@@ -983,9 +980,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz",
"integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz",
"integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==",
"cpu": [
"riscv64"
],
@@ -1000,9 +997,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz",
"integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz",
"integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==",
"cpu": [
"riscv64"
],
@@ -1017,9 +1014,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz",
"integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz",
"integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==",
"cpu": [
"s390x"
],
@@ -1034,9 +1031,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz",
"integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz",
"integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==",
"cpu": [
"x64"
],
@@ -1051,9 +1048,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz",
"integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz",
"integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==",
"cpu": [
"x64"
],
@@ -1068,9 +1065,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz",
"integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz",
"integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==",
"cpu": [
"x64"
],
@@ -1082,9 +1079,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz",
"integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz",
"integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==",
"cpu": [
"arm64"
],
@@ -1096,9 +1093,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz",
"integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz",
"integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==",
"cpu": [
"arm64"
],
@@ -1110,9 +1107,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz",
"integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz",
"integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==",
"cpu": [
"ia32"
],
@@ -1124,9 +1121,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz",
"integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz",
"integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==",
"cpu": [
"x64"
],
@@ -1138,9 +1135,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz",
"integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz",
"integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==",
"cpu": [
"x64"
],
@@ -1197,9 +1194,9 @@
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
@@ -1238,26 +1235,26 @@
}
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=12"
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
@@ -1284,9 +1281,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.33",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz",
"integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==",
"version": "2.10.34",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz",
"integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -1451,9 +1448,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001793",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
"integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==",
"version": "1.0.30001797",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz",
"integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==",
"dev": true,
"funding": [
{
@@ -1472,18 +1469,35 @@
"license": "CC-BY-4.0"
},
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
@@ -1491,40 +1505,63 @@
"license": "ISC"
},
"node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=20"
"node": ">=12"
}
},
"node_modules/concurrently": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.0.tgz",
"integrity": "sha512-DRrk10z3sVPpguNe8od2cGNqZGqbT15rwAnxD4dG3b78mdNNb/gJyr8T834Oj518WcBmTktrt4FhdwZn09ZWSg==",
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "5.6.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.4",
"supports-color": "10.2.2",
"tree-kill": "1.2.2",
"yargs": "18.0.0"
},
"bin": {
"conc": "dist/bin/index.js",
"concurrently": "dist/bin/index.js"
"color-name": "~1.1.4"
},
"engines": {
"node": ">=22"
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concurrently": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
"integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^4.1.2",
"date-fns": "^2.30.0",
"lodash": "^4.17.21",
"rxjs": "^7.8.1",
"shell-quote": "^1.8.1",
"spawn-command": "0.0.2",
"supports-color": "^8.1.1",
"tree-kill": "^1.2.2",
"yargs": "^17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": "^14.13.0 || >=16.0.0"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
@@ -1577,6 +1614,23 @@
"node": ">=6.6.0"
}
},
"node_modules/date-fns": {
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.21.0"
},
"engines": {
"node": ">=0.11"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/date-fns"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1657,16 +1711,16 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.364",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz",
"integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==",
"version": "1.5.368",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz",
"integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==",
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
@@ -1929,19 +1983,6 @@
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-east-asian-width": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -1997,6 +2038,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -2098,6 +2149,16 @@
"node": ">= 0.10"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -2136,11 +2197,12 @@
"node": ">=6"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
@@ -2299,9 +2361,9 @@
}
},
"node_modules/node-abi/node_modules/semver": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
"integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -2311,9 +2373,9 @@
}
},
"node_modules/node-releases": {
"version": "2.0.46",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz",
"integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==",
"version": "2.0.47",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz",
"integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2537,20 +2599,6 @@
"react": "^18.3.1"
}
},
"node_modules/react-leaflet": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
"license": "Hippocratic-2.1",
"dependencies": {
"@react-leaflet/core": "^2.1.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -2575,14 +2623,24 @@
"node": ">= 6"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rollup": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
"integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
"integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
"@types/estree": "1.0.9"
},
"bin": {
"rollup": "dist/bin/rollup"
@@ -2592,31 +2650,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.60.4",
"@rollup/rollup-android-arm64": "4.60.4",
"@rollup/rollup-darwin-arm64": "4.60.4",
"@rollup/rollup-darwin-x64": "4.60.4",
"@rollup/rollup-freebsd-arm64": "4.60.4",
"@rollup/rollup-freebsd-x64": "4.60.4",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
"@rollup/rollup-linux-arm-musleabihf": "4.60.4",
"@rollup/rollup-linux-arm64-gnu": "4.60.4",
"@rollup/rollup-linux-arm64-musl": "4.60.4",
"@rollup/rollup-linux-loong64-gnu": "4.60.4",
"@rollup/rollup-linux-loong64-musl": "4.60.4",
"@rollup/rollup-linux-ppc64-gnu": "4.60.4",
"@rollup/rollup-linux-ppc64-musl": "4.60.4",
"@rollup/rollup-linux-riscv64-gnu": "4.60.4",
"@rollup/rollup-linux-riscv64-musl": "4.60.4",
"@rollup/rollup-linux-s390x-gnu": "4.60.4",
"@rollup/rollup-linux-x64-gnu": "4.60.4",
"@rollup/rollup-linux-x64-musl": "4.60.4",
"@rollup/rollup-openbsd-x64": "4.60.4",
"@rollup/rollup-openharmony-arm64": "4.60.4",
"@rollup/rollup-win32-arm64-msvc": "4.60.4",
"@rollup/rollup-win32-ia32-msvc": "4.60.4",
"@rollup/rollup-win32-x64-gnu": "4.60.4",
"@rollup/rollup-win32-x64-msvc": "4.60.4",
"@rollup/rollup-android-arm-eabi": "4.61.1",
"@rollup/rollup-android-arm64": "4.61.1",
"@rollup/rollup-darwin-arm64": "4.61.1",
"@rollup/rollup-darwin-x64": "4.61.1",
"@rollup/rollup-freebsd-arm64": "4.61.1",
"@rollup/rollup-freebsd-x64": "4.61.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.61.1",
"@rollup/rollup-linux-arm-musleabihf": "4.61.1",
"@rollup/rollup-linux-arm64-gnu": "4.61.1",
"@rollup/rollup-linux-arm64-musl": "4.61.1",
"@rollup/rollup-linux-loong64-gnu": "4.61.1",
"@rollup/rollup-linux-loong64-musl": "4.61.1",
"@rollup/rollup-linux-ppc64-gnu": "4.61.1",
"@rollup/rollup-linux-ppc64-musl": "4.61.1",
"@rollup/rollup-linux-riscv64-gnu": "4.61.1",
"@rollup/rollup-linux-riscv64-musl": "4.61.1",
"@rollup/rollup-linux-s390x-gnu": "4.61.1",
"@rollup/rollup-linux-x64-gnu": "4.61.1",
"@rollup/rollup-linux-x64-musl": "4.61.1",
"@rollup/rollup-openbsd-x64": "4.61.1",
"@rollup/rollup-openharmony-arm64": "4.61.1",
"@rollup/rollup-win32-arm64-msvc": "4.61.1",
"@rollup/rollup-win32-ia32-msvc": "4.61.1",
"@rollup/rollup-win32-x64-gnu": "4.61.1",
"@rollup/rollup-win32-x64-msvc": "4.61.1",
"fsevents": "~2.3.2"
}
},
@@ -2882,6 +2940,12 @@
"node": ">=0.10.0"
}
},
"node_modules/spawn-command": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
"dev": true
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -2901,37 +2965,31 @@
}
},
"node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
"node": ">=8"
}
},
"node_modules/strip-json-comments": {
@@ -2944,13 +3002,16 @@
}
},
"node_modules/supports-color": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=18"
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
@@ -3169,18 +3230,18 @@
}
},
"node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=18"
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
@@ -3210,31 +3271,32 @@
"license": "ISC"
},
"node_modules/yargs": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^9.0.1",
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"string-width": "^7.2.0",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^22.0.0"
"yargs-parser": "^21.1.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
"node": ">=12"
}
}
}
+12 -14
View File
@@ -1,26 +1,24 @@
{
"name": "business-map",
"name": "locales-espanoles",
"version": "1.0.0",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"server": "node server/index.js",
"dev:all": "concurrently -k -n vite,api -c cyan,green \"npm run dev\" \"npm run server\"",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"server": "node server/index.js",
"dev:all": "concurrently -k -n vite,api -c cyan,green \"npm run dev\" \"npm run server\""
},
"dependencies": {
"better-sqlite3": "^12.10.0",
"express": "^5.2.1",
"leaflet": "^1.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1"
"react": "^18.2.0",
"react-dom": "^18.2.0",
"better-sqlite3": "^12.0.0",
"express": "^5.0.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"concurrently": "^10.0.0",
"vite": "^5.4.0"
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.2.0",
"concurrently": "^8.0.0"
}
}
-33
View File
@@ -1,33 +0,0 @@
#!/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
@@ -1,450 +0,0 @@
#!/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
@@ -1,33 +0,0 @@
#!/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 "$@"
+42 -28
View File
@@ -3,36 +3,50 @@ import { fileURLToPath } from "url";
import { dirname, join } from "path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const db = new Database(join(__dirname, "..", "localesp.db"));
const dbPath = join(__dirname, "../localesp.db");
const db = new Database(dbPath);
db.exec(`
CREATE TABLE IF NOT EXISTS businesses (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL,
address TEXT NOT NULL,
description TEXT NOT NULL,
contact TEXT NOT NULL DEFAULT '',
lat REAL NOT NULL,
lng REAL NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
createdAt TEXT NOT NULL
)
CREATE TABLE IF NOT EXISTS locales (
id TEXT PRIMARY KEY,
nombre TEXT NOT NULL,
descripcion TEXT,
ubicacion TEXT,
telefono TEXT,
email TEXT,
web TEXT,
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,
ubicacion TEXT,
telefono TEXT,
email TEXT,
web TEXT,
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 users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
created TEXT DEFAULT CURRENT_TIMESTAMP
);
`);
const empty = db.prepare("SELECT COUNT(*) AS n FROM businesses").get().n === 0;
if (empty) {
const ins = db.prepare(`
INSERT INTO businesses
(id, name, category, address, description, contact, lat, lng, status, createdAt)
VALUES
(@id, @name, @category, @address, @description, @contact, @lat, @lng, @status, @createdAt)
`);
for (const b of [
{ id: "1", name: "Café del Mercado", category: "Cafetería", address: "Plaza Mayor 3, Madrid", description: "Café de especialidad y bollería artesana.", contact: "hola@cafedelmercado.example", lat: 40.4155, lng: -3.7074, status: "approved", createdAt: "2025-01-10T10:00:00Z" },
{ id: "2", name: "Librería El Faro", category: "Librería", address: "Calle Luna 12, Madrid", description: "Librería independiente con sección infantil.", contact: "", lat: 40.4221, lng: -3.7059, status: "approved", createdAt: "2025-01-12T10:00:00Z" },
{ id: "3", name: "Taller Bicicletas Rueda", category: "Servicios", address: "Calle Embajadores 45, Madrid", description: "Reparación y venta de segunda mano.", contact: "+34 600 000 000", lat: 40.4078, lng: -3.7053, status: "approved", createdAt: "2025-01-15T10:00:00Z" },
]) ins.run(b);
}
export default db;
+71 -27
View File
@@ -1,51 +1,95 @@
import express from "express";
import { randomUUID } from "crypto";
import { fileURLToPath } 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")));
app.get("/api/businesses", (_req, res) => {
res.json(db.prepare("SELECT * FROM businesses WHERE status = 'approved' ORDER BY createdAt DESC").all());
// 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);
});
app.post("/api/businesses", (req, res) => {
const { name, category, address, description, contact, lat, lng } = req.body;
const business = {
id: randomUUID(),
name, category, address, description,
contact: contact || "",
lat, lng,
status: "pending",
createdAt: new Date().toISOString(),
};
// Pendientes (admin)
app.get("/api/pendientes", (_req, res) => {
const pendientes = db.prepare("SELECT * FROM pendientes ORDER BY fechaPropuesta DESC").all();
res.json(pendientes);
});
// Enviar propuesta
app.post("/api/propuestas", (req, res) => {
const { nombre, descripcion, ubicacion, telefono, email, web, categoria, lat, lng } = req.body;
const id = randomUUID();
const fechaPropuesta = new Date().toISOString();
db.prepare(`
INSERT INTO businesses (id, name, category, address, description, contact, lat, lng, status, createdAt)
VALUES (@id, @name, @category, @address, @description, @contact, @lat, @lng, @status, @createdAt)
`).run(business);
res.status(201).json(business);
INSERT INTO pendientes (id, nombre, descripcion, ubicacion, telefono, email, web, categoria, estado, fechaPropuesta, lat, lng)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pendiente', ?, ?, ?)
`).run(id, nombre, descripcion, ubicacion, telefono, email, web, categoria, fechaPropuesta, lat, lng);
res.status(201).json({ id, nombre, descripcion, ubicacion, telefono, email, web, categoria, estado: "pendiente", fechaPropuesta, lat, lng });
});
app.get("/api/admin/pending", (_req, res) => {
res.json(db.prepare("SELECT * FROM businesses WHERE status = 'pending' ORDER BY createdAt ASC").all());
});
// 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, ubicacion, telefono, email, web, categoria, fecha, lat, lng)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(localeId, propuesta.nombre, propuesta.descripcion, propuesta.ubicacion, propuesta.telefono, propuesta.email, propuesta.web, propuesta.categoria, fecha, propuesta.lat, propuesta.lng);
db.prepare("DELETE FROM pendientes WHERE id = ?").run(id);
app.patch("/api/admin/businesses/:id", (req, res) => {
const { status } = req.body;
if (!["approved", "rejected"].includes(status))
return res.status(400).json({ error: "status must be 'approved' or 'rejected'" });
const info = db.prepare("UPDATE businesses SET status = ? WHERE id = ?").run(status, req.params.id);
if (info.changes === 0) return res.status(404).json({ error: "not found" });
res.json({ ok: true });
});
// 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);
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"));
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`API listening on http://localhost:${PORT}`));
app.listen(PORT, () => console.log(`Server listening on http://localhost:${PORT}`));
+282
View File
@@ -0,0 +1,282 @@
// 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,
obtenerPalabrasFiltradas, guardarPalabrasFiltradas,
} from "./firebase.js";
const CATEGORIAS = {
"Restauración": { emoji:"🍽️", color:"#8B0000", bg:"#FFF0F0" },
"Pequeño comercio": { emoji:"🛍️", color:"#1A5276", bg:"#EBF5FB" },
"Peluquería y estética": { emoji:"✂️", color:"#76448A", bg:"#F5EEF8" },
"Servicios del hogar": { emoji:"🔧", color:"#1E8449", bg:"#EAFAF1" },
"Otros": { emoji:"📌", color:"#784212", bg:"#FDF2E9" },
};
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); }
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 = CATEGORIAS[p.categoria] || {};
const fecha = new Date(p.fechaPropuesta).toLocaleDateString("es-ES", { day:"numeric", month:"short", hour:"2-digit", minute:"2-digit" });
return (
<div style={{ ...S.card, borderLeft:`4px solid ${cat.color||"#CCC"}`, 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>
{cat.emoji && <span style={S.badge(cat.color, cat.bg)}>{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", gap:16, fontSize:13, color:"#666" }}>
<span>📍 {p.provincia}</span>
{p.direccion && <span>🗺 {p.direccion}</span>}
<span style={{ color:"#D4AF37" }}>{"★".repeat(p.puntuacion||0)}{"☆".repeat(5-(p.puntuacion||0))}</span>
<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>}
{p.enlaceGoogleMaps && <a href={p.enlaceGoogleMaps} target="_blank" rel="noopener noreferrer" style={{ display:"inline-block", marginTop:6, fontSize:12, color:"#1A73E8" }}>Ver en Google Maps </a>}
</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 [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("");
useEffect(() => {
setCargando(true);
const u1 = suscribirPendientes(l => { setPendientes(l); setCargando(false); }, () => setCargando(false));
const u2 = suscribirLocales(l => setPublicados(l), () => {});
obtenerPalabrasFiltradas().then(p => setPalabras(p));
return () => { u1(); u2(); };
}, []);
const aprobar = async (p) => { setBusy(true); try { await aprobarPropuesta(p); } catch(e) { alert("Error: "+e.message); } setBusy(false); };
const rechazar = async (id) => { if (!confirm("¿Rechazar y eliminar esta propuesta?")) return; try { await rechazarPropuesta(id); } catch(e) { alert("Error: "+e.message); } };
const eliminar = async (id) => { if (!confirm("¿Eliminar este local publicado?")) return; try { await deleteLocal(id); } catch(e) { alert("Error: "+e.message); } };
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); alert("✅ Filtro guardado."); }
catch(e) { alert("Error: "+e.message); }
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:"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>
{CATEGORIAS[l.categoria] && <span style={S.badge(CATEGORIAS[l.categoria].color, CATEGORIAS[l.categoria].bg)}>{CATEGORIAS[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 && <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>
))
}
</>
)}
{/* ── 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>
</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} />;
}
+596 -61
View File
@@ -1,74 +1,609 @@
import { useEffect, useState } from "react";
import MapView from "./components/MapView.jsx";
import Sidebar from "./components/Sidebar.jsx";
import SubmitForm from "./components/SubmitForm.jsx";
import AdminPanel from "./components/AdminPanel.jsx";
import { getApproved, submit } from "./services/businessRepository.js";
import { useState, useEffect, useRef, useCallback } from "react";
import { suscribirLocales, enviarPropuesta, obtenerPalabrasFiltradas, contienepalabrasProhibidas } from "./firebase.js";
export default function App() {
const [businesses, setBusinesses] = useState([]);
const [selectedId, setSelectedId] = useState(null);
const [formStep, setFormStep] = useState(null); // null | 'picking' | 'editing'
const [pickedLocation, setPickedLocation] = useState(null);
const [adminOpen, setAdminOpen] = useState(false);
async function loadBusinesses() {
setBusinesses(await getApproved());
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"]
}
};
const NOMBRES_CATEGORIAS = Object.keys(CATEGORIAS);
useEffect(() => { loadBusinesses(); }, []);
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 selected = businesses.find((b) => b.id === selectedId) || null;
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],
};
function handlePickLocation(loc) {
setPickedLocation(loc);
if (formStep === "picking") setFormStep("editing");
}
const COLORES_MAPA = {
"Restauración":"#8B0000","Pequeño comercio":"#1A5276",
"Peluquería y estética":"#76448A","Servicios del hogar":"#1E8449","Otros":"#784212",
};
async function handleSubmit(data) {
await submit(data);
setFormStep(null);
setPickedLocation(null);
alert("¡Gracias! Tu propuesta queda pendiente de revisión.");
}
function parsearEnlaceGoogleMaps(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]) };
m = url.match(/[?&]q=(-?\d+\.\d+),(-?\d+\.\d+)/);
if (m) return { lat: parseFloat(m[1]), lng: parseFloat(m[2]) };
} catch {}
return null;
}
function handleCancel() {
setFormStep(null);
setPickedLocation(null);
}
async function geocodificarDireccion(direccion) {
const query = encodeURIComponent(direccion + ", España");
const url = `https://nominatim.openstreetmap.org/search?q=${query}&format=json&limit=1&countrycodes=es`;
const res = await fetch(url, { headers: { "Accept-Language": "es", "User-Agent": "LocalesEspanoles/1.0" } });
const data = await res.json();
if (data?.length) return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon), displayName: data[0].display_name };
return null;
}
function StarRating({ value, onChange, readOnly = false }) {
const [hover, setHover] = useState(0);
return (
<div className="app">
<Sidebar
business={selected}
onContribute={() => setFormStep("picking")}
onClose={() => setSelectedId(null)}
picking={formStep === "picking"}
onCancelPicking={handleCancel}
onAdmin={() => setAdminOpen(true)}
/>
<MapView
businesses={businesses}
selectedId={selectedId}
onSelect={setSelectedId}
pickingLocation={formStep === "picking"}
onPickLocation={handlePickLocation}
pickedLocation={pickedLocation}
/>
{formStep === "editing" && (
<SubmitForm
pickedLocation={pickedLocation}
onSubmit={handleSubmit}
onCancel={handleCancel}
onRepick={() => setFormStep("picking")}
/>
)}
{adminOpen && (
<AdminPanel
onClose={() => setAdminOpen(false)}
onApproved={loadBusinesses}
/>
<div style={{ display: "flex", gap: 2 }}>
{[1,2,3,4,5].map(star => (
<span key={star}
onClick={() => !readOnly && onChange && onChange(star)}
onMouseEnter={() => !readOnly && setHover(star)}
onMouseLeave={() => !readOnly && setHover(0)}
style={{ fontSize: 22, cursor: readOnly ? "default" : "pointer", color: star <= (hover || value) ? "#D4AF37" : "#ccc", transition: "color 0.15s", userSelect: "none" }}
></span>
))}
</div>
);
}
function CategoriaBadge({ categoria, subcategoria }) {
const cat = CATEGORIAS[categoria];
if (!cat) return null;
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>
);
}
function LocalCard({ local }) {
const gmUrl = local.enlaceGoogleMaps || (local.direccion
? `https://www.google.com/maps/search/${encodeURIComponent(local.nombre + " " + local.direccion)}`
: null);
return (
<div 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" }}>
<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}</p>
</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>
<span style={{ fontSize: 13, color: "var(--color-text-secondary)" }}>{local.direccion}</span>
</div>
)}
{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>
Ver en Google Maps
</a>
)}
<StarRating value={local.puntuacion} readOnly />
{local.descripcion && (
<p style={{ fontSize: 13, color: "var(--color-text-secondary)", margin: 0, fontStyle: "italic" }}>"{local.descripcion}"</p>
)}
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: 0 }}>
Publicado {new Date(local.fecha).toLocaleDateString("es-ES", { day: "numeric", month: "long", year: "numeric" })}
</p>
</div>
);
}
function CampoUbicacion({ form, setForm }) {
const [modoInput, setModoInput] = useState("direccion");
const [geocodificando, setGeocodificando] = useState(false);
const [resultadoGeo, setResultadoGeo] = useState(null);
const debounceRef = useRef(null);
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"
};
const handleDireccionChange = (val) => {
setForm(f => ({ ...f, direccion: val, lat: null, lng: null, enlaceGoogleMaps: "" }));
setResultadoGeo(null);
clearTimeout(debounceRef.current);
if (val.trim().length < 8) return;
debounceRef.current = setTimeout(async () => {
setGeocodificando(true);
try {
const geo = await geocodificarDireccion(val);
if (geo) {
setForm(f => ({ ...f, lat: geo.lat, lng: geo.lng }));
setResultadoGeo({ ok: true, texto: geo.displayName });
} else {
setResultadoGeo({ ok: false, texto: "No se encontró la dirección. Se usará el centro de la provincia." });
}
} catch {
setResultadoGeo({ ok: false, texto: "Error al geocodificar. Se usará el centro de la provincia." });
}
setGeocodificando(false);
}, 900);
};
const handleEnlaceChange = (val) => {
setForm(f => ({ ...f, enlaceGoogleMaps: val, lat: null, lng: null, direccion: "" }));
setResultadoGeo(null);
if (!val.trim()) return;
const coords = parsearEnlaceGoogleMaps(val);
if (coords) {
setForm(f => ({ ...f, lat: coords.lat, lng: coords.lng }));
setResultadoGeo({ ok: true, texto: `Coordenadas detectadas: ${coords.lat.toFixed(5)}, ${coords.lng.toFixed(5)}` });
} else {
setResultadoGeo({ ok: false, texto: "No se pudieron extraer coordenadas. Copia la URL completa desde Google Maps." });
}
};
return (
<div style={{ gridColumn: "1 / -1", marginTop: 4 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 6 }}>
<label style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
Ubicación <span style={{ color: "#8B0000" }}>*</span>
</label>
<div style={{ display: "flex", border: "0.5px solid var(--color-border-secondary)", borderRadius: "var(--border-radius-md)", overflow: "hidden" }}>
{[{ id: "direccion", label: "📍 Dirección" }, { id: "enlace", label: "🔗 Google Maps" }].map(opt => (
<button key={opt.id} onClick={() => { setModoInput(opt.id); setResultadoGeo(null); setForm(f => ({ ...f, lat: null, lng: null, direccion: "", enlaceGoogleMaps: "" })); }}
style={{ background: modoInput === opt.id ? "var(--color-background-secondary)" : "none", border: "none", padding: "4px 10px", fontSize: 11, cursor: "pointer", fontWeight: modoInput === opt.id ? 500 : 400, color: modoInput === opt.id ? "var(--color-text-primary)" : "var(--color-text-secondary)" }}>
{opt.label}
</button>
))}
</div>
</div>
{modoInput === "direccion" ? (
<input style={inputStyle} value={form.direccion || ""} placeholder="Ej: Calle Mayor 5, Alcalá de Henares, Madrid"
onChange={e => handleDireccionChange(e.target.value)} />
) : (
<input style={inputStyle} value={form.enlaceGoogleMaps || ""} placeholder="Pega aquí el enlace de Google Maps..."
onChange={e => handleEnlaceChange(e.target.value)} />
)}
{geocodificando && (
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 6, fontSize: 12, color: "var(--color-text-secondary)" }}>
<i className="ti ti-loader" aria-hidden="true" style={{ fontSize: 14 }}></i> Buscando dirección...
</div>
)}
{resultadoGeo && !geocodificando && (
<div style={{ display: "flex", alignItems: "flex-start", gap: 6, marginTop: 6, fontSize: 12,
color: resultadoGeo.ok ? "#1E8449" : "#784212",
background: resultadoGeo.ok ? "#EAFAF1" : "#FDF2E9",
padding: "6px 10px", borderRadius: "var(--border-radius-md)" }}>
<i className={`ti ${resultadoGeo.ok ? "ti-circle-check" : "ti-alert-triangle"}`} aria-hidden="true" style={{ fontSize: 14, flexShrink: 0, marginTop: 1 }}></i>
<span style={{ lineHeight: 1.4 }}>{resultadoGeo.texto}</span>
</div>
)}
</div>
);
}
// Banner descarga app móvil
function BannerMovil() {
const [visible, setVisible] = useState(true);
if (!visible) return null;
return (
<div style={{ position: "fixed", bottom: 20, right: 20, zIndex: 999, background: "white", border: "1px solid #E5DDD5", borderRadius: 14, padding: "14px 18px", boxShadow: "0 6px 24px rgba(0,0,0,0.10)", maxWidth: 280, fontFamily: "var(--font-sans)" }}>
<button onClick={() => setVisible(false)} 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 }}>
<span style={{ fontSize: 30 }}>📱</span>
<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>
<a href="/movil" target="_blank" rel="noopener noreferrer"
style={{ display: "block", textAlign: "center", padding: "9px 0", background: "#8B0000", color: "white", borderRadius: 8, fontSize: 13, fontWeight: 700, textDecoration: "none" }}>
📲 Abrir app móvil
</a>
</div>
);
}
// App principal
export default function App() {
const [locales, setLocales] = useState([]);
const [vista, setVista] = useState("lista");
const [mostrarForm, setMostrarForm] = 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 mapRef = useRef(null);
const mapInstanceRef = useRef(null);
const markersRef = useRef([]);
const [form, setForm] = useState({
nombre: "", provincia: "", categoria: "", subcategoria: "",
direccion: "", enlaceGoogleMaps: "", lat: null, lng: null,
puntuacion: 0, descripcion: ""
});
// Carga locales aprobados en tiempo real
useEffect(() => {
setLoading(true);
const unsub = suscribirLocales(
lista => { setLocales(lista); setLoading(false); },
() => { setLocales([]); setLoading(false); }
);
return () => unsub();
}, []);
// Carga filtro de palabras desde Firestore
useEffect(() => {
obtenerPalabrasFiltradas().then(p => setPalabrasProhibidas(p));
}, []);
const resetForm = () => setForm({ nombre: "", provincia: "", categoria: "", subcategoria: "", direccion: "", enlaceGoogleMaps: "", lat: null, lng: null, puntuacion: 0, descripcion: "" });
const enviarLocal = async () => {
setErrorFiltro("");
// Comprobar filtro de palabras
const textoCompleto = [form.nombre, form.descripcion, form.direccion].join(" ");
if (contienepalabrasProhibidas(textoCompleto, palabrasProhibidas)) {
setErrorFiltro("Tu propuesta contiene palabras no permitidas. Por favor, revisa el nombre, dirección o descripción.");
return;
}
let lat = form.lat, lng = form.lng;
if (!lat || !lng) {
const fb = COORDS_PROVINCIAS[form.provincia] || [40.4, -3.7];
lat = fb[0] + (Math.random() - 0.5) * 0.04;
lng = fb[1] + (Math.random() - 0.5) * 0.04;
}
setSaving(true);
try {
await enviarPropuesta({
nombre: form.nombre, provincia: form.provincia,
categoria: form.categoria, subcategoria: form.subcategoria,
direccion: form.direccion, enlaceGoogleMaps: form.enlaceGoogleMaps,
puntuacion: form.puntuacion, descripcion: form.descripcion,
lat, lng,
});
setEnviado(true);
resetForm();
setMostrarForm(false);
setTimeout(() => setEnviado(false), 5000);
} catch(e) { console.error(e); }
setSaving(false);
};
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);
})
.sort((a, b) => {
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
useEffect(() => {
if (vista !== "mapa") return;
const timer = setTimeout(() => {
if (!mapRef.current) 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]);
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 = [];
locales.forEach(local => {
if (!local.lat || !local.lng) return;
const color = COLORES_MAPA[local.categoria] || "#555";
const cat = CATEGORIAS[local.categoria];
const emoji = cat?.emoji || "📌";
const stars = "★".repeat(local.puntuacion) + "☆".repeat(5 - local.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;">${emoji} ${local.nombre}${labelDir}</div>`, iconAnchor: [0, 32] })
}).addTo(map);
const gmLink = local.enlaceGoogleMaps || (local.direccion ? `https://www.google.com/maps/search/${encodeURIComponent(local.nombre + " " + local.direccion)}` : null);
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||"#eee"};color:${color};font-size:12px;padding:2px 8px;border-radius:4px;display:inline-block;margin:4px 0;font-weight:500">${emoji} ${local.categoria}</span>${local.subcategoria?`<span style="font-size:11px;color:#666;margin-left:4px">${local.subcategoria}</span>`:""}${local.direccion?`<br><span style="font-size:12px;color:#444;margin-top:4px;display:block">📍 ${local.direccion}</span>`:""}${gmLink?`<br><a href="${gmLink}" target="_blank" style="display:inline-block;margin-top:6px;font-size:12px;color:#1A73E8;font-weight:500;text-decoration:none;background:#E8F0FE;padding:3px 8px;border-radius:4px">Ver en Google Maps ↗</a>`:"" }<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) : "—";
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" };
const tieneUbicacion = !!(form.direccion || form.enlaceGoogleMaps);
const formValido = form.nombre && form.provincia && form.categoria && form.puntuacion > 0 && tieneUbicacion;
return (
<div style={{ maxWidth: 720, margin: "0 auto", padding: "1.5rem 1rem", fontFamily: "var(--font-sans)" }}>
<h2 className="sr-only">Directorio de locales españoles</h2>
{/* Header */}
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: "1.5rem", gap: 12, flexWrap: "wrap" }}>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ fontSize: 28 }}>🇪🇸</span>
<h1 style={{ margin: 0, fontSize: 22, fontWeight: 500 }}>Locales Españoles</h1>
</div>
<p style={{ margin: "4px 0 0", fontSize: 13, color: "var(--color-text-secondary)" }}>Directorio colaborativo de negocios por toda España</p>
</div>
<button onClick={() => { setMostrarForm(!mostrarForm); setErrorFiltro(""); }}
style={{ background: "#8B0000", color: "white", border: "none", borderRadius: "var(--border-radius-md)", padding: "8px 16px", fontSize: 14, fontWeight: 500, cursor: "pointer", display: "flex", alignItems: "center", gap: 6, whiteSpace: "nowrap" }}>
<i className="ti ti-plus" aria-hidden="true"></i> Proponer local
</button>
</div>
{/* Confirmación enviada */}
{enviado && (
<div 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 }}></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" }}>
{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>
{/* Formulario de propuesta */}
{mostrarForm && (
<div style={{ background: "var(--color-background-primary)", border: "0.5px solid var(--color-border-secondary)", borderRadius: "var(--border-radius-lg)", padding: "1.25rem", marginBottom: "1.5rem" }}>
<p style={{ margin: "0 0 4px", fontWeight: 500, fontSize: 16 }}>Proponer un local</p>
<p style={{ margin: "0 0 1rem", fontSize: 13, color: "var(--color-text-secondary)" }}>El administrador revisará tu propuesta antes de publicarla.</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
<div>
<label style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Nombre del local *</label>
<input style={inputStyle} placeholder="Bar El Olivo, Clínica San José..." value={form.nombre} onChange={e => setForm(f => ({ ...f, nombre: e.target.value }))} />
</div>
<div>
<label style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Provincia *</label>
<select style={inputStyle} value={form.provincia} onChange={e => setForm(f => ({ ...f, provincia: e.target.value }))}>
<option value="">Selecciona provincia...</option>
{PROVINCIAS.map(p => <option key={p} value={p}>{p}</option>)}
</select>
</div>
<div>
<label style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Categoría *</label>
<select style={inputStyle} value={form.categoria} onChange={e => setForm(f => ({ ...f, categoria: e.target.value, subcategoria: "" }))}>
<option value="">Selecciona categoría...</option>
{NOMBRES_CATEGORIAS.map(c => <option key={c} value={c}>{CATEGORIAS[c].emoji} {c}</option>)}
</select>
</div>
<div>
<label style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Tipo específico</label>
<select style={{ ...inputStyle, opacity: form.categoria ? 1 : 0.5 }} value={form.subcategoria} onChange={e => setForm(f => ({ ...f, subcategoria: e.target.value }))} disabled={!form.categoria}>
<option value="">{form.categoria ? "Selecciona tipo..." : "Elige categoría primero"}</option>
{form.categoria && CATEGORIAS[form.categoria]?.subcategorias.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<CampoUbicacion form={form} setForm={setForm} />
</div>
<div style={{ marginTop: 10 }}>
<label style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Descripción / comentario</label>
<textarea style={{ ...inputStyle, resize: "vertical", minHeight: 60 }} placeholder="¿Qué lo hace especial? Horarios, servicios..." value={form.descripcion} onChange={e => setForm(f => ({ ...f, descripcion: e.target.value }))} />
</div>
<div style={{ marginTop: 10 }}>
<label style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 6 }}>Puntuación *</label>
<StarRating value={form.puntuacion} onChange={v => setForm(f => ({ ...f, puntuacion: v }))} />
</div>
{/* Error filtro palabras */}
{errorFiltro && (
<div style={{ marginTop: 12, background: "#FDEDEC", border: "1px solid #F5B7B1", borderRadius: "var(--border-radius-md)", padding: "10px 14px", fontSize: 13, color: "#922B21", display: "flex", alignItems: "center", gap: 8 }}>
<i className="ti ti-ban" aria-hidden="true"></i> {errorFiltro}
</div>
)}
<div style={{ display: "flex", gap: 8, marginTop: "1rem" }}>
<button onClick={enviarLocal} disabled={!formValido || saving}
style={{ background: formValido ? "#8B0000" : "var(--color-background-secondary)", color: formValido ? "white" : "var(--color-text-tertiary)", border: "none", borderRadius: "var(--border-radius-md)", padding: "8px 20px", fontSize: 14, fontWeight: 500, cursor: formValido ? "pointer" : "not-allowed" }}>
{saving ? "Enviando..." : "📨 Enviar propuesta"}
</button>
<button onClick={() => { setMostrarForm(false); setErrorFiltro(""); }} style={{ background: "none", border: "0.5px solid var(--color-border-secondary)", borderRadius: "var(--border-radius-md)", padding: "8px 16px", fontSize: 14, cursor: "pointer", color: "var(--color-text-secondary)" }}>Cancelar</button>
</div>
</div>
)}
{/* Tabs */}
<div 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} 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: "1rem" }}>
<input style={inputStyle} placeholder="🔍 Buscar por nombre, dirección..." value={busqueda} onChange={e => setBusqueda(e.target.value)} />
<select style={{ ...inputStyle, opacity: filtroCategoria ? 1 : 0.6 }} value={filtroSubcat} onChange={e => setFiltroSubcat(e.target.value)} disabled={!filtroCategoria}>
<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)}>
<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)}>
<option value="fecha">Reciente</option>
<option value="puntuacion"> Mejor</option>
<option value="nombre">A-Z</option>
</select>
</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 }}></i>
Cargando locales...
</div>
) : localesFiltrados.length === 0 ? (
<div style={{ textAlign: "center", padding: "3rem", color: "var(--color-text-tertiary)" }}>
<div style={{ fontSize: 40, marginBottom: 12 }}>🏘</div>
<p style={{ margin: 0, fontWeight: 500 }}>{locales.length === 0 ? "Todavía no hay locales publicados" : "No hay locales que coincidan"}</p>
<p style={{ margin: "6px 0 0", fontSize: 13 }}> el primero en proponer uno el administrador lo revisará y publicará</p>
</div>
) : (
<div style={{ display: "grid", gap: 10 }}>
{localesFiltrados.map(local => <LocalCard key={local.id} local={local} />)}
{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>
)}
</>
)}
{vista === "mapa" && (
<div style={{ 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 }}></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>}
<div ref={mapRef} style={{ height: 440, width: "100%" }}></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>
{locales.length} {locales.length === 1 ? "local marcado" : "locales marcados"}
</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 arriba
</p>
<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>
);
}
+143
View File
@@ -0,0 +1,143 @@
// 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 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 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
const user = localStorage.getItem("user");
currentUser = user ? JSON.parse(user) : null;
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;
}
-55
View File
@@ -1,55 +0,0 @@
import { useEffect, useState } from "react";
import { getPending, setStatus } from "../services/businessRepository.js";
export default function AdminPanel({ onClose, onApproved }) {
const [pending, setPending] = useState([]);
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
setPending(await getPending());
setLoading(false);
}
useEffect(() => { load(); }, []);
async function handle(id, status) {
await setStatus(id, status);
await load();
onApproved();
}
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal admin-panel" onClick={(e) => e.stopPropagation()}>
<div className="admin-header">
<h2>Propuestas pendientes</h2>
<button className="close" onClick={onClose} aria-label="Cerrar">×</button>
</div>
{loading && <p className="admin-empty">Cargando</p>}
{!loading && pending.length === 0 && (
<p className="admin-empty">No hay propuestas pendientes.</p>
)}
{!loading && pending.map((b) => (
<div key={b.id} className="admin-item">
<div className="admin-item-info">
<strong>{b.name}</strong>
<span className="badge">{b.category}</span>
<p>{b.address}</p>
<p className="admin-desc">{b.description}</p>
{b.contact && <p className="admin-contact">{b.contact}</p>}
<p className="admin-coords">{b.lat.toFixed(4)}, {b.lng.toFixed(4)}</p>
</div>
<div className="admin-actions">
<button className="approve" onClick={() => handle(b.id, "approved")}>Aprobar</button>
<button className="reject" onClick={() => handle(b.id, "rejected")}>Rechazar</button>
</div>
</div>
))}
</div>
</div>
);
}
-68
View File
@@ -1,68 +0,0 @@
import { MapContainer, TileLayer, Marker, useMapEvents } from "react-leaflet";
import L from "leaflet";
// Configuración del icono por defecto. Leaflet no encuentra sus iconos cuando
// se empaqueta con Vite; los referenciamos desde el CDN para no copiar
// archivos a public/.
const icon = new L.Icon({
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
iconSize: [25, 41],
iconAnchor: [12, 41],
});
const DEFAULT_CENTER = [40.4168, -3.7038];
const DEFAULT_ZOOM = 13;
function LocationPicker({ onPick }) {
useMapEvents({
click(e) {
onPick({ lat: e.latlng.lat, lng: e.latlng.lng });
},
});
return null;
}
export default function MapView({
businesses,
selectedId,
onSelect,
pickingLocation,
onPickLocation,
pickedLocation,
}) {
return (
<div className="map">
<MapContainer
center={DEFAULT_CENTER}
zoom={DEFAULT_ZOOM}
style={{ height: "100%", width: "100%" }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{businesses.map((b) => (
<Marker
key={b.id}
position={[b.lat, b.lng]}
icon={icon}
eventHandlers={{ click: () => onSelect(b.id) }}
opacity={selectedId && selectedId !== b.id ? 0.6 : 1}
/>
))}
{pickingLocation && <LocationPicker onPick={onPickLocation} />}
{pickedLocation && (
<Marker
position={[pickedLocation.lat, pickedLocation.lng]}
icon={icon}
/>
)}
</MapContainer>
{pickingLocation && (
<div className="map-hint">Haz clic en el mapa para fijar la ubicación</div>
)}
</div>
);
}
-64
View File
@@ -1,64 +0,0 @@
export default function Sidebar({ business, onContribute, onClose, picking, onCancelPicking, onAdmin }) {
return (
<aside className="sidebar">
<header className="sidebar-header">
<h1>Business Map</h1>
<p className="tagline">Registro colaborativo de negocios</p>
</header>
<div className="sidebar-body">
{picking ? (
<div className="empty">
<p>Haz clic en el mapa para fijar la ubicación del negocio.</p>
</div>
) : business ? (
<BusinessDetails business={business} onClose={onClose} />
) : (
<Empty />
)}
</div>
<footer className="sidebar-footer">
{picking ? (
<button onClick={onCancelPicking}>Cancelar</button>
) : (
<>
<button className="primary" onClick={onContribute}>
+ Añadir un negocio
</button>
<button className="admin-btn" onClick={onAdmin} title="Panel de administración">
Admin
</button>
</>
)}
</footer>
</aside>
);
}
function Empty() {
return (
<div className="empty">
<p>Selecciona un punto del mapa para ver sus detalles.</p>
</div>
);
}
function BusinessDetails({ business, onClose }) {
return (
<article className="details">
<button className="close" onClick={onClose} aria-label="Cerrar">
×
</button>
<span className="badge">{business.category}</span>
<h2>{business.name}</h2>
<p className="address">{business.address}</p>
<p className="description">{business.description}</p>
{business.contact && (
<p className="contact">
<strong>Contacto:</strong> {business.contact}
</p>
)}
</article>
);
}
-100
View File
@@ -1,100 +0,0 @@
import { useState } from "react";
const EMPTY = {
name: "",
category: "",
address: "",
description: "",
contact: "",
};
export default function SubmitForm({ pickedLocation, onSubmit, onCancel, onRepick }) {
const [values, setValues] = useState(EMPTY);
function update(field) {
return (e) => setValues({ ...values, [field]: e.target.value });
}
function handleSubmit(e) {
e.preventDefault();
onSubmit({ ...values, lat: pickedLocation.lat, lng: pickedLocation.lng });
}
return (
<div className="modal-backdrop" onClick={onCancel}>
<form
className="modal"
onClick={(e) => e.stopPropagation()}
onSubmit={handleSubmit}
>
<h2>Proponer un negocio</h2>
<p className="hint">
Los datos se revisarán antes de aparecer en el mapa.
</p>
<label>
Nombre
<input
required
value={values.name}
onChange={update("name")}
/>
</label>
<label>
Categoría
<input
required
placeholder="Cafetería, librería, taller…"
value={values.category}
onChange={update("category")}
/>
</label>
<label>
Dirección
<input
required
value={values.address}
onChange={update("address")}
/>
</label>
<label>
Descripción
<textarea
required
rows={3}
value={values.description}
onChange={update("description")}
/>
</label>
<label>
Contacto (opcional)
<input
placeholder="Email, teléfono o web"
value={values.contact}
onChange={update("contact")}
/>
</label>
<div className="location-status">
{`Ubicación: ${pickedLocation.lat.toFixed(4)}, ${pickedLocation.lng.toFixed(4)}`}
<button type="button" className="repick-link" onClick={onRepick}>
Cambiar
</button>
</div>
<div className="actions">
<button type="button" onClick={onCancel}>
Cancelar
</button>
<button type="submit" className="primary">
Enviar
</button>
</div>
</form>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
// firebase.js - Re-exports local API instead of Firebase
export {
escucharAuth,
loginAdmin,
registrarAdmin,
cerrarSesion,
suscribirLocales,
deleteLocal,
enviarPropuesta,
suscribirPendientes,
aprobarPropuesta,
rechazarPropuesta,
obtenerPalabrasFiltradas,
guardarPalabrasFiltradas,
contienepalabrasProhibidas,
} from "./api.js";
+16 -8
View File
@@ -1,10 +1,18 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "./styles.css";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import AppMovil from "./AppMovil.jsx";
import AdminPanel from "./AdminPanel.jsx";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
// Enrutador mínimo sin dependencias extra
const ruta = window.location.pathname.replace(/\/$/, "");
function Root() {
if (ruta === "/admin") return <AdminPanel />;
if (ruta === "/movil") return <AppMovil />;
return <App />;
}
createRoot(document.getElementById("root")).render(
<StrictMode><Root /></StrictMode>
);
-31
View File
@@ -1,31 +0,0 @@
export async function getApproved() {
const res = await fetch(`${import.meta.env.BASE_URL}api/businesses`);
if (!res.ok) throw new Error("Error cargando negocios");
return res.json();
}
export async function submit(data) {
const res = await fetch(`${import.meta.env.BASE_URL}api/businesses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Error enviando propuesta");
return res.json();
}
export async function getPending() {
const res = await fetch(`${import.meta.env.BASE_URL}api/admin/pending`);
if (!res.ok) throw new Error("Error cargando pendientes");
return res.json();
}
export async function setStatus(id, status) {
const res = await fetch(`${import.meta.env.BASE_URL}api/admin/businesses/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
if (!res.ok) throw new Error("Error actualizando estado");
return res.json();
}
-377
View File
@@ -1,377 +0,0 @@
:root {
--bg: #fafaf7;
--surface: #ffffff;
--text: #1a1a1a;
--muted: #6b6b6b;
--border: #e6e6e2;
--accent: #2f6f4f;
--accent-hover: #245a3f;
--shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
--sidebar-width: 360px;
}
* {
box-sizing: border-box;
}
html, body, #root {
height: 100%;
margin: 0;
}
body {
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: var(--text);
background: var(--bg);
}
.app {
display: flex;
height: 100%;
}
/* Sidebar */
.sidebar {
width: var(--sidebar-width);
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border);
}
.sidebar-header h1 {
margin: 0;
font-size: 1.25rem;
}
.tagline {
margin: 4px 0 0;
font-size: 0.85rem;
color: var(--muted);
}
.sidebar-body {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.sidebar-footer {
padding: 16px 24px;
border-top: 1px solid var(--border);
}
.empty {
color: var(--muted);
font-size: 0.95rem;
line-height: 1.5;
}
/* Detalles */
.details {
position: relative;
}
.close {
position: absolute;
top: -8px;
right: -8px;
width: 32px;
height: 32px;
border: none;
background: transparent;
font-size: 1.5rem;
cursor: pointer;
color: var(--muted);
line-height: 1;
}
.close:hover {
color: var(--text);
}
.badge {
display: inline-block;
background: var(--bg);
border: 1px solid var(--border);
padding: 2px 10px;
border-radius: 12px;
font-size: 0.75rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.details h2 {
margin: 12px 0 4px;
font-size: 1.3rem;
}
.address {
margin: 0 0 16px;
color: var(--muted);
font-size: 0.9rem;
}
.description {
line-height: 1.55;
margin: 0 0 16px;
}
.contact {
font-size: 0.9rem;
margin: 0;
}
/* Mapa */
.map {
flex: 1;
position: relative;
}
.map-hint {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
background: var(--surface);
padding: 8px 16px;
border-radius: 4px;
box-shadow: var(--shadow);
font-size: 0.9rem;
z-index: 1000;
}
/* Botones */
button {
font: inherit;
padding: 10px 18px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
}
button:hover {
background: var(--bg);
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: white;
width: 100%;
}
button.primary:hover {
background: var(--accent-hover);
}
/* Modal */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 16px;
}
.modal {
background: var(--surface);
border-radius: 6px;
padding: 28px;
width: 100%;
max-width: 460px;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow);
}
.modal h2 {
margin: 0 0 4px;
}
.modal .hint {
color: var(--muted);
font-size: 0.9rem;
margin: 0 0 20px;
}
.modal label {
display: block;
margin-bottom: 14px;
font-size: 0.9rem;
color: var(--muted);
}
.modal input,
.modal textarea {
width: 100%;
padding: 8px 10px;
margin-top: 4px;
border: 1px solid var(--border);
border-radius: 4px;
font: inherit;
color: var(--text);
background: var(--surface);
}
.modal input:focus,
.modal textarea:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
border-color: var(--accent);
}
.location-status {
background: var(--bg);
padding: 10px 12px;
border-radius: 4px;
font-size: 0.85rem;
color: var(--muted);
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.repick-link {
padding: 2px 8px;
font-size: 0.8rem;
border-color: var(--border);
background: transparent;
color: var(--accent);
border-radius: 3px;
flex-shrink: 0;
}
.repick-link:hover {
background: var(--bg);
text-decoration: underline;
}
.actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
/* Admin */
.admin-btn {
margin-top: 8px;
width: 100%;
font-size: 0.85rem;
color: var(--muted);
}
.admin-panel {
max-width: 560px;
}
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.admin-header h2 {
margin: 0;
}
.admin-header .close {
position: static;
width: auto;
height: auto;
}
.admin-empty {
color: var(--muted);
text-align: center;
padding: 24px 0;
}
.admin-item {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 14px 0;
border-top: 1px solid var(--border);
}
.admin-item-info {
flex: 1;
font-size: 0.9rem;
}
.admin-item-info strong {
display: block;
margin-bottom: 4px;
}
.admin-item-info p {
margin: 2px 0;
color: var(--muted);
}
.admin-desc {
color: var(--text) !important;
}
.admin-contact, .admin-coords {
font-size: 0.8rem;
}
.admin-actions {
display: flex;
flex-direction: column;
gap: 6px;
flex-shrink: 0;
}
.admin-actions .approve {
background: var(--accent);
border-color: var(--accent);
color: white;
padding: 6px 14px;
font-size: 0.85rem;
}
.admin-actions .approve:hover {
background: var(--accent-hover);
}
.admin-actions .reject {
padding: 6px 14px;
font-size: 0.85rem;
color: #c0392b;
border-color: #e0b0b0;
}
.admin-actions .reject:hover {
background: #fff5f5;
}
/* Responsive */
@media (max-width: 720px) {
.app {
flex-direction: column-reverse;
}
.sidebar {
width: 100%;
max-height: 45%;
border-right: none;
border-top: 1px solid var(--border);
}
}
+10 -19
View File
@@ -1,23 +1,14 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: "/",
server: {
host: "0.0.0.0",
strictPort: true,
allowedHosts: true,
cors: {
origin: "*",
methods: "GET,HEAD,PUT,PATCH,POST,DELETE",
preflightContinue: false,
optionsSuccessStatus: 204,
},
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,HEAD,PUT,PATCH,POST,DELETE",
"Access-Control-Allow-Headers": "Content-Type,Authorization",
},
},
});
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
}
}
}
})