Compare commits
7
Commits
bd4282c426
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cc326e8ac | ||
|
|
6323fe8f0e | ||
|
|
adb2b4dfe4 | ||
|
|
98b19aa3a2 | ||
|
|
27166eb3f4 | ||
|
|
e08d959cf9 | ||
|
|
c06300833b |
@@ -0,0 +1,54 @@
|
|||||||
|
name: deploy-branch
|
||||||
|
|
||||||
|
# CI/CD: cada push a cualquier rama se despliega en <rama>.localesp.es
|
||||||
|
# Estrategia A — un entorno por rama. El registro DNS A es manual (precondición).
|
||||||
|
# Detalles: docs/07-cicd.md
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "**"
|
||||||
|
delete:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
branch:
|
||||||
|
description: "Rama a desplegar (vacío = la actual)"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
if: github.event_name != 'delete'
|
||||||
|
runs-on: self-hosted
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build (npm ci + vite build)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# better-sqlite3 (v12) requiere C++20; el GCC 8 del sistema (AlmaLinux 8) no lo soporta.
|
||||||
|
# gcc-toolset-12 (GCC 12) ya está instalado en /opt/rh.
|
||||||
|
source /opt/rh/gcc-toolset-12/enable 2>/dev/null || echo "WARN: gcc-toolset-12 no encontrado"
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Deploy branch
|
||||||
|
env:
|
||||||
|
BRANCH: ${{ github.event.inputs.branch || github.ref_name }}
|
||||||
|
run: bash scripts/deploy.sh "$BRANCH"
|
||||||
|
|
||||||
|
# Al borrar una rama, se limpia su entorno
|
||||||
|
teardown:
|
||||||
|
if: github.event_name == 'delete'
|
||||||
|
runs-on: self-hosted
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- name: Checkout (default branch, trae scripts/)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Teardown branch env
|
||||||
|
env:
|
||||||
|
# github.event.ref en un 'delete' es refs/heads/<rama>
|
||||||
|
BRANCH: ${{ github.event.ref }}
|
||||||
|
run: bash scripts/teardown.sh "${BRANCH#refs/heads/}"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: Bug
|
||||||
|
about: Reportar un error
|
||||||
|
labels: bug
|
||||||
|
---
|
||||||
|
|
||||||
|
**Qué pasa**
|
||||||
|
Descripción breve.
|
||||||
|
|
||||||
|
**Pasos para reproducir**
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
|
||||||
|
**Qué esperabas**
|
||||||
|
|
||||||
|
**Entorno**
|
||||||
|
- Navegador:
|
||||||
|
- Sistema operativo:
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
name: Propuesta de mejora
|
||||||
|
about: Sugerir una funcionalidad o mejora
|
||||||
|
labels: enhancement
|
||||||
|
---
|
||||||
|
|
||||||
|
**Problema o necesidad**
|
||||||
|
¿Qué te falta? ¿Quién la usaría?
|
||||||
|
|
||||||
|
**Propuesta**
|
||||||
|
Cómo lo resolverías. Mantenlo simple: si la propuesta requiere muchas piezas
|
||||||
|
nuevas, conviene discutirla antes en Discussions.
|
||||||
|
|
||||||
|
**Alternativas que has considerado**
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
## Qué cambia
|
||||||
|
|
||||||
|
<!-- En una o dos frases. -->
|
||||||
|
|
||||||
|
## Por qué
|
||||||
|
|
||||||
|
<!-- Issue relacionado o motivación. -->
|
||||||
|
|
||||||
|
## Notas para el revisor
|
||||||
|
|
||||||
|
- [ ] La app arranca con `npm run dev`.
|
||||||
|
- [ ] No he añadido dependencias innecesarias.
|
||||||
|
- [ ] He seguido la estructura existente.
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
|
# artefactos de despliegue manual / datos locales
|
||||||
|
*.tgz
|
||||||
|
localesp.db
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) [AÑO] [ORGANIZACIÓN]
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,2 +1,39 @@
|
|||||||
# LocalESP
|
# Business Map
|
||||||
Registro colaborativo de negocios españoles
|
|
||||||
|
Registro colaborativo de negocios, en un mapa abierto.
|
||||||
|
|
||||||
|
Prototipo open source mantenido por **[ORGANIZACIÓN]**. Cualquiera puede ver
|
||||||
|
los negocios registrados y proponer nuevos; las propuestas son revisadas
|
||||||
|
antes de aparecer públicamente.
|
||||||
|
|
||||||
|
## Demo local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Abre la URL que indique Vite (normalmente `http://localhost:5173`).
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- React 18 + Vite
|
||||||
|
- Leaflet + OpenStreetMap (mapa)
|
||||||
|
- CSS plano
|
||||||
|
|
||||||
|
Sin backend en el prototipo: los datos vienen de un mock en memoria. Ver
|
||||||
|
`docs/02-implementation-alternatives.md` para la decisión de backend.
|
||||||
|
|
||||||
|
## Documentación
|
||||||
|
|
||||||
|
- [`docs/01-use-cases.md`](docs/01-use-cases.md) – qué hace y qué no.
|
||||||
|
- [`docs/02-implementation-alternatives.md`](docs/02-implementation-alternatives.md) – decisiones técnicas.
|
||||||
|
- [`docs/03-decisions.md`](docs/03-decisions.md) – ADRs.
|
||||||
|
- [`docs/04-governance.md`](docs/04-governance.md) – cómo se gobierna el proyecto.
|
||||||
|
- [`docs/05-contributing.md`](docs/05-contributing.md) – cómo contribuir.
|
||||||
|
- [`docs/06-github-setup.md`](docs/06-github-setup.md) – configuración inicial del repo.
|
||||||
|
- [`docs/07-cicd.md`](docs/07-cicd.md) – CI/CD: despliegue por rama en `<rama>.localesp.es`.
|
||||||
|
|
||||||
|
## Licencia
|
||||||
|
|
||||||
|
MIT. Ver [LICENSE](LICENSE).
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Casos de uso
|
||||||
|
|
||||||
|
Este documento describe los casos de uso del prototipo. El objetivo es mantener
|
||||||
|
el alcance acotado para minimizar el coste de mantenimiento.
|
||||||
|
|
||||||
|
## Actores
|
||||||
|
|
||||||
|
- **Visitante**: cualquier persona que accede a la aplicación. No requiere
|
||||||
|
autenticación. Puede consultar el mapa y contribuir con nuevas entradas.
|
||||||
|
- **Revisor**: persona designada por la organización que aprueba o rechaza las
|
||||||
|
contribuciones. Su flujo está fuera del frontend del prototipo (ver
|
||||||
|
alternativas de implementación).
|
||||||
|
|
||||||
|
## Casos de uso del prototipo
|
||||||
|
|
||||||
|
### CU-01: Explorar negocios en el mapa
|
||||||
|
- El visitante abre la aplicación.
|
||||||
|
- Ve un mapa centrado en una ubicación por defecto con marcadores de los
|
||||||
|
negocios aprobados.
|
||||||
|
- Puede hacer zoom y desplazarse libremente.
|
||||||
|
|
||||||
|
### CU-02: Ver detalles de un negocio
|
||||||
|
- El visitante selecciona un marcador en el mapa.
|
||||||
|
- La barra lateral muestra los detalles del negocio: nombre, categoría,
|
||||||
|
dirección, descripción corta y enlace/contacto si existe.
|
||||||
|
- Al seleccionar otro marcador, la barra lateral se actualiza.
|
||||||
|
|
||||||
|
### CU-03: Contribuir con un nuevo negocio
|
||||||
|
- El visitante abre el formulario de contribución desde la interfaz principal.
|
||||||
|
- Rellena los campos mínimos: nombre, categoría, dirección, descripción y
|
||||||
|
contacto opcional. La ubicación se obtiene geocodificando la dirección o
|
||||||
|
haciendo clic en el mapa.
|
||||||
|
- Envía el formulario. La entrada queda en estado "pendiente de revisión" y no
|
||||||
|
aparece en el mapa hasta ser aprobada.
|
||||||
|
- El visitante recibe confirmación visual del envío.
|
||||||
|
|
||||||
|
## Fuera de alcance del prototipo
|
||||||
|
|
||||||
|
Decisiones tomadas para mantener el proyecto simple (ver
|
||||||
|
`02-implementation-alternatives.md` para el razonamiento):
|
||||||
|
|
||||||
|
- Autenticación de usuarios.
|
||||||
|
- Panel de moderación dentro de la aplicación. La revisión se hace en el
|
||||||
|
backend (por ejemplo, en el dashboard del proveedor de datos elegido).
|
||||||
|
- Edición o eliminación de negocios por el visitante.
|
||||||
|
- Búsqueda avanzada, filtros complejos o categorías jerárquicas.
|
||||||
|
- Comentarios, valoraciones, fotos múltiples o multimedia.
|
||||||
|
- Internacionalización. El prototipo usa un único idioma.
|
||||||
|
|
||||||
|
Estos casos pueden añadirse después si la comunidad lo pide, pero no forman
|
||||||
|
parte del prototipo.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Alternativas de implementación
|
||||||
|
|
||||||
|
Este documento registra las decisiones técnicas tomadas y por qué. El criterio
|
||||||
|
guía es **mínimo coste de mantenimiento** para una organización sin ánimo de
|
||||||
|
lucro, siguiendo KISS y evitando YAGNI.
|
||||||
|
|
||||||
|
## 1. Framework frontend
|
||||||
|
|
||||||
|
| Opción | Pros | Contras |
|
||||||
|
|---|---|---|
|
||||||
|
| **React + Vite** (elegido) | Ecosistema enorme, fácil encontrar contribuidores, build rápido. | Requiere build step. |
|
||||||
|
| Next.js | SSR, rutas, imágenes. | Demasiado para una SPA de una página; aumenta complejidad y coste de hosting. |
|
||||||
|
| Vanilla JS | Sin dependencias. | Más código manual, menos contribuidores familiarizados. |
|
||||||
|
|
||||||
|
**Decisión**: React + Vite. Es el stack más común y los contribuidores
|
||||||
|
potenciales lo conocen. Vite mantiene la configuración mínima.
|
||||||
|
|
||||||
|
## 2. Mapa
|
||||||
|
|
||||||
|
| Opción | Pros | Contras |
|
||||||
|
|---|---|---|
|
||||||
|
| **Leaflet + OpenStreetMap** (elegido) | Open source, sin claves API, sin coste, tiles gratuitos. | Menos pulido que Mapbox. |
|
||||||
|
| Mapbox GL | Más bonito, mejor rendimiento. | Requiere API key y tiene cuotas de pago. |
|
||||||
|
| Google Maps | Familiar. | Requiere facturación activada, cuotas estrictas, no encaja con espíritu open source. |
|
||||||
|
|
||||||
|
**Decisión**: Leaflet con tiles de OpenStreetMap. Cero coste, sin claves, y la
|
||||||
|
librería `react-leaflet` lo hace trivial de integrar.
|
||||||
|
|
||||||
|
## 3. Backend y almacenamiento de datos
|
||||||
|
|
||||||
|
| Opción | Pros | Contras |
|
||||||
|
|---|---|---|
|
||||||
|
| **Backend-as-a-Service** (Supabase o similar) elegido | Sin servidor que mantener, base de datos gestionada, panel de moderación incluido en su dashboard. Plan gratuito generoso. | Dependencia de un proveedor. |
|
||||||
|
| Backend propio (Node + Postgres) | Control total. | Hay que mantenerlo, pagarlo, securizarlo. Alto coste para una ONG. |
|
||||||
|
| Archivo JSON en el repositorio | Sin backend, máxima simplicidad. | Las contribuciones tendrían que llegar como Pull Requests, fricción alta para no-técnicos. |
|
||||||
|
|
||||||
|
**Decisión**: BaaS tipo Supabase. Para el prototipo, la capa de datos se
|
||||||
|
abstrae en `src/services/businessRepository.js`, así que cambiar de proveedor
|
||||||
|
después es localizado. El prototipo entregado usa un **mock en memoria** para
|
||||||
|
no acoplar a un proveedor concreto antes de que la organización lo decida.
|
||||||
|
|
||||||
|
## 4. Moderación
|
||||||
|
|
||||||
|
| Opción | Pros | Contras |
|
||||||
|
|---|---|---|
|
||||||
|
| **Aprobación desde el dashboard del BaaS** (elegido) | Cero código extra. El revisor cambia un campo `status` de `pending` a `approved`. | Requiere acceso al dashboard. |
|
||||||
|
| Panel de moderación dentro de la app | Más cómodo. | Requiere autenticación, roles, vistas extra: explosión de complejidad. |
|
||||||
|
|
||||||
|
**Decisión**: La moderación vive fuera del frontend público. El modelo de
|
||||||
|
datos incluye un campo `status` (`pending` / `approved` / `rejected`). El
|
||||||
|
frontend solo lee los `approved`.
|
||||||
|
|
||||||
|
## 5. Estilos
|
||||||
|
|
||||||
|
| Opción | Pros | Contras |
|
||||||
|
|---|---|---|
|
||||||
|
| **CSS plano con variables** (elegido) | Cero dependencias, fácil de leer, los contribuidores con HTML/CSS básico pueden colaborar. | Sin utilidades. |
|
||||||
|
| Tailwind | Productivo. | Cadena de build extra, curva de aprendizaje. |
|
||||||
|
| CSS-in-JS | Componentes encapsulados. | Runtime extra, peor accesibilidad para nuevos contribuidores. |
|
||||||
|
|
||||||
|
**Decisión**: CSS plano. La app es de una página; no se justifica más.
|
||||||
|
|
||||||
|
## 6. Gestión de estado
|
||||||
|
|
||||||
|
**Decisión**: `useState` y `useEffect` de React. No hay Redux, Zustand ni
|
||||||
|
Context global. La app es pequeña; añadir una librería de estado sería YAGNI.
|
||||||
|
|
||||||
|
## 7. Geocodificación de direcciones del formulario
|
||||||
|
|
||||||
|
**Decisión para el prototipo**: el usuario hace clic en el mapa para fijar la
|
||||||
|
ubicación, o introduce lat/lng. Geocodificación automática vía Nominatim
|
||||||
|
(OpenStreetMap) queda como mejora futura con bajo coste de añadir.
|
||||||
|
|
||||||
|
## 8. Tests
|
||||||
|
|
||||||
|
**Decisión**: el prototipo no incluye suite de tests para no inflarlo, pero la
|
||||||
|
estructura (servicios separados de componentes, funciones puras donde posible)
|
||||||
|
facilita añadirlos. Cuando se incorporen, **Vitest** es la elección natural por
|
||||||
|
integrarse con Vite sin configuración.
|
||||||
|
|
||||||
|
## 9. Despliegue
|
||||||
|
|
||||||
|
**Decisión recomendada**: **GitHub Pages** o **Cloudflare Pages** (gratuitos
|
||||||
|
para open source). Una SPA estática construida con `vite build` se despliega
|
||||||
|
sin coste y sin servidor. Ver `docs/06-github-setup.md`.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Registro de decisiones
|
||||||
|
|
||||||
|
Lista corta y cronológica de decisiones que merecen ser revisadas más
|
||||||
|
adelante. Formato ADR ligero.
|
||||||
|
|
||||||
|
## D-001: SPA de una sola página
|
||||||
|
**Contexto**: el uso principal es ver un mapa y unos detalles.
|
||||||
|
**Decisión**: una sola página, sin router. El formulario es un panel/modal en
|
||||||
|
la misma vista.
|
||||||
|
**Consecuencia**: si en el futuro hay vistas adicionales (estadísticas, perfil
|
||||||
|
del negocio en URL propia), habrá que introducir React Router. Reevaluar
|
||||||
|
cuando aparezca el segundo caso de uso navegable.
|
||||||
|
|
||||||
|
## D-002: Capa de servicios
|
||||||
|
**Decisión**: todo acceso a datos pasa por `src/services/businessRepository.js`.
|
||||||
|
**Razón**: cumplir Dependency Inversion (SOLID). Los componentes no saben si
|
||||||
|
los datos vienen de un mock, de Supabase o de un JSON.
|
||||||
|
**Consecuencia**: cambiar de proveedor es modificar un único archivo.
|
||||||
|
|
||||||
|
## D-003: Modelo de datos mínimo
|
||||||
|
Campos del negocio:
|
||||||
|
- `id` (string)
|
||||||
|
- `name` (string)
|
||||||
|
- `category` (string)
|
||||||
|
- `address` (string)
|
||||||
|
- `description` (string)
|
||||||
|
- `contact` (string, opcional)
|
||||||
|
- `lat` (number)
|
||||||
|
- `lng` (number)
|
||||||
|
- `status` (`pending` | `approved` | `rejected`)
|
||||||
|
- `createdAt` (ISO string)
|
||||||
|
|
||||||
|
Sin slugs, sin fotos, sin tags. Reevaluar tras feedback de la comunidad.
|
||||||
|
|
||||||
|
## D-004: Sin autenticación en el prototipo
|
||||||
|
**Razón**: el visitante no necesita cuenta. El moderador opera en el panel del
|
||||||
|
backend.
|
||||||
|
**Reevaluar si**: aparecen casos de uso que requieran identidad (favoritos,
|
||||||
|
edición por el dueño del negocio).
|
||||||
|
|
||||||
|
## D-005: Idioma
|
||||||
|
El prototipo está en español, sin i18n. Si la organización quiere expandirse,
|
||||||
|
añadir `react-i18next` cuando exista una segunda lengua real, no antes.
|
||||||
|
|
||||||
|
## D-006: Mock de datos en el prototipo
|
||||||
|
El repositorio entregado usa un mock en memoria con varios negocios de
|
||||||
|
ejemplo. La organización debe decidir el backend antes de pasar a producción.
|
||||||
|
La interfaz del repositorio (`getApproved`, `submit`) está pensada para
|
||||||
|
mapearse 1:1 a Supabase u otro BaaS.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Gobernanza
|
||||||
|
|
||||||
|
Este proyecto es mantenido por **[NOMBRE DE LA ORGANIZACIÓN]**, una entidad
|
||||||
|
sin ánimo de lucro. Estas pautas son deliberadamente breves; queremos invertir
|
||||||
|
el esfuerzo en código y comunidad, no en burocracia.
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
- **Mantenedores**: personas con permisos de merge en `main`. Son nombradas
|
||||||
|
por la organización. Responsables de revisar PRs, etiquetar issues y publicar
|
||||||
|
releases.
|
||||||
|
- **Contribuidores**: cualquiera que envíe un Pull Request, abra un issue o
|
||||||
|
participe en las discusiones.
|
||||||
|
- **Moderadores de contenido**: personas con acceso al panel del backend para
|
||||||
|
aprobar o rechazar los negocios enviados por los visitantes. No
|
||||||
|
necesariamente coinciden con los mantenedores del código.
|
||||||
|
|
||||||
|
## Toma de decisiones
|
||||||
|
|
||||||
|
- Decisiones pequeñas (bugs, mejoras menores): las toma quien revisa el PR.
|
||||||
|
- Decisiones de diseño o que afecten al modelo de datos: discusión pública en
|
||||||
|
un issue durante al menos 7 días. Cierra el debate un mantenedor buscando
|
||||||
|
consenso. Si no lo hay, decide la organización.
|
||||||
|
- Las decisiones relevantes se registran en `docs/03-decisions.md`.
|
||||||
|
|
||||||
|
## Código de conducta
|
||||||
|
|
||||||
|
Adoptamos el [Contributor Covenant](https://www.contributor-covenant.org/). En
|
||||||
|
resumen: trata a las personas con respeto. Las violaciones se reportan al
|
||||||
|
correo `[CORREO_CONTACTO]` y las gestiona la organización.
|
||||||
|
|
||||||
|
## Licencia
|
||||||
|
|
||||||
|
El proyecto se publica bajo **MIT**. Las contribuciones se entienden
|
||||||
|
licenciadas bajo los mismos términos.
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
Versionado semántico (semver). Los mantenedores publican versiones cuando
|
||||||
|
acumulan cambios suficientes. No hay calendario fijo.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Cómo contribuir
|
||||||
|
|
||||||
|
¡Gracias por querer ayudar! El proyecto se mantiene gracias a la comunidad.
|
||||||
|
|
||||||
|
## Antes de empezar
|
||||||
|
|
||||||
|
1. Mira los [issues abiertos](../../issues), sobre todo los marcados
|
||||||
|
`good first issue`.
|
||||||
|
2. Si tu cambio es grande, abre un issue antes de programar para discutirlo.
|
||||||
|
|
||||||
|
## Flujo
|
||||||
|
|
||||||
|
1. Haz fork del repositorio.
|
||||||
|
2. Crea una rama desde `main`: `git checkout -b mi-cambio`.
|
||||||
|
3. Escribe el código siguiendo las pautas de abajo.
|
||||||
|
4. Asegúrate de que la app arranca: `npm install && npm run dev`.
|
||||||
|
5. Abre un Pull Request hacia `main` describiendo el cambio.
|
||||||
|
|
||||||
|
## Pautas de código
|
||||||
|
|
||||||
|
- **Mantén la app simple.** Si dudas entre dos soluciones, elige la más corta
|
||||||
|
y obvia. Este proyecto evita la complejidad a propósito.
|
||||||
|
- **Sigue la estructura existente.** Componentes en `src/components`, acceso a
|
||||||
|
datos en `src/services`.
|
||||||
|
- **No añadas dependencias sin necesidad.** Cada nueva librería es coste de
|
||||||
|
mantenimiento.
|
||||||
|
- **SOLID y Clean Code aplicados con sentido común.** Nombres claros,
|
||||||
|
funciones cortas, una responsabilidad por archivo.
|
||||||
|
- **Estilo**: 2 espacios de indentación, comillas dobles en JSX y simples en JS.
|
||||||
|
|
||||||
|
## Tipos de contribución
|
||||||
|
|
||||||
|
- **Código**: bugs, mejoras, nuevas funciones acordadas en issues.
|
||||||
|
- **Documentación**: cualquier archivo de `docs/` o el README.
|
||||||
|
- **Diseño**: propuestas en issues con mockups.
|
||||||
|
- **Reportar negocios**: usa la propia aplicación; no es necesario un PR.
|
||||||
|
|
||||||
|
## Revisión
|
||||||
|
|
||||||
|
Un mantenedor revisará tu PR. Puede pedir cambios. Una vez aprobado, hará
|
||||||
|
merge. Sé paciente: somos voluntarios.
|
||||||
|
|
||||||
|
## Reconocimiento
|
||||||
|
|
||||||
|
Todas las personas que han contribuido aparecen automáticamente en la pestaña
|
||||||
|
de Contributors del repositorio.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Configuración del repositorio en GitHub
|
||||||
|
|
||||||
|
Pasos para dejar listo el repositorio. Pensado para que lo hagas una sola vez.
|
||||||
|
|
||||||
|
## 1. Crear el repositorio
|
||||||
|
|
||||||
|
1. En GitHub, crea un repositorio en la cuenta de la organización.
|
||||||
|
Recomendado: nombre corto, kebab-case, por ejemplo `business-map`.
|
||||||
|
2. Marca **Public**.
|
||||||
|
3. **No** inicialices con README, .gitignore ni licencia (ya vienen en este
|
||||||
|
prototipo).
|
||||||
|
4. Sube el código:
|
||||||
|
```bash
|
||||||
|
git init
|
||||||
|
git add .
|
||||||
|
git commit -m "Prototipo inicial"
|
||||||
|
git branch -M main
|
||||||
|
git remote add origin git@github.com:ORG/business-map.git
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Ajustes básicos (Settings → General)
|
||||||
|
|
||||||
|
- **Features**: deja activos Issues y Discussions. Desactiva Wikis y Projects
|
||||||
|
hasta que se usen.
|
||||||
|
- **Pull Requests**: marca "Allow squash merging" como única opción y "Always
|
||||||
|
suggest updating pull request branches".
|
||||||
|
- **Archive**: deja desactivado.
|
||||||
|
|
||||||
|
## 3. Protección de la rama `main` (Settings → Branches)
|
||||||
|
|
||||||
|
Añade una regla para `main`:
|
||||||
|
- Require a pull request before merging.
|
||||||
|
- Require approvals: 1.
|
||||||
|
- Dismiss stale approvals when new commits are pushed.
|
||||||
|
- Do not allow bypassing the above settings.
|
||||||
|
|
||||||
|
## 4. Plantillas
|
||||||
|
|
||||||
|
Las plantillas ya están en `.github/` del prototipo:
|
||||||
|
- `ISSUE_TEMPLATE/bug.md`
|
||||||
|
- `ISSUE_TEMPLATE/feature.md`
|
||||||
|
- `PULL_REQUEST_TEMPLATE.md`
|
||||||
|
|
||||||
|
## 5. Documentos visibles en GitHub
|
||||||
|
|
||||||
|
Para que GitHub los muestre como insignias del repositorio:
|
||||||
|
- `README.md` (raíz).
|
||||||
|
- `LICENSE` (raíz).
|
||||||
|
- `docs/05-contributing.md`: súbelo además como `CONTRIBUTING.md` en la raíz o
|
||||||
|
enlázalo desde el README.
|
||||||
|
- `CODE_OF_CONDUCT.md` recomendado en la raíz (Contributor Covenant oficial).
|
||||||
|
|
||||||
|
## 6. Despliegue gratuito
|
||||||
|
|
||||||
|
Opción recomendada: **Cloudflare Pages** o **GitHub Pages**.
|
||||||
|
|
||||||
|
Para GitHub Pages con Vite:
|
||||||
|
1. Settings → Pages → Build and deployment → Source: **GitHub Actions**.
|
||||||
|
2. Añade un workflow en `.github/workflows/deploy.yml` (no incluido en el
|
||||||
|
prototipo para mantenerlo mínimo; añadirlo cuando se decida la URL final).
|
||||||
|
3. Ajusta `base` en `vite.config.js` al path del repo.
|
||||||
|
|
||||||
|
## 7. Etiquetas iniciales sugeridas
|
||||||
|
|
||||||
|
Crea en Issues → Labels:
|
||||||
|
- `good first issue` (verde)
|
||||||
|
- `bug` (rojo)
|
||||||
|
- `enhancement` (azul)
|
||||||
|
- `documentation` (gris)
|
||||||
|
- `discussion` (morado)
|
||||||
|
|
||||||
|
## 8. Discussions
|
||||||
|
|
||||||
|
Activa Discussions con las categorías: Announcements, Ideas, Q&A. Sirven como
|
||||||
|
foro de baja fricción para la comunidad.
|
||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
# CI/CD — despliegue por rama
|
||||||
|
|
||||||
|
Cada rama se despliega automáticamente en `https://<rama>.localesp.es`.
|
||||||
|
|
||||||
|
## Cómo funciona
|
||||||
|
|
||||||
|
```
|
||||||
|
push a rama X
|
||||||
|
│ Gitea Actions (workflow: .gitea/workflows/deploy.yml)
|
||||||
|
▼
|
||||||
|
runner self-hosted (host executor, en el propio VPS)
|
||||||
|
│ npm ci → vite build → scripts/deploy.sh <rama>
|
||||||
|
▼
|
||||||
|
/opt/localesp/<slug>/ código + dist/ + node_modules (prod)
|
||||||
|
systemd localesp-<slug>.service node server/index.js (PORT=80xx)
|
||||||
|
nginx <slug>.localesp.es proxy_pass → localhost:80xx
|
||||||
|
certbot *.localesp.es Let's Encrypt (si el DNS ya apunta)
|
||||||
|
```
|
||||||
|
|
||||||
|
El **slug** se deriva del nombre de rama (`Feature/Foo` → `feature-foo`),
|
||||||
|
válido como hostname DNS. Un entorno = un subdirectorio bajo `/opt/localesp/`,
|
||||||
|
un servicio, un vhost y un puerto. La base de datos `localesp.db` de cada
|
||||||
|
entorno **se conserva** entre despliegues (no se sobreescribe).
|
||||||
|
|
||||||
|
Puertos: los históricos son `8080` (prototipo-yb01) y `8081` (master); los
|
||||||
|
nuevos se asignan desde `8082` hacia arriba. Si la rama ya tiene su
|
||||||
|
`localesp-<slug>.service`, se reutiliza su puerto (adopta el entorno existente).
|
||||||
|
|
||||||
|
## Precondición manual: el registro DNS
|
||||||
|
|
||||||
|
El registro A de `<rama>.localesp.es` **se crea a mano** (no está automatizado).
|
||||||
|
|
||||||
|
1. En el proveedor DNS de `localesp.es`, añade un registro A:
|
||||||
|
`<rama>.localesp.es A <IP-pública-del-VPS>`
|
||||||
|
2. Espera a que propague (`dig +short <rama>.localesp.es`).
|
||||||
|
3. Lanza el workflow (push a la rama, o *Actions → Run workflow* en Gitea).
|
||||||
|
|
||||||
|
Mientras el DNS no apunte al VPS, `deploy.sh` despliega la app igualmente y la
|
||||||
|
sirve por **HTTP**; emite el cert Let's Encrypt en cuanto detecta que el DNS ya
|
||||||
|
resuelve. No hay que tocar nada más.
|
||||||
|
|
||||||
|
## Añadir una rama nueva
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b mi-feature
|
||||||
|
# ... cambios ...
|
||||||
|
git push -u origin mi-feature
|
||||||
|
```
|
||||||
|
|
||||||
|
El workflow corre solo. Si quieres URL pública: crea el registro A (arriba).
|
||||||
|
Si no, el entorno existe pero no es alcanzable por hostname (útil para tests internos
|
||||||
|
si añades el host a `/etc/hosts`).
|
||||||
|
|
||||||
|
## Borrar una rama
|
||||||
|
|
||||||
|
Al borrar la rama en Gitea, el job `teardown` limpia el entorno (unit systemd,
|
||||||
|
vhost, cert y `/opt/localesp-<slug>`). También manual:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh localesp.jumpingcrab.com 'bash /opt/localesp-master/scripts/teardown.sh mi-feature'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entornos existentes (ya migrados al layout unificado)
|
||||||
|
|
||||||
|
Los entornos legacy ya están reubicados bajo `/opt/localesp/<rama>` con sus
|
||||||
|
systemd units renombradas al esquema `localesp-<slug>.service`, de modo que el
|
||||||
|
CI los **adopta** sin cambios (reutiliza puerto y vhost):
|
||||||
|
|
||||||
|
| Rama | Dir | Unit | Puerto | Hosts |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `master` | `/opt/localesp/master` | `localesp-master.service` | 8081 | `localesp.es`, `master.localesp.es` |
|
||||||
|
| `prototipo-yb01` | `/opt/localesp/prototipo-yb01` | `localesp-prototipo-yb01.service` | 8080 | `prototipo-yb01.localesp.es` |
|
||||||
|
|
||||||
|
El antiguo `localesp.service` (sin slug) se retiró; ahora todas las units
|
||||||
|
siguen el patrón `localesp-<slug>.service`. La db de cada entorno se preservó.
|
||||||
|
|
||||||
|
## Seguridad
|
||||||
|
|
||||||
|
El runner (`gitea-runner`) se ejecuta como **root en el VPS de producción** y en
|
||||||
|
modo **host** (sin contenedor): cualquier workflow que se ejecute corresponde a
|
||||||
|
código del repo corriendo con privilegios de root en la caja. Esto es aceptable
|
||||||
|
mientras el repo sea propio / de baja colaboración. Si entran colaboradores
|
||||||
|
externos, conviene migrar a:
|
||||||
|
|
||||||
|
- runner en contenedor + despliegue por SSH (clave como secret de Gitea), o
|
||||||
|
- *ephemeral runners* (`gitea-runner register --ephemeral`).
|
||||||
|
|
||||||
|
## Evolución: estrategia B (wildcard)
|
||||||
|
|
||||||
|
Para eliminar la precondición manual del DNS y el cert por rama, más adelante:
|
||||||
|
|
||||||
|
1. Registro **wildcard `*.localesp.es`** (DNS).
|
||||||
|
2. **Wildcard cert** `*.localesp.es` vía challenge DNS-01 (una sola vez).
|
||||||
|
3. Un único vhost `*.localesp.es` que enruta por `Host` con un `map` rama→puerto.
|
||||||
|
|
||||||
|
`deploy.sh` apenas cambia: se caen los pasos de creación de vhost y certbot.
|
||||||
|
Nada de lo hecho aquí se pierde.
|
||||||
|
|
||||||
|
## Infra del runner (referencia)
|
||||||
|
|
||||||
|
Instalado en el VPS (`localesp.jumpingcrab.com`):
|
||||||
|
|
||||||
|
- Binario: `/usr/local/bin/gitea-runner` (v2.2.0), host executor.
|
||||||
|
**Cadena de suministro verificada**: el binario coincide (sha256 `d6b3f5bb…`)
|
||||||
|
con el descomprimido del release oficial `gitea-runner-2.2.0-linux-amd64.xz`, cuya suma
|
||||||
|
`13357e0d…` está publicada en el release. Gitea no publica firma GPG del runner; la suma
|
||||||
|
es la garantía máxima disponible. No hay paquete `dnf` oficial (solo binario o Docker image).
|
||||||
|
- Config: `/etc/gitea-runner/config.yaml` — etiquetas `self-hosted:host`, `linux:host`.
|
||||||
|
- Registro: `/var/lib/gitea-runner/.runner` (instance-level, `https://git.localesp.es`).
|
||||||
|
- Servicio: `gitea-runner.service` (systemd, `User=root`, `WorkingDirectory=/var/lib/gitea-runner`).
|
||||||
|
|
||||||
|
Comprobar estado: `systemctl status gitea-runner` · `journalctl -u gitea-runner -f`.
|
||||||
|
Ver runners en Gitea: *Site administration → Actions → Runners*.
|
||||||
+6
-35
@@ -3,41 +3,12 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Locales Españoles</title>
|
<title>Business Map</title>
|
||||||
<meta name="description" content="Directorio colaborativo de negocios por toda España" />
|
<link
|
||||||
<!-- No indexar el admin -->
|
rel="stylesheet"
|
||||||
<meta name="robots" content="index, follow" />
|
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||||
<!-- Tabler Icons -->
|
crossorigin=""
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3.11.0/dist/tabler-icons.min.css" />
|
/>
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
||||||
--color-background-primary: #ffffff;
|
|
||||||
--color-background-secondary: #f5f5f7;
|
|
||||||
--color-text-primary: #1d1d1f;
|
|
||||||
--color-text-secondary: #6e6e73;
|
|
||||||
--color-text-tertiary: #aeaeb2;
|
|
||||||
--color-text-info: #1a73e8;
|
|
||||||
--color-border-secondary: #d2d2d7;
|
|
||||||
--color-border-tertiary: #e5e5ea;
|
|
||||||
--border-radius-md: 8px;
|
|
||||||
--border-radius-lg: 12px;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--color-background-primary: #1c1c1e;
|
|
||||||
--color-background-secondary: #2c2c2e;
|
|
||||||
--color-text-primary: #f5f5f7;
|
|
||||||
--color-text-secondary: #aeaeb2;
|
|
||||||
--color-text-tertiary: #636366;
|
|
||||||
--color-border-secondary: #3a3a3c;
|
|
||||||
--color-border-tertiary: #2c2c2e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body { margin: 0; background: var(--color-background-secondary); color: var(--color-text-primary); font-family: var(--font-sans); min-height: 100vh; }
|
|
||||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
[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
|
|
||||||
Generated
+255
-317
@@ -1,22 +1,24 @@
|
|||||||
{
|
{
|
||||||
"name": "locales-espanoles",
|
"name": "business-map",
|
||||||
"version": "1.0.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "locales-espanoles",
|
"name": "business-map",
|
||||||
"version": "1.0.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.0.0",
|
"better-sqlite3": "^12.10.0",
|
||||||
"express": "^5.0.0",
|
"express": "^5.2.1",
|
||||||
"react": "^18.2.0",
|
"leaflet": "^1.9.4",
|
||||||
"react-dom": "^18.2.0"
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-leaflet": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"concurrently": "^8.0.0",
|
"concurrently": "^10.0.0",
|
||||||
"vite": "^5.2.0"
|
"vite": "^5.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
@@ -253,16 +255,6 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@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": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||||
@@ -752,6 +744,17 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -760,9 +763,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
|
||||||
"integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==",
|
"integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -774,9 +777,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
|
||||||
"integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==",
|
"integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -788,9 +791,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
|
||||||
"integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==",
|
"integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -802,9 +805,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
|
||||||
"integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==",
|
"integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -816,9 +819,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz",
|
||||||
"integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==",
|
"integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -830,9 +833,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz",
|
||||||
"integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==",
|
"integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -844,9 +847,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz",
|
||||||
"integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==",
|
"integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -861,9 +864,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz",
|
||||||
"integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==",
|
"integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -878,9 +881,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz",
|
||||||
"integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==",
|
"integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -895,9 +898,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz",
|
||||||
"integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==",
|
"integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -912,9 +915,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz",
|
||||||
"integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==",
|
"integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -929,9 +932,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz",
|
||||||
"integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==",
|
"integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -946,9 +949,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz",
|
||||||
"integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==",
|
"integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -963,9 +966,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz",
|
||||||
"integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==",
|
"integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -980,9 +983,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz",
|
||||||
"integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==",
|
"integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -997,9 +1000,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz",
|
||||||
"integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==",
|
"integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1014,9 +1017,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz",
|
||||||
"integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==",
|
"integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -1031,9 +1034,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz",
|
||||||
"integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==",
|
"integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1048,9 +1051,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz",
|
||||||
"integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==",
|
"integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1065,9 +1068,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz",
|
||||||
"integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==",
|
"integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1079,9 +1082,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz",
|
||||||
"integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==",
|
"integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1093,9 +1096,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz",
|
||||||
"integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==",
|
"integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1107,9 +1110,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz",
|
||||||
"integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==",
|
"integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -1121,9 +1124,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz",
|
||||||
"integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==",
|
"integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1135,9 +1138,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz",
|
||||||
"integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==",
|
"integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1194,9 +1197,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -1235,26 +1238,26 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ansi-regex": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "5.0.1",
|
"version": "6.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ansi-styles": {
|
"node_modules/ansi-styles": {
|
||||||
"version": "4.3.0",
|
"version": "6.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
|
||||||
"color-convert": "^2.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
@@ -1281,9 +1284,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.34",
|
"version": "2.10.33",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz",
|
||||||
"integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==",
|
"integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -1448,9 +1451,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001797",
|
"version": "1.0.30001793",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
|
||||||
"integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==",
|
"integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1469,35 +1472,18 @@
|
|||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "5.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
|
||||||
"ansi-styles": "^4.1.0",
|
|
||||||
"supports-color": "^7.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"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": {
|
"node_modules/chownr": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||||
@@ -1505,63 +1491,40 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/cliui": {
|
"node_modules/cliui": {
|
||||||
"version": "8.0.1",
|
"version": "9.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
|
||||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"string-width": "^4.2.0",
|
"string-width": "^7.2.0",
|
||||||
"strip-ansi": "^6.0.1",
|
"strip-ansi": "^7.1.0",
|
||||||
"wrap-ansi": "^7.0.0"
|
"wrap-ansi": "^9.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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": {
|
|
||||||
"color-name": "~1.1.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"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": {
|
"node_modules/concurrently": {
|
||||||
"version": "8.2.2",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.0.tgz",
|
||||||
"integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
|
"integrity": "sha512-DRrk10z3sVPpguNe8od2cGNqZGqbT15rwAnxD4dG3b78mdNNb/gJyr8T834Oj518WcBmTktrt4FhdwZn09ZWSg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "^4.1.2",
|
"chalk": "5.6.2",
|
||||||
"date-fns": "^2.30.0",
|
"rxjs": "7.8.2",
|
||||||
"lodash": "^4.17.21",
|
"shell-quote": "1.8.4",
|
||||||
"rxjs": "^7.8.1",
|
"supports-color": "10.2.2",
|
||||||
"shell-quote": "^1.8.1",
|
"tree-kill": "1.2.2",
|
||||||
"spawn-command": "0.0.2",
|
"yargs": "18.0.0"
|
||||||
"supports-color": "^8.1.1",
|
|
||||||
"tree-kill": "^1.2.2",
|
|
||||||
"yargs": "^17.7.2"
|
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"conc": "dist/bin/concurrently.js",
|
"conc": "dist/bin/index.js",
|
||||||
"concurrently": "dist/bin/concurrently.js"
|
"concurrently": "dist/bin/index.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^14.13.0 || >=16.0.0"
|
"node": ">=22"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||||
@@ -1614,23 +1577,6 @@
|
|||||||
"node": ">=6.6.0"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -1711,16 +1657,16 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.368",
|
"version": "1.5.364",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz",
|
||||||
"integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==",
|
"integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/emoji-regex": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "8.0.0",
|
"version": "10.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -1983,6 +1929,19 @@
|
|||||||
"node": "6.* || 8.* || >= 10.*"
|
"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": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -2038,16 +1997,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/has-symbols": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
@@ -2149,16 +2098,6 @@
|
|||||||
"node": ">= 0.10"
|
"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": {
|
"node_modules/is-promise": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||||
@@ -2197,12 +2136,11 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/leaflet": {
|
||||||
"version": "4.18.1",
|
"version": "1.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
"dev": true,
|
"license": "BSD-2-Clause"
|
||||||
"license": "MIT"
|
|
||||||
},
|
},
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
@@ -2361,9 +2299,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-abi/node_modules/semver": {
|
"node_modules/node-abi/node_modules/semver": {
|
||||||
"version": "7.8.2",
|
"version": "7.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||||
"integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
|
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
@@ -2373,9 +2311,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.47",
|
"version": "2.0.46",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz",
|
||||||
"integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==",
|
"integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2599,6 +2537,20 @@
|
|||||||
"react": "^18.3.1"
|
"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": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
@@ -2623,24 +2575,14 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.61.1",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
|
||||||
"integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==",
|
"integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.9"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"rollup": "dist/bin/rollup"
|
"rollup": "dist/bin/rollup"
|
||||||
@@ -2650,31 +2592,31 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.61.1",
|
"@rollup/rollup-android-arm-eabi": "4.60.4",
|
||||||
"@rollup/rollup-android-arm64": "4.61.1",
|
"@rollup/rollup-android-arm64": "4.60.4",
|
||||||
"@rollup/rollup-darwin-arm64": "4.61.1",
|
"@rollup/rollup-darwin-arm64": "4.60.4",
|
||||||
"@rollup/rollup-darwin-x64": "4.61.1",
|
"@rollup/rollup-darwin-x64": "4.60.4",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.61.1",
|
"@rollup/rollup-freebsd-arm64": "4.60.4",
|
||||||
"@rollup/rollup-freebsd-x64": "4.61.1",
|
"@rollup/rollup-freebsd-x64": "4.60.4",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.61.1",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.61.1",
|
"@rollup/rollup-linux-arm-musleabihf": "4.60.4",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.61.1",
|
"@rollup/rollup-linux-arm64-gnu": "4.60.4",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.61.1",
|
"@rollup/rollup-linux-arm64-musl": "4.60.4",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.61.1",
|
"@rollup/rollup-linux-loong64-gnu": "4.60.4",
|
||||||
"@rollup/rollup-linux-loong64-musl": "4.61.1",
|
"@rollup/rollup-linux-loong64-musl": "4.60.4",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.61.1",
|
"@rollup/rollup-linux-ppc64-gnu": "4.60.4",
|
||||||
"@rollup/rollup-linux-ppc64-musl": "4.61.1",
|
"@rollup/rollup-linux-ppc64-musl": "4.60.4",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.61.1",
|
"@rollup/rollup-linux-riscv64-gnu": "4.60.4",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.61.1",
|
"@rollup/rollup-linux-riscv64-musl": "4.60.4",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.61.1",
|
"@rollup/rollup-linux-s390x-gnu": "4.60.4",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.61.1",
|
"@rollup/rollup-linux-x64-gnu": "4.60.4",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.61.1",
|
"@rollup/rollup-linux-x64-musl": "4.60.4",
|
||||||
"@rollup/rollup-openbsd-x64": "4.61.1",
|
"@rollup/rollup-openbsd-x64": "4.60.4",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.61.1",
|
"@rollup/rollup-openharmony-arm64": "4.60.4",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.61.1",
|
"@rollup/rollup-win32-arm64-msvc": "4.60.4",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.61.1",
|
"@rollup/rollup-win32-ia32-msvc": "4.60.4",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.61.1",
|
"@rollup/rollup-win32-x64-gnu": "4.60.4",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.61.1",
|
"@rollup/rollup-win32-x64-msvc": "4.60.4",
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2940,12 +2882,6 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -2965,31 +2901,37 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emoji-regex": "^8.0.0",
|
"emoji-regex": "^10.3.0",
|
||||||
"is-fullwidth-code-point": "^3.0.0",
|
"get-east-asian-width": "^1.0.0",
|
||||||
"strip-ansi": "^6.0.1"
|
"strip-ansi": "^7.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/strip-ansi": {
|
"node_modules/strip-ansi": {
|
||||||
"version": "6.0.1",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^6.2.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/strip-json-comments": {
|
"node_modules/strip-json-comments": {
|
||||||
@@ -3002,16 +2944,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "8.1.1",
|
"version": "10.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
|
||||||
"has-flag": "^4.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||||
@@ -3230,18 +3169,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wrap-ansi": {
|
"node_modules/wrap-ansi": {
|
||||||
"version": "7.0.0",
|
"version": "9.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^4.0.0",
|
"ansi-styles": "^6.2.1",
|
||||||
"string-width": "^4.1.0",
|
"string-width": "^7.0.0",
|
||||||
"strip-ansi": "^6.0.0"
|
"strip-ansi": "^7.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
@@ -3271,32 +3210,31 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/yargs": {
|
"node_modules/yargs": {
|
||||||
"version": "17.7.2",
|
"version": "18.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
|
||||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cliui": "^8.0.1",
|
"cliui": "^9.0.1",
|
||||||
"escalade": "^3.1.1",
|
"escalade": "^3.1.1",
|
||||||
"get-caller-file": "^2.0.5",
|
"get-caller-file": "^2.0.5",
|
||||||
"require-directory": "^2.1.1",
|
"string-width": "^7.2.0",
|
||||||
"string-width": "^4.2.3",
|
|
||||||
"y18n": "^5.0.5",
|
"y18n": "^5.0.5",
|
||||||
"yargs-parser": "^21.1.1"
|
"yargs-parser": "^22.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/yargs-parser": {
|
"node_modules/yargs-parser": {
|
||||||
"version": "21.1.1",
|
"version": "22.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
|
||||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-12
@@ -1,24 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "locales-espanoles",
|
"name": "business-map",
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"server": "node server/index.js",
|
"server": "node server/index.js",
|
||||||
"dev:all": "concurrently -k -n vite,api -c cyan,green \"npm run dev\" \"npm run server\""
|
"dev:all": "concurrently -k -n vite,api -c cyan,green \"npm run dev\" \"npm run server\"",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.2.0",
|
"better-sqlite3": "^12.10.0",
|
||||||
"react-dom": "^18.2.0",
|
"express": "^5.2.1",
|
||||||
"better-sqlite3": "^12.0.0",
|
"leaflet": "^1.9.4",
|
||||||
"express": "^5.0.0"
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-leaflet": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"vite": "^5.2.0",
|
"concurrently": "^10.0.0",
|
||||||
"concurrently": "^8.0.0"
|
"vite": "^5.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+33
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# deploy.sh — Despliega la rama actual a <slug>.localesp.es
|
||||||
|
# Toda la lógica vive en lib/env.sh (modelo OOP). Éste es sólo el entrypoint.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=lib/env.sh
|
||||||
|
. "$SCRIPT_DIR/lib/env.sh"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "usage: $0 <branch>" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local branch root
|
||||||
|
|
||||||
|
branch="${1:-${GITHUB_REF_NAME:-}}"
|
||||||
|
if [ -z "$branch" ]; then
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
root="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
Logger::set_tag deploy
|
||||||
|
Env::new env "$branch" "$root"
|
||||||
|
Logger::info "$(Env::banner env)"
|
||||||
|
|
||||||
|
Env::deploy env
|
||||||
|
|
||||||
|
Logger::info "LISTO: $(Env::url env) ($(Env::summary env))"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# lib/env.sh — Modelo OOP de entornos de previsualización por rama.
|
||||||
|
#
|
||||||
|
# Estrategia (entorno por rama):
|
||||||
|
# /opt/localesp/<slug> + systemd unit localesp-<slug> + vhost nginx + cert Let's Encrypt.
|
||||||
|
# Reutiliza el puerto de la unit existente (adopta entornos legacy) o asigna uno libre (>=8082).
|
||||||
|
#
|
||||||
|
# PRECONDICIÓN MANUAL: el registro A <slug>.localesp.es debe existir (se gestiona a mano).
|
||||||
|
# Si no resuelve aún, la app queda servida por HTTP y se emite el cert cuando el DNS apunte aquí.
|
||||||
|
#
|
||||||
|
# Representación OOP en bash:
|
||||||
|
# - Instancia = array asociativo global, referenciado por NOMBRE.
|
||||||
|
# - Constructor = Class::new <nombre> <args> (declara el array y establece invariantes).
|
||||||
|
# - Método = Class::method <nombre> <args>; accede al estado vía `local -n self="$1"`.
|
||||||
|
# - Composición = Env posee los nombres de sus colaboradores (<env>__unit, __vhost, __cert).
|
||||||
|
# - Servicios sin estado (Slug, Host, Port, Artifacts, Deps, Logger) = métodos estáticos.
|
||||||
|
#
|
||||||
|
# Requiere bash >= 4.3 (namerefs). Sin efectos al sourcear: sólo define funciones/constantes.
|
||||||
|
#
|
||||||
|
# CONTRATO (importante): el NOMBRE de instancia pasado a Env::new (p.ej. "env") es el
|
||||||
|
# nombre de un array asociativo global. NUNCA declares en el caller una `local` con ese
|
||||||
|
# mismo nombre: bash usa ámbito dinámico y la local sombrearía al array global, rompiendo
|
||||||
|
# el nameref `local -n self=env`. Pasa el nombre como literal: Env::new env ...
|
||||||
|
|
||||||
|
DOMAIN="localesp.es"
|
||||||
|
PORT_BASE=8082 # 8080/8081 son entornos legacy
|
||||||
|
LOGGER_TAG="env"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Logger — servicio singleton (tag configurable según entrypoint).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Logger::set_tag() {
|
||||||
|
LOGGER_TAG="$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger::info() {
|
||||||
|
printf '\033[1;34m[%s]\033[0m %s\n' "$LOGGER_TAG" "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger::error() {
|
||||||
|
printf '\033[1;31m[%s:ERROR]\033[0m %s\n' "$LOGGER_TAG" "$*" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Slug — value object estático: normaliza un nombre de rama a slug.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Slug::from() {
|
||||||
|
local branch="$1"
|
||||||
|
printf '%s' "$branch" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Host — servicio estático: IP pública y resolución DNS.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Host::public_ip() {
|
||||||
|
curl -s4 --max-time 5 ifconfig.me || true
|
||||||
|
}
|
||||||
|
|
||||||
|
Host::resolved_ip() {
|
||||||
|
local host="$1"
|
||||||
|
getent hosts "$host" | awk '{print $1}' | head -1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Port — servicio estático: asigna/reutiliza puerto para una unit+dest.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Port::acquire() {
|
||||||
|
local unit="$1"
|
||||||
|
local dest="$2"
|
||||||
|
local unit_file="/etc/systemd/system/$unit.service"
|
||||||
|
local used
|
||||||
|
local port
|
||||||
|
|
||||||
|
# Adopta entorno existente: reutiliza el puerto de la unit ya instalada.
|
||||||
|
if [ -f "$unit_file" ]; then
|
||||||
|
port="$(grep -oE 'PORT=[0-9]+' "$unit_file" | head -1 | cut -d= -f2 || true)"
|
||||||
|
if [ -n "$port" ]; then
|
||||||
|
echo "$port"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -f "$dest/.port" ]; then
|
||||||
|
cat "$dest/.port"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
used="$( { grep -rhoE 'PORT=[0-9]+' /etc/systemd/system/localesp*.service 2>/dev/null | cut -d= -f2
|
||||||
|
grep -rhoE 'localhost:[0-9]+' /etc/nginx/conf.d/*.conf 2>/dev/null | cut -d: -f2; } | sort -un)"
|
||||||
|
port="$PORT_BASE"
|
||||||
|
while printf '%s\n' "$used" | grep -qx "$port"; do
|
||||||
|
port=$((port+1))
|
||||||
|
done
|
||||||
|
echo "$port"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Artifacts — servicio estático: artefactos buildados.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Artifacts::require_built() {
|
||||||
|
local root="$1"
|
||||||
|
|
||||||
|
if [ ! -d "$root/dist" ]; then
|
||||||
|
Logger::error "falta dist/ — ¿se ejecutó el build?"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
Artifacts::sync() {
|
||||||
|
local root="$1"
|
||||||
|
local dest="$2"
|
||||||
|
|
||||||
|
mkdir -p "$dest/dist" "$dest/server"
|
||||||
|
rsync -a --delete "$root/dist/" "$dest/dist/"
|
||||||
|
rsync -a --delete "$root/server/" "$dest/server/"
|
||||||
|
cp -a "$root/package.json" "$root/package-lock.json" "$dest/"
|
||||||
|
# localesp.db se PRESERVA (datos de usuarios). No se toca aquí.
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Deps — servicio estático: dependencias de producción.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Deps::install() {
|
||||||
|
local root="$1"
|
||||||
|
local dest="$2"
|
||||||
|
|
||||||
|
# Reutiliza los node_modules ya compilados por el workflow (better-sqlite3 v12 exige
|
||||||
|
# C++20, GCC 12). Copiar + npm prune evita recompilar en cada despliegue.
|
||||||
|
# Fallback a npm ci --omit=dev si se ejecuta a mano sin build previo.
|
||||||
|
if [ -d "$root/node_modules" ]; then
|
||||||
|
mkdir -p "$dest/node_modules"
|
||||||
|
rsync -a --delete "$root/node_modules/" "$dest/node_modules/"
|
||||||
|
( cd "$dest" && npm prune --omit=dev --no-audit --no-fund )
|
||||||
|
else
|
||||||
|
( cd "$dest" && npm ci --omit=dev --no-audit --no-fund )
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SystemdUnit — entidad: fichero .service de un entorno.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SystemdUnit::new() {
|
||||||
|
local self="$1"
|
||||||
|
local name="$2"
|
||||||
|
local dest="$3"
|
||||||
|
local branch="$4"
|
||||||
|
local host="$5"
|
||||||
|
local port="$6"
|
||||||
|
|
||||||
|
declare -gA "$self"
|
||||||
|
local -n u="$self"
|
||||||
|
u[name]="$name"
|
||||||
|
u[dest]="$dest"
|
||||||
|
u[branch]="$branch"
|
||||||
|
u[host]="$host"
|
||||||
|
u[port]="$port"
|
||||||
|
u[file]="/etc/systemd/system/${name}.service"
|
||||||
|
}
|
||||||
|
|
||||||
|
SystemdUnit::render() {
|
||||||
|
local self="$1"
|
||||||
|
local -n u="$self"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=LocaleSP env rama '${u[branch]}' (${u[host]})
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=${u[dest]}
|
||||||
|
ExecStart=/usr/bin/node server/index.js
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
Environment=PORT=${u[port]}
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
SystemdUnit::write() {
|
||||||
|
local self="$1"
|
||||||
|
local -n u="$self"
|
||||||
|
|
||||||
|
Logger::info "escribiendo unit ${u[name]} (PORT=${u[port]})"
|
||||||
|
SystemdUnit::render "$self" > "${u[file]}"
|
||||||
|
echo "${u[port]}" > "${u[dest]}/.port"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "${u[name]}" >/dev/null 2>&1 || true
|
||||||
|
systemctl restart "${u[name]}"
|
||||||
|
Logger::info "${u[name]} arrancada"
|
||||||
|
}
|
||||||
|
|
||||||
|
SystemdUnit::remove() {
|
||||||
|
local self="$1"
|
||||||
|
local -n u="$self"
|
||||||
|
|
||||||
|
systemctl disable --now "${u[name]}" 2>/dev/null || true
|
||||||
|
rm -f "${u[file]}"
|
||||||
|
systemctl daemon-reload
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# NginxVhost — entidad: vhost nginx de un entorno.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
NginxVhost::new() {
|
||||||
|
local self="$1"
|
||||||
|
local slug="$2"
|
||||||
|
local host="$3"
|
||||||
|
local port="$4"
|
||||||
|
|
||||||
|
declare -gA "$self"
|
||||||
|
local -n v="$self"
|
||||||
|
v[slug]="$slug"
|
||||||
|
v[host]="$host"
|
||||||
|
v[port]="$port"
|
||||||
|
}
|
||||||
|
|
||||||
|
NginxVhost::find_path() {
|
||||||
|
local self="$1"
|
||||||
|
local -n v="$self"
|
||||||
|
local host_re
|
||||||
|
host_re="$(printf '%s' "${v[host]}" | sed 's/\./\\./g')"
|
||||||
|
grep -rlE "server_name[[:space:]]+$host_re[[:space:]]*;" /etc/nginx/conf.d/*.conf 2>/dev/null | head -1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
NginxVhost::render() {
|
||||||
|
local self="$1"
|
||||||
|
local -n v="$self"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name ${v[host]};
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:${v[port]};
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
NginxVhost::create() {
|
||||||
|
local self="$1"
|
||||||
|
local -n v="$self"
|
||||||
|
local path="/etc/nginx/conf.d/${v[slug]}.conf"
|
||||||
|
|
||||||
|
Logger::info "creando vhost HTTP $path"
|
||||||
|
NginxVhost::render "$self" > "$path"
|
||||||
|
}
|
||||||
|
|
||||||
|
NginxVhost::repoint() {
|
||||||
|
local self="$1"
|
||||||
|
local -n v="$self"
|
||||||
|
local path="$2"
|
||||||
|
|
||||||
|
Logger::info "vhost existente: $path — forzando proxy_pass -> :${v[port]}"
|
||||||
|
sed -i -E "s|proxy_pass http://localhost:[0-9]+;|proxy_pass http://localhost:${v[port]};|g" "$path"
|
||||||
|
}
|
||||||
|
|
||||||
|
NginxVhost::configure() {
|
||||||
|
local self="$1"
|
||||||
|
local existing
|
||||||
|
|
||||||
|
existing="$(NginxVhost::find_path "$self")"
|
||||||
|
if [ -z "$existing" ]; then
|
||||||
|
NginxVhost::create "$self"
|
||||||
|
else
|
||||||
|
NginxVhost::repoint "$self" "$existing"
|
||||||
|
fi
|
||||||
|
nginx -t 2>&1 | tail -2
|
||||||
|
systemctl reload nginx
|
||||||
|
Logger::info "nginx recargado"
|
||||||
|
}
|
||||||
|
|
||||||
|
NginxVhost::remove() {
|
||||||
|
local self="$1"
|
||||||
|
local path
|
||||||
|
|
||||||
|
path="$(NginxVhost::find_path "$self")"
|
||||||
|
if [ -n "$path" ]; then
|
||||||
|
Logger::info "borrando vhost $path"
|
||||||
|
rm -f "$path"
|
||||||
|
nginx -t && systemctl reload nginx
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TlsCert — entidad: certificado Let's Encrypt de un entorno.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
TlsCert::new() {
|
||||||
|
local self="$1"
|
||||||
|
local host="$2"
|
||||||
|
|
||||||
|
declare -gA "$self"
|
||||||
|
local -n c="$self"
|
||||||
|
c[host]="$host"
|
||||||
|
}
|
||||||
|
|
||||||
|
TlsCert::exists() {
|
||||||
|
local self="$1"
|
||||||
|
local -n c="$self"
|
||||||
|
[ -d "/etc/letsencrypt/live/${c[host]}" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
TlsCert::ensure() {
|
||||||
|
local self="$1"
|
||||||
|
local -n c="$self"
|
||||||
|
local host="${c[host]}"
|
||||||
|
local pub_ip resolved
|
||||||
|
|
||||||
|
if TlsCert::exists "$self"; then
|
||||||
|
Logger::info "cert TLS ya presente para $host"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
Logger::info "comprobando DNS de $host"
|
||||||
|
pub_ip="$(Host::public_ip)"
|
||||||
|
resolved="$(Host::resolved_ip "$host")"
|
||||||
|
|
||||||
|
if [ -n "$pub_ip" ] && [ "$resolved" = "$pub_ip" ]; then
|
||||||
|
Logger::info "emitiendo cert con certbot --nginx"
|
||||||
|
if certbot --nginx -d "$host" -n --redirect --keep-until-expiring; then
|
||||||
|
Logger::info "cert emitido ✓"
|
||||||
|
else
|
||||||
|
Logger::error "certbot falló; $host sigue en HTTP. Revisa y vuelve a lanzar el workflow."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
Logger::error "DNS de $host -> '${resolved:-<sin resolver>}', esperado $pub_ip."
|
||||||
|
Logger::error "Crea el registro A $host -> $pub_ip y, tras propagar, re-lanza el workflow"
|
||||||
|
Logger::error "(o ejecuta: certbot --nginx -d $host). La app ya vive en http://$host"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
TlsCert::delete() {
|
||||||
|
local self="$1"
|
||||||
|
local -n c="$self"
|
||||||
|
local host="${c[host]}"
|
||||||
|
|
||||||
|
if TlsCert::exists "$self"; then
|
||||||
|
Logger::info "borrando cert $host"
|
||||||
|
certbot delete --cert-name "$host" -n 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
TlsCert::protocol() {
|
||||||
|
local self="$1"
|
||||||
|
|
||||||
|
if TlsCert::exists "$self"; then
|
||||||
|
echo "https"
|
||||||
|
else
|
||||||
|
echo "http"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Env — aggregate root: un entorno de rama. Compone SystemdUnit, NginxVhost, TlsCert.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Env::new() {
|
||||||
|
local self="$1"
|
||||||
|
local branch="$2"
|
||||||
|
local root="$3"
|
||||||
|
|
||||||
|
declare -gA "$self"
|
||||||
|
local -n s="$self"
|
||||||
|
|
||||||
|
s[branch]="$branch"
|
||||||
|
s[root]="$root"
|
||||||
|
s[slug]="$(Slug::from "$branch")"
|
||||||
|
if [ -z "${s[slug]}" ]; then
|
||||||
|
Logger::error "slug vacío para la rama '$branch'"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
s[host]="${s[slug]}.$DOMAIN"
|
||||||
|
s[dest]="/opt/localesp/${s[slug]}"
|
||||||
|
s[unit_name]="localesp-${s[slug]}"
|
||||||
|
s[port]="$(Port::acquire "${s[unit_name]}" "${s[dest]}")"
|
||||||
|
|
||||||
|
# Composición: crea los colaboradores y guarda sus nombres en el agregado.
|
||||||
|
SystemdUnit::new "${self}__unit" "${s[unit_name]}" "${s[dest]}" "$branch" "${s[host]}" "${s[port]}"
|
||||||
|
NginxVhost::new "${self}__vhost" "${s[slug]}" "${s[host]}" "${s[port]}"
|
||||||
|
TlsCert::new "${self}__cert" "${s[host]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
Env::deploy() {
|
||||||
|
local self="$1"
|
||||||
|
local -n s="$self"
|
||||||
|
|
||||||
|
Artifacts::require_built "${s[root]}"
|
||||||
|
Logger::info "sincronizando artefactos -> ${s[dest]}"
|
||||||
|
Artifacts::sync "${s[root]}" "${s[dest]}"
|
||||||
|
|
||||||
|
Logger::info "instalando dependencias (prod)"
|
||||||
|
Deps::install "${s[root]}" "${s[dest]}"
|
||||||
|
|
||||||
|
SystemdUnit::write "${self}__unit"
|
||||||
|
|
||||||
|
Logger::info "configurando nginx para ${s[host]}"
|
||||||
|
NginxVhost::configure "${self}__vhost"
|
||||||
|
|
||||||
|
TlsCert::ensure "${self}__cert"
|
||||||
|
}
|
||||||
|
|
||||||
|
Env::teardown() {
|
||||||
|
local self="$1"
|
||||||
|
local -n s="$self"
|
||||||
|
|
||||||
|
SystemdUnit::remove "${self}__unit"
|
||||||
|
TlsCert::delete "${self}__cert"
|
||||||
|
NginxVhost::remove "${self}__vhost"
|
||||||
|
rm -rf "${s[dest]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
Env::url() {
|
||||||
|
local self="$1"
|
||||||
|
local -n s="$self"
|
||||||
|
local proto
|
||||||
|
|
||||||
|
proto="$(TlsCert::protocol "${self}__cert")"
|
||||||
|
echo "$proto://${s[host]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
Env::banner() {
|
||||||
|
local self="$1"
|
||||||
|
local -n s="$self"
|
||||||
|
|
||||||
|
echo "rama=${s[branch]} slug=${s[slug]} host=${s[host]} dest=${s[dest]} puerto=${s[port]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
Env::identity() {
|
||||||
|
local self="$1"
|
||||||
|
local -n s="$self"
|
||||||
|
|
||||||
|
echo "${s[host]} (${s[dest]}, ${s[unit_name]})"
|
||||||
|
}
|
||||||
|
|
||||||
|
Env::summary() {
|
||||||
|
local self="$1"
|
||||||
|
local -n s="$self"
|
||||||
|
|
||||||
|
echo "rama=${s[branch]} unit=${s[unit_name]} puerto=${s[port]}"
|
||||||
|
}
|
||||||
Executable
+33
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# teardown.sh — Elimina el entorno de una rama (al borrar la rama o a mano).
|
||||||
|
# Toda la lógica vive en lib/env.sh (modelo OOP). Éste es sólo el entrypoint.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=lib/env.sh
|
||||||
|
. "$SCRIPT_DIR/lib/env.sh"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "usage: $0 <branch>" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local branch root
|
||||||
|
|
||||||
|
branch="${1:-}"
|
||||||
|
if [ -z "$branch" ]; then
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
root="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
Logger::set_tag teardown
|
||||||
|
Env::new env "$branch" "$root"
|
||||||
|
Logger::info "$(Env::identity env)"
|
||||||
|
|
||||||
|
Env::teardown env
|
||||||
|
|
||||||
|
Logger::info "entorno eliminado ✓"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
+28
-42
@@ -3,50 +3,36 @@ import { fileURLToPath } from "url";
|
|||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const dbPath = join(__dirname, "../localesp.db");
|
const db = new Database(join(__dirname, "..", "localesp.db"));
|
||||||
const db = new Database(dbPath);
|
|
||||||
|
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS locales (
|
CREATE TABLE IF NOT EXISTS businesses (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
nombre TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
descripcion TEXT,
|
category TEXT NOT NULL,
|
||||||
ubicacion TEXT,
|
address TEXT NOT NULL,
|
||||||
telefono TEXT,
|
description TEXT NOT NULL,
|
||||||
email TEXT,
|
contact TEXT NOT NULL DEFAULT '',
|
||||||
web TEXT,
|
lat REAL NOT NULL,
|
||||||
categoria TEXT,
|
lng REAL NOT NULL,
|
||||||
fecha TEXT DEFAULT CURRENT_TIMESTAMP,
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
lat REAL,
|
createdAt TEXT NOT NULL
|
||||||
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;
|
export default db;
|
||||||
|
|||||||
+27
-71
@@ -1,95 +1,51 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import db from "./db.js";
|
import db from "./db.js";
|
||||||
import { randomUUID } from "crypto";
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static(join(__dirname, "../dist")));
|
app.use(express.static(join(__dirname, "../dist")));
|
||||||
|
|
||||||
// Locales (público, solo lectura)
|
app.get("/api/businesses", (_req, res) => {
|
||||||
app.get("/api/locales", (_req, res) => {
|
res.json(db.prepare("SELECT * FROM businesses WHERE status = 'approved' ORDER BY createdAt DESC").all());
|
||||||
const locales = db.prepare("SELECT * FROM locales ORDER BY fecha DESC").all();
|
|
||||||
res.json(locales);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pendientes (admin)
|
app.post("/api/businesses", (req, res) => {
|
||||||
app.get("/api/pendientes", (_req, res) => {
|
const { name, category, address, description, contact, lat, lng } = req.body;
|
||||||
const pendientes = db.prepare("SELECT * FROM pendientes ORDER BY fechaPropuesta DESC").all();
|
const business = {
|
||||||
res.json(pendientes);
|
id: randomUUID(),
|
||||||
});
|
name, category, address, description,
|
||||||
|
contact: contact || "",
|
||||||
// Enviar propuesta
|
lat, lng,
|
||||||
app.post("/api/propuestas", (req, res) => {
|
status: "pending",
|
||||||
const { nombre, descripcion, ubicacion, telefono, email, web, categoria, lat, lng } = req.body;
|
createdAt: new Date().toISOString(),
|
||||||
const id = randomUUID();
|
};
|
||||||
const fechaPropuesta = new Date().toISOString();
|
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO pendientes (id, nombre, descripcion, ubicacion, telefono, email, web, categoria, estado, fechaPropuesta, lat, lng)
|
INSERT INTO businesses (id, name, category, address, description, contact, lat, lng, status, createdAt)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pendiente', ?, ?, ?)
|
VALUES (@id, @name, @category, @address, @description, @contact, @lat, @lng, @status, @createdAt)
|
||||||
`).run(id, nombre, descripcion, ubicacion, telefono, email, web, categoria, fechaPropuesta, lat, lng);
|
`).run(business);
|
||||||
|
res.status(201).json(business);
|
||||||
res.status(201).json({ id, nombre, descripcion, ubicacion, telefono, email, web, categoria, estado: "pendiente", fechaPropuesta, lat, lng });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Aprobar propuesta
|
app.get("/api/admin/pending", (_req, res) => {
|
||||||
app.post("/api/aprobar/:id", (req, res) => {
|
res.json(db.prepare("SELECT * FROM businesses WHERE status = 'pending' ORDER BY createdAt ASC").all());
|
||||||
const { id } = req.params;
|
});
|
||||||
const propuesta = db.prepare("SELECT * FROM pendientes WHERE id = ?").get(id);
|
|
||||||
|
|
||||||
if (!propuesta) return res.status(404).json({ error: "Propuesta no encontrada" });
|
|
||||||
|
|
||||||
const localeId = randomUUID();
|
|
||||||
const fecha = new Date().toISOString();
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO locales (id, nombre, descripcion, 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 });
|
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) => {
|
app.get(/^\/(?!api\/).*/, (_req, res) => {
|
||||||
res.sendFile(join(__dirname, "../dist/index.html"));
|
res.sendFile(join(__dirname, "../dist/index.html"));
|
||||||
});
|
});
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
app.listen(PORT, () => console.log(`Server listening on http://localhost:${PORT}`));
|
app.listen(PORT, () => console.log(`API listening on http://localhost:${PORT}`));
|
||||||
|
|||||||
@@ -1,282 +0,0 @@
|
|||||||
// AdminPanel.jsx — /admin
|
|
||||||
// No aparece en ningún menú público. Acceso solo por URL directa.
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import {
|
|
||||||
escucharAuth, loginAdmin, registrarAdmin, cerrarSesion,
|
|
||||||
suscribirPendientes, aprobarPropuesta, rechazarPropuesta,
|
|
||||||
suscribirLocales, deleteLocal,
|
|
||||||
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. Tú pulsas <strong>Aprobar</strong> → pasa a <code>/locales</code> y se publica. O <strong>Rechazar</strong> → se elimina sin publicar.</li>
|
|
||||||
<li>Solo los locales en <code>/locales</code> son visibles en la web y la app móvil.</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</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} />;
|
|
||||||
}
|
|
||||||
+59
-594
@@ -1,609 +1,74 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { suscribirLocales, enviarPropuesta, obtenerPalabrasFiltradas, contienepalabrasProhibidas } from "./firebase.js";
|
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";
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
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_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],
|
|
||||||
};
|
|
||||||
|
|
||||||
const COLORES_MAPA = {
|
|
||||||
"Restauración":"#8B0000","Pequeño comercio":"#1A5276",
|
|
||||||
"Peluquería y estética":"#76448A","Servicios del hogar":"#1E8449","Otros":"#784212",
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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() {
|
export default function App() {
|
||||||
const [locales, setLocales] = useState([]);
|
const [businesses, setBusinesses] = useState([]);
|
||||||
const [vista, setVista] = useState("lista");
|
const [selectedId, setSelectedId] = useState(null);
|
||||||
const [mostrarForm, setMostrarForm] = useState(false);
|
const [formStep, setFormStep] = useState(null); // null | 'picking' | 'editing'
|
||||||
const [loading, setLoading] = useState(true);
|
const [pickedLocation, setPickedLocation] = useState(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [adminOpen, setAdminOpen] = useState(false);
|
||||||
const [enviado, setEnviado] = useState(false); // confirmación de propuesta enviada
|
|
||||||
const [errorFiltro, setErrorFiltro] = useState(""); // palabra prohibida detectada
|
|
||||||
const [busqueda, setBusqueda] = useState("");
|
|
||||||
const [filtroCategoria, setFiltroCategoria] = useState("");
|
|
||||||
const [filtroSubcat, setFiltroSubcat] = useState("");
|
|
||||||
const [filtroProvincia, setFiltroProvincia] = useState("");
|
|
||||||
const [orden, setOrden] = useState("fecha");
|
|
||||||
const [palabrasProhibidas, setPalabrasProhibidas] = useState([]);
|
|
||||||
const mapRef = useRef(null);
|
|
||||||
const mapInstanceRef = useRef(null);
|
|
||||||
const markersRef = useRef([]);
|
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
async function loadBusinesses() {
|
||||||
nombre: "", provincia: "", categoria: "", subcategoria: "",
|
setBusinesses(await getApproved());
|
||||||
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) {
|
useEffect(() => { loadBusinesses(); }, []);
|
||||||
const L = window.L;
|
|
||||||
const map = mapObj || mapInstanceRef.current;
|
const selected = businesses.find((b) => b.id === selectedId) || null;
|
||||||
if (!L || !map) return;
|
|
||||||
markersRef.current.forEach(m => m.remove());
|
function handlePickLocation(loc) {
|
||||||
markersRef.current = [];
|
setPickedLocation(loc);
|
||||||
locales.forEach(local => {
|
if (formStep === "picking") setFormStep("editing");
|
||||||
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; }, {});
|
async function handleSubmit(data) {
|
||||||
const provincias = [...new Set(locales.map(l => l.provincia))].sort();
|
await submit(data);
|
||||||
const mediaGlobal = locales.length ? (locales.reduce((s, l) => s + l.puntuacion, 0) / locales.length).toFixed(1) : "—";
|
setFormStep(null);
|
||||||
|
setPickedLocation(null);
|
||||||
|
alert("¡Gracias! Tu propuesta queda pendiente de revisión.");
|
||||||
|
}
|
||||||
|
|
||||||
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" };
|
function handleCancel() {
|
||||||
const tieneUbicacion = !!(form.direccion || form.enlaceGoogleMaps);
|
setFormStep(null);
|
||||||
const formValido = form.nombre && form.provincia && form.categoria && form.puntuacion > 0 && tieneUbicacion;
|
setPickedLocation(null);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 720, margin: "0 auto", padding: "1.5rem 1rem", fontFamily: "var(--font-sans)" }}>
|
<div className="app">
|
||||||
<h2 className="sr-only">Directorio de locales españoles</h2>
|
<Sidebar
|
||||||
|
business={selected}
|
||||||
{/* Header */}
|
onContribute={() => setFormStep("picking")}
|
||||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: "1.5rem", gap: 12, flexWrap: "wrap" }}>
|
onClose={() => setSelectedId(null)}
|
||||||
<div>
|
picking={formStep === "picking"}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
onCancelPicking={handleCancel}
|
||||||
<span style={{ fontSize: 28 }}>🇪🇸</span>
|
onAdmin={() => setAdminOpen(true)}
|
||||||
<h1 style={{ margin: 0, fontSize: 22, fontWeight: 500 }}>Locales Españoles</h1>
|
/>
|
||||||
</div>
|
<MapView
|
||||||
<p style={{ margin: "4px 0 0", fontSize: 13, color: "var(--color-text-secondary)" }}>Directorio colaborativo de negocios por toda España</p>
|
businesses={businesses}
|
||||||
</div>
|
selectedId={selectedId}
|
||||||
<button onClick={() => { setMostrarForm(!mostrarForm); setErrorFiltro(""); }}
|
onSelect={setSelectedId}
|
||||||
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" }}>
|
pickingLocation={formStep === "picking"}
|
||||||
<i className="ti ti-plus" aria-hidden="true"></i> Proponer local
|
onPickLocation={handlePickLocation}
|
||||||
</button>
|
pickedLocation={pickedLocation}
|
||||||
</div>
|
/>
|
||||||
|
{formStep === "editing" && (
|
||||||
{/* Confirmación enviada */}
|
<SubmitForm
|
||||||
{enviado && (
|
pickedLocation={pickedLocation}
|
||||||
<div style={{ background: "#EAFAF1", border: "1px solid #A9DFBF", borderRadius: "var(--border-radius-md)", padding: "12px 16px", marginBottom: "1rem", display: "flex", alignItems: "center", gap: 10 }}>
|
onSubmit={handleSubmit}
|
||||||
<span style={{ fontSize: 20 }}>✅</span>
|
onCancel={handleCancel}
|
||||||
<div>
|
onRepick={() => setFormStep("picking")}
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
{adminOpen && (
|
||||||
{/* Stats */}
|
<AdminPanel
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10, marginBottom: "1.25rem" }}>
|
onClose={() => setAdminOpen(false)}
|
||||||
{[{ label: "Locales", value: locales.length }, { label: "Provincias", value: provincias.length }, { label: "Media ★", value: mediaGlobal }].map(s => (
|
onApproved={loadBusinesses}
|
||||||
<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 }}>Sé 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,383 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import { suscribirLocales, enviarPropuesta, obtenerPalabrasFiltradas, contienepalabrasProhibidas } from "./firebase.js";
|
|
||||||
|
|
||||||
const CATEGORIAS = {
|
|
||||||
"Restauración": { emoji:"🍽️", color:"#8B0000", bg:"#FFF0F0", text:"#8B0000", subcategorias:["Tapas y raciones","Paella y arroces","Pintxos","Asador / Carne a la brasa","Mariscos y pescados","Bocadillos y montaditos","Menú del día","Cocina vasca","Cocina catalana","Cocina andaluza","Cocina gallega","Cocina madrileña","Cocina mediterránea","Pizzería","Hamburguesería","Comida rápida","Cocina internacional","Cafetería / Desayunos","Heladería","Pastelería"] },
|
|
||||||
"Pequeño comercio": { emoji:"🛍️", color:"#1A5276", bg:"#EBF5FB", text:"#1A5276", subcategorias:["Alimentación / Ultramarinos","Frutería","Carnicería","Pescadería","Panadería","Farmacia","Papelería / Librería","Floristería","Joyería / Relojería","Zapatería","Ropa y moda","Juguetería","Ferretería","Bazar / Todo a 100","Estanco","Quiosco","Óptica","Ortopedia","Tienda de mascotas","Electrodomésticos"] },
|
|
||||||
"Peluquería y estética": { emoji:"✂️", color:"#76448A", bg:"#F5EEF8", text:"#76448A", subcategorias:["Peluquería señora","Peluquería caballero","Peluquería unisex","Barbería","Centro de estética","Uñas / Manicura","Depilación","Masajes y spa","Tatuajes y piercings","Centro de bronceado","Micropigmentación"] },
|
|
||||||
"Servicios del hogar": { emoji:"🔧", color:"#1E8449", bg:"#EAFAF1", text:"#1E8449", subcategorias:["Cerrajería","Fontanería / Plomería","Electricidad","Reformas y construcción","Pintura","Carpintería","Cristalería","Climatización / Aire acondicionado","Mudanzas","Limpieza","Jardinería","Instalación solar","Alarmas y seguridad","Reparación electrodomésticos"] },
|
|
||||||
"Otros": { emoji:"📌", color:"#784212", bg:"#FDF2E9", text:"#784212", subcategorias:["Taller mecánico","Lavado de coches","Academia / Clases","Gestoría / Asesoría","Inmobiliaria","Agencia de viajes","Fotografía","Informática / Reparación móviles","Copistería / Imprenta","Veterinaria","Gimnasio / Fitness","Centro médico / Clínica","Fisioterapia","Psicología","Lavandería","Tintorería","Otro"] },
|
|
||||||
};
|
|
||||||
const CATS = Object.keys(CATEGORIAS);
|
|
||||||
|
|
||||||
const PROVINCIAS = ["Álava","Albacete","Alicante","Almería","Asturias","Ávila","Badajoz","Barcelona","Burgos","Cáceres","Cádiz","Cantabria","Castellón","Ciudad Real","Córdoba","Cuenca","Gerona","Granada","Guadalajara","Guipúzcoa","Huelva","Huesca","Islas Baleares","Jaén","La Coruña","La Rioja","Las Palmas","León","Lérida","Lugo","Madrid","Málaga","Murcia","Navarra","Orense","Palencia","Pontevedra","Salamanca","Santa Cruz de Tenerife","Segovia","Sevilla","Soria","Tarragona","Teruel","Toledo","Valencia","Valladolid","Vizcaya","Zamora","Zaragoza"];
|
|
||||||
|
|
||||||
const COORDS = {"Álava":[42.85,-2.67],"Albacete":[38.99,-1.86],"Alicante":[38.35,-0.48],"Almería":[36.83,-2.46],"Asturias":[43.36,-5.86],"Ávila":[40.66,-4.68],"Badajoz":[38.88,-6.97],"Barcelona":[41.39,2.17],"Burgos":[42.34,-3.70],"Cáceres":[39.48,-6.37],"Cádiz":[36.53,-6.29],"Cantabria":[43.46,-3.81],"Castellón":[39.99,-0.05],"Ciudad Real":[38.98,-3.93],"Córdoba":[37.89,-4.78],"Cuenca":[40.07,-2.14],"Gerona":[41.98,2.82],"Granada":[37.18,-3.60],"Guadalajara":[40.63,-3.16],"Guipúzcoa":[43.32,-1.98],"Huelva":[37.26,-6.94],"Huesca":[42.14,-0.41],"Islas Baleares":[39.57,2.65],"Jaén":[37.78,-3.78],"La Coruña":[43.36,-8.41],"La Rioja":[42.46,-2.44],"Las Palmas":[28.12,-15.44],"León":[42.60,-5.57],"Lérida":[41.62,0.62],"Lugo":[43.01,-7.56],"Madrid":[40.42,-3.70],"Málaga":[36.72,-4.42],"Murcia":[37.99,-1.13],"Navarra":[42.82,-1.64],"Orense":[42.34,-7.86],"Palencia":[42.01,-4.53],"Pontevedra":[42.43,-8.65],"Salamanca":[40.97,-5.66],"Santa Cruz de Tenerife":[28.46,-16.25],"Segovia":[40.94,-4.11],"Sevilla":[37.39,-5.98],"Soria":[41.76,-2.46],"Tarragona":[41.12,1.24],"Teruel":[40.34,-1.11],"Toledo":[39.86,-4.03],"Valencia":[39.47,-0.38],"Valladolid":[41.65,-4.72],"Vizcaya":[43.26,-2.94],"Zamora":[41.50,-5.74],"Zaragoza":[41.65,-0.89]};
|
|
||||||
|
|
||||||
async function geocodificar(dir) {
|
|
||||||
try {
|
|
||||||
const q = encodeURIComponent(dir + ", España");
|
|
||||||
const r = await fetch(`https://nominatim.openstreetmap.org/search?q=${q}&format=json&limit=1&countrycodes=es`, { headers:{"Accept-Language":"es","User-Agent":"LocalesMovil/1.0"} });
|
|
||||||
const d = await r.json();
|
|
||||||
if (d?.length) return { lat: parseFloat(d[0].lat), lng: parseFloat(d[0].lon) };
|
|
||||||
} catch {}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsearGoogleMaps(url) {
|
|
||||||
try {
|
|
||||||
let m = url.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
|
|
||||||
if (m) return { lat: parseFloat(m[1]), lng: parseFloat(m[2]) };
|
|
||||||
m = url.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/);
|
|
||||||
if (m) return { lat: parseFloat(m[1]), lng: parseFloat(m[2]) };
|
|
||||||
} catch {}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Estilos base móvil ───────────────────────────────────────────────────────
|
|
||||||
const S = {
|
|
||||||
screen: { minHeight:"100dvh", display:"flex", flexDirection:"column", background:"#F8F5F0", fontFamily:"'Georgia', serif" },
|
|
||||||
header: { background:"#8B0000", color:"white", padding:"14px 16px 10px", position:"sticky", top:0, zIndex:10 },
|
|
||||||
headerTitle: { margin:0, fontSize:20, fontWeight:700, letterSpacing:"-0.3px" },
|
|
||||||
headerSub: { margin:"2px 0 0", fontSize:12, opacity:0.75 },
|
|
||||||
body: { flex:1, overflowY:"auto", padding:"12px 14px 80px" },
|
|
||||||
navBar: { position:"fixed", bottom:0, left:0, right:0, background:"white", borderTop:"1px solid #E5DDD5", display:"flex", zIndex:10, padding:"4px 0" },
|
|
||||||
navBtn: (active) => ({ flex:1, display:"flex", flexDirection:"column", alignItems:"center", gap:3, padding:"6px 0", background:"none", border:"none", cursor:"pointer", color: active ? "#8B0000" : "#999", fontSize:10, fontWeight: active ? 700 : 400 }),
|
|
||||||
navIcon: (active) => ({ fontSize:22, lineHeight:1, color: active ? "#8B0000" : "#999" }),
|
|
||||||
card: { background:"white", borderRadius:12, padding:"14px 16px", marginBottom:10, border:"1px solid #EEE8E0" },
|
|
||||||
badge: (cat) => { const c = CATEGORIAS[cat]; return { background: c?.bg||"#eee", color: c?.text||"#555", fontSize:11, padding:"3px 9px", borderRadius:20, fontWeight:500, display:"inline-block" }; },
|
|
||||||
input: { width:"100%", boxSizing:"border-box", padding:"11px 13px", borderRadius:10, border:"1px solid #DDD7CF", background:"white", fontSize:15, outline:"none", fontFamily:"inherit" },
|
|
||||||
label: { fontSize:12, color:"#888", display:"block", marginBottom:5, fontWeight:600, textTransform:"uppercase", letterSpacing:"0.4px" },
|
|
||||||
btn: (primary) => ({ width:"100%", padding:"13px", borderRadius:10, border:"none", cursor:"pointer", fontSize:15, fontWeight:700, background: primary ? "#8B0000" : "#F0EBE5", color: primary ? "white" : "#666", fontFamily:"inherit" }),
|
|
||||||
stars: (n) => "★".repeat(n)+"☆".repeat(5-n),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Pantalla: Lista ──────────────────────────────────────────────────────────
|
|
||||||
function PantallaLista({ locales, onDelete, onBuscar, busqueda, filtroCat, setFiltroCat }) {
|
|
||||||
const filtrados = locales.filter(l => {
|
|
||||||
const q = busqueda.toLowerCase();
|
|
||||||
return (!q || l.nombre.toLowerCase().includes(q) || (l.direccion||"").toLowerCase().includes(q) || l.provincia.toLowerCase().includes(q))
|
|
||||||
&& (!filtroCat || l.categoria === filtroCat);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div style={{ marginBottom:12 }}>
|
|
||||||
<input style={S.input} placeholder="🔍 Buscar nombre, ciudad..." value={busqueda} onChange={e => onBuscar(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chips categoría */}
|
|
||||||
<div style={{ display:"flex", gap:6, overflowX:"auto", paddingBottom:8, marginBottom:10, scrollbarWidth:"none" }}>
|
|
||||||
<button onClick={() => setFiltroCat("")} style={{ ...S.badge("Restauración"), background: !filtroCat?"#8B0000":"#EEE8E0", color: !filtroCat?"white":"#777", flexShrink:0, cursor:"pointer", border:"none", fontFamily:"inherit" }}>Todos</button>
|
|
||||||
{CATS.map(c => (
|
|
||||||
<button key={c} onClick={() => setFiltroCat(filtroCat===c?"":c)}
|
|
||||||
style={{ ...S.badge(c), flexShrink:0, cursor:"pointer", border: filtroCat===c?"1.5px solid "+CATEGORIAS[c].color:"1px solid transparent", fontFamily:"inherit" }}>
|
|
||||||
{CATEGORIAS[c].emoji} {c}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filtrados.length === 0 ? (
|
|
||||||
<div style={{ textAlign:"center", padding:"3rem 1rem", color:"#AAA" }}>
|
|
||||||
<div style={{ fontSize:48, marginBottom:12 }}>🏘️</div>
|
|
||||||
<p style={{ margin:0, fontSize:15, color:"#888" }}>{locales.length===0 ? "¡Sé el primero en añadir un local!" : "Sin resultados"}</p>
|
|
||||||
</div>
|
|
||||||
) : filtrados.map(local => (
|
|
||||||
<div key={local.id} style={S.card}>
|
|
||||||
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"flex-start", marginBottom:8 }}>
|
|
||||||
<div style={{ flex:1 }}>
|
|
||||||
<p style={{ margin:0, fontWeight:700, fontSize:16, color:"#1A1A1A" }}>{local.nombre}</p>
|
|
||||||
<p style={{ margin:"2px 0 0", fontSize:13, color:"#888" }}>📍 {local.provincia}</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => onDelete(local.id)} style={{ background:"none", border:"none", color:"#CCC", fontSize:18, cursor:"pointer", padding:"0 0 0 8px" }}>✕</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ display:"flex", flexWrap:"wrap", gap:5, marginBottom:8 }}>
|
|
||||||
<span style={S.badge(local.categoria)}>{CATEGORIAS[local.categoria]?.emoji} {local.categoria}</span>
|
|
||||||
{local.subcategoria && <span style={{ fontSize:11, color:"#888", padding:"3px 8px", background:"#F5F5F5", borderRadius:20 }}>{local.subcategoria}</span>}
|
|
||||||
</div>
|
|
||||||
{local.direccion && <p style={{ margin:"0 0 6px", fontSize:13, color:"#666" }}>{local.direccion}</p>}
|
|
||||||
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"center" }}>
|
|
||||||
<span style={{ color:"#D4AF37", fontSize:18, letterSpacing:1 }}>{S.stars(local.puntuacion)}</span>
|
|
||||||
{(local.direccion || local.enlaceGoogleMaps) && (
|
|
||||||
<a href={local.enlaceGoogleMaps || `https://www.google.com/maps/search/${encodeURIComponent(local.nombre+" "+local.direccion)}`}
|
|
||||||
target="_blank" rel="noopener noreferrer"
|
|
||||||
style={{ fontSize:12, color:"#1A73E8", textDecoration:"none", background:"#E8F0FE", padding:"4px 10px", borderRadius:8, fontWeight:600 }}>
|
|
||||||
Maps ↗
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{local.descripcion && <p style={{ margin:"8px 0 0", fontSize:13, color:"#666", fontStyle:"italic" }}>"{local.descripcion}"</p>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Pantalla: Añadir ─────────────────────────────────────────────────────────
|
|
||||||
function PantallaAnadir({ onGuardar }) {
|
|
||||||
const [form, setForm] = useState({ nombre:"", provincia:"", categoria:"", subcategoria:"", direccion:"", enlaceGoogleMaps:"", puntuacion:0, descripcion:"" });
|
|
||||||
const [modoUbic, setModoUbic] = useState("direccion");
|
|
||||||
const [geocodif, setGeocodif] = useState(null);
|
|
||||||
const [guardando, setGuardando] = useState(false);
|
|
||||||
const [ok, setOk] = useState(false);
|
|
||||||
|
|
||||||
const valido = form.nombre && form.provincia && form.categoria && form.puntuacion > 0 && (form.direccion || form.enlaceGoogleMaps);
|
|
||||||
|
|
||||||
const guardar = async () => {
|
|
||||||
if (!valido) return;
|
|
||||||
setGuardando(true);
|
|
||||||
let lat, lng;
|
|
||||||
if (modoUbic === "direccion" && form.direccion) {
|
|
||||||
const geo = await geocodificar(form.direccion);
|
|
||||||
if (geo) { lat = geo.lat; lng = geo.lng; }
|
|
||||||
} else if (form.enlaceGoogleMaps) {
|
|
||||||
const c = parsearGoogleMaps(form.enlaceGoogleMaps);
|
|
||||||
if (c) { lat = c.lat; lng = c.lng; }
|
|
||||||
}
|
|
||||||
if (!lat) {
|
|
||||||
const fb = COORDS[form.provincia] || [40.4,-3.7];
|
|
||||||
lat = fb[0] + (Math.random()-0.5)*0.04;
|
|
||||||
lng = fb[1] + (Math.random()-0.5)*0.04;
|
|
||||||
}
|
|
||||||
await onGuardar({ ...form, lat, lng, id: Date.now().toString(), fecha: new Date().toISOString() });
|
|
||||||
setOk(true);
|
|
||||||
setGuardando(false);
|
|
||||||
setTimeout(() => {
|
|
||||||
setOk(false);
|
|
||||||
setForm({ nombre:"", provincia:"", categoria:"", subcategoria:"", direccion:"", enlaceGoogleMaps:"", puntuacion:0, descripcion:"" });
|
|
||||||
setGeocodif(null);
|
|
||||||
}, 1500);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (ok) return (
|
|
||||||
<div style={{ display:"flex", flexDirection:"column", alignItems:"center", justifyContent:"center", minHeight:"60vh", gap:16 }}>
|
|
||||||
<div style={{ fontSize:64 }}>✅</div>
|
|
||||||
<p style={{ fontSize:18, fontWeight:700, color:"#1E8449", margin:0 }}>¡Local añadido!</p>
|
|
||||||
<p style={{ fontSize:14, color:"#888", margin:0 }}>Sincronizando con la web...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ paddingBottom:20 }}>
|
|
||||||
<div style={{ marginBottom:16 }}>
|
|
||||||
<label style={S.label}>Nombre del local *</label>
|
|
||||||
<input style={S.input} placeholder="Bar El Olivo, Clínica San José..." value={form.nombre} onChange={e => setForm(f=>({...f,nombre:e.target.value}))} />
|
|
||||||
</div>
|
|
||||||
<div style={{ marginBottom:16 }}>
|
|
||||||
<label style={S.label}>Provincia *</label>
|
|
||||||
<select style={S.input} value={form.provincia} onChange={e => setForm(f=>({...f,provincia:e.target.value}))}>
|
|
||||||
<option value="">Selecciona...</option>
|
|
||||||
{PROVINCIAS.map(p => <option key={p} value={p}>{p}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginBottom:16 }}>
|
|
||||||
<label style={S.label}>Categoría *</label>
|
|
||||||
<select style={S.input} value={form.categoria} onChange={e => setForm(f=>({...f,categoria:e.target.value,subcategoria:""}))}>
|
|
||||||
<option value="">Selecciona...</option>
|
|
||||||
{CATS.map(c => <option key={c} value={c}>{CATEGORIAS[c].emoji} {c}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{form.categoria && (
|
|
||||||
<div style={{ marginBottom:16 }}>
|
|
||||||
<label style={S.label}>Tipo específico</label>
|
|
||||||
<select style={S.input} value={form.subcategoria} onChange={e => setForm(f=>({...f,subcategoria:e.target.value}))}>
|
|
||||||
<option value="">Selecciona...</option>
|
|
||||||
{CATEGORIAS[form.categoria].subcategorias.map(s => <option key={s} value={s}>{s}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Ubicación */}
|
|
||||||
<div style={{ marginBottom:16 }}>
|
|
||||||
<label style={S.label}>Ubicación *</label>
|
|
||||||
<div style={{ display:"flex", border:"1px solid #DDD7CF", borderRadius:10, overflow:"hidden", marginBottom:8 }}>
|
|
||||||
{[{id:"direccion",label:"📍 Dirección"},{id:"enlace",label:"🔗 Google Maps"}].map(opt => (
|
|
||||||
<button key={opt.id} onClick={() => { setModoUbic(opt.id); setForm(f=>({...f,direccion:"",enlaceGoogleMaps:""})); setGeocodif(null); }}
|
|
||||||
style={{ flex:1, padding:"10px 6px", border:"none", background: modoUbic===opt.id?"#F0EBE5":"white", fontSize:13, cursor:"pointer", fontWeight: modoUbic===opt.id?700:400, color: modoUbic===opt.id?"#8B0000":"#666", fontFamily:"inherit" }}>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{modoUbic === "direccion" ? (
|
|
||||||
<input style={S.input} placeholder="Calle Mayor 5, Madrid..." value={form.direccion}
|
|
||||||
onChange={e => { setForm(f=>({...f,direccion:e.target.value})); setGeocodif(null); }} />
|
|
||||||
) : (
|
|
||||||
<input style={S.input} placeholder="Pega el enlace de Google Maps..." value={form.enlaceGoogleMaps}
|
|
||||||
onChange={e => {
|
|
||||||
const val = e.target.value;
|
|
||||||
setForm(f=>({...f,enlaceGoogleMaps:val}));
|
|
||||||
const c = parsearGoogleMaps(val);
|
|
||||||
setGeocodif(c ? "✅ Coordenadas detectadas" : val ? "⚠️ No se detectaron coordenadas" : null);
|
|
||||||
}} />
|
|
||||||
)}
|
|
||||||
{geocodif && <p style={{ fontSize:12, color:geocodif.startsWith("✅")?"#1E8449":"#784212", margin:"6px 0 0" }}>{geocodif}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Puntuación */}
|
|
||||||
<div style={{ marginBottom:16 }}>
|
|
||||||
<label style={S.label}>Puntuación *</label>
|
|
||||||
<div style={{ display:"flex", gap:8 }}>
|
|
||||||
{[1,2,3,4,5].map(n => (
|
|
||||||
<button key={n} onClick={() => setForm(f=>({...f,puntuacion:n}))}
|
|
||||||
style={{ fontSize:32, background:"none", border:"none", cursor:"pointer", color: n<=form.puntuacion?"#D4AF37":"#DDD", padding:0 }}>★</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom:24 }}>
|
|
||||||
<label style={S.label}>Descripción (opcional)</label>
|
|
||||||
<textarea style={{ ...S.input, minHeight:80, resize:"vertical" }} placeholder="Horarios, especialidades, qué lo hace especial..."
|
|
||||||
value={form.descripcion} onChange={e => setForm(f=>({...f,descripcion:e.target.value}))} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onClick={guardar} disabled={!valido||guardando} style={{ ...S.btn(true), opacity: valido?1:0.4 }}>
|
|
||||||
{guardando ? "Enviando..." : "📨 Enviar propuesta"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Pantalla: Estadísticas ───────────────────────────────────────────────────
|
|
||||||
function PantallaStats({ locales }) {
|
|
||||||
const media = locales.length ? (locales.reduce((s,l)=>s+l.puntuacion,0)/locales.length).toFixed(1) : "—";
|
|
||||||
const provincias = [...new Set(locales.map(l=>l.provincia))].length;
|
|
||||||
const porCat = CATS.map(c => ({ cat:c, n: locales.filter(l=>l.categoria===c).length })).filter(x=>x.n>0).sort((a,b)=>b.n-a.n);
|
|
||||||
const top5 = [...locales].sort((a,b)=>b.puntuacion-a.puntuacion).slice(0,5);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div style={{ display:"grid", gridTemplateColumns:"1fr 1fr", gap:10, marginBottom:14 }}>
|
|
||||||
{[{v:locales.length,l:"Locales"},{v:provincias,l:"Provincias"},{v:media,l:"Media ★"},{v:porCat.length,l:"Categorías"}].map(s=>(
|
|
||||||
<div key={s.l} style={{ background:"white", borderRadius:12, padding:"14px 16px", border:"1px solid #EEE8E0", textAlign:"center" }}>
|
|
||||||
<p style={{ margin:0, fontSize:11, color:"#AAA", textTransform:"uppercase", letterSpacing:"0.5px", fontWeight:600 }}>{s.l}</p>
|
|
||||||
<p style={{ margin:"6px 0 0", fontSize:28, fontWeight:700, color:"#1A1A1A" }}>{s.v}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{porCat.length > 0 && (
|
|
||||||
<div style={S.card}>
|
|
||||||
<p style={{ margin:"0 0 12px", fontWeight:700, fontSize:14, color:"#555", textTransform:"uppercase", letterSpacing:"0.4px" }}>Por categoría</p>
|
|
||||||
{porCat.map(({cat,n}) => {
|
|
||||||
const pct = Math.round((n/locales.length)*100);
|
|
||||||
return (
|
|
||||||
<div key={cat} style={{ marginBottom:10 }}>
|
|
||||||
<div style={{ display:"flex", justifyContent:"space-between", marginBottom:4 }}>
|
|
||||||
<span style={{ fontSize:14 }}>{CATEGORIAS[cat].emoji} {cat}</span>
|
|
||||||
<span style={{ fontSize:13, fontWeight:700, color:CATEGORIAS[cat].color }}>{n}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ background:"#F0EBE5", borderRadius:4, height:6, overflow:"hidden" }}>
|
|
||||||
<div style={{ height:"100%", background:CATEGORIAS[cat].color, width:pct+"%", borderRadius:4 }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{top5.length > 0 && (
|
|
||||||
<div style={S.card}>
|
|
||||||
<p style={{ margin:"0 0 12px", fontWeight:700, fontSize:14, color:"#555", textTransform:"uppercase", letterSpacing:"0.4px" }}>Top valorados</p>
|
|
||||||
{top5.map((l,i) => (
|
|
||||||
<div key={l.id} style={{ display:"flex", alignItems:"center", gap:10, marginBottom:10 }}>
|
|
||||||
<span style={{ fontSize:18, fontWeight:700, color:"#D4AF37", minWidth:24 }}>#{i+1}</span>
|
|
||||||
<div style={{ flex:1 }}>
|
|
||||||
<p style={{ margin:0, fontSize:14, fontWeight:600 }}>{l.nombre}</p>
|
|
||||||
<p style={{ margin:0, fontSize:12, color:"#888" }}>{l.provincia}</p>
|
|
||||||
</div>
|
|
||||||
<span style={{ color:"#D4AF37", fontSize:14 }}>{"★".repeat(l.puntuacion)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p style={{ textAlign:"center", fontSize:12, color:"#BBB", marginTop:16 }}>
|
|
||||||
Datos sincronizados con la versión web
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── App principal ────────────────────────────────────────────────────────────
|
|
||||||
export default function AppMovil() {
|
|
||||||
const [tab, setTab] = useState("lista");
|
|
||||||
const [locales, setLocales] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [busqueda, setBusqueda] = useState("");
|
|
||||||
const [filtroCat, setFiltroCat] = useState("");
|
|
||||||
|
|
||||||
const [palabrasProhibidas, setPalabrasProhibidas] = useState([]);
|
|
||||||
|
|
||||||
const cargar = useCallback(() => {}, []); // Firestore ya escucha en tiempo real
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLoading(true);
|
|
||||||
const unsub = suscribirLocales(
|
|
||||||
(lista) => { setLocales(lista); setLoading(false); },
|
|
||||||
() => { setLocales([]); setLoading(false); }
|
|
||||||
);
|
|
||||||
return () => unsub();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { obtenerPalabrasFiltradas().then(p => setPalabrasProhibidas(p)); }, []);
|
|
||||||
|
|
||||||
const addLocal = async (local) => {
|
|
||||||
// Comprobar filtro de palabras
|
|
||||||
const texto = [local.nombre, local.descripcion||"", local.direccion||""].join(" ");
|
|
||||||
if (contienepalabrasProhibidas(texto, palabrasProhibidas)) {
|
|
||||||
alert("Tu propuesta contiene palabras no permitidas. Por favor, revísala.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try { await enviarPropuesta(local); } catch(e) { console.error(e); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const delLocal = null; // Solo el admin puede eliminar locales
|
|
||||||
|
|
||||||
const TABS = [
|
|
||||||
{ id:"lista", icon:"🗂️", label:"Lista" },
|
|
||||||
{ id:"anadir", icon:"➕", label:"Añadir" },
|
|
||||||
{ id:"stats", icon:"📊", label:"Stats" },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={S.screen}>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={S.header}>
|
|
||||||
<div style={{ display:"flex", alignItems:"center", gap:10 }}>
|
|
||||||
<span style={{ fontSize:26 }}>🇪🇸</span>
|
|
||||||
<div>
|
|
||||||
<h1 style={S.headerTitle}>Locales Españoles</h1>
|
|
||||||
<p style={S.headerSub}>{loading ? "Cargando..." : `${locales.length} ${locales.length===1?"local":"locales"} · sincronizado`}</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={cargar} style={{ marginLeft:"auto", background:"rgba(255,255,255,0.2)", border:"none", borderRadius:8, padding:"6px 10px", color:"white", cursor:"pointer", fontSize:18 }}>↻</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contenido */}
|
|
||||||
<div style={S.body}>
|
|
||||||
{loading ? (
|
|
||||||
<div style={{ textAlign:"center", padding:"4rem 1rem", color:"#AAA" }}>
|
|
||||||
<p style={{ fontSize:32, margin:"0 0 12px" }}>⏳</p>
|
|
||||||
<p style={{ margin:0, fontSize:15 }}>Sincronizando con la web...</p>
|
|
||||||
</div>
|
|
||||||
) : tab === "lista" ? (
|
|
||||||
<PantallaLista locales={locales} onDelete={null} onBuscar={setBusqueda} busqueda={busqueda} filtroCat={filtroCat} setFiltroCat={setFiltroCat} />
|
|
||||||
) : tab === "anadir" ? (
|
|
||||||
<PantallaAnadir onGuardar={async (local) => { await addLocal(local); setTab("lista"); }} />
|
|
||||||
) : (
|
|
||||||
<PantallaStats locales={locales} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Nav bar inferior */}
|
|
||||||
<nav style={S.navBar}>
|
|
||||||
{TABS.map(t => (
|
|
||||||
<button key={t.id} onClick={() => setTab(t.id)} style={S.navBtn(tab===t.id)}>
|
|
||||||
<span style={S.navIcon(tab===t.id)}>{t.icon}</span>
|
|
||||||
{t.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-143
@@ -1,143 +0,0 @@
|
|||||||
// Local SQLite API instead of Firebase
|
|
||||||
|
|
||||||
const API_URL = "/api";
|
|
||||||
|
|
||||||
export async function suscribirLocales(onData, onError) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_URL}/locales`);
|
|
||||||
const data = await response.json();
|
|
||||||
onData(data);
|
|
||||||
// Simulate real-time by polling every 5 seconds
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/locales`);
|
|
||||||
const newData = await res.json();
|
|
||||||
onData(newData);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
} catch (err) {
|
|
||||||
onError(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function suscribirPendientes(onData, onError) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_URL}/pendientes`);
|
|
||||||
const data = await response.json();
|
|
||||||
onData(data);
|
|
||||||
// Simulate real-time by polling every 5 seconds
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/pendientes`);
|
|
||||||
const newData = await res.json();
|
|
||||||
onData(newData);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
} catch (err) {
|
|
||||||
onError(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function enviarPropuesta(local) {
|
|
||||||
const { id: _x, ...datos } = local;
|
|
||||||
const response = await fetch(`${API_URL}/propuestas`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(datos),
|
|
||||||
});
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function 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;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getPending, setStatus } from "../services/businessRepository.js";
|
||||||
|
|
||||||
|
export default function AdminPanel({ onClose, onApproved }) {
|
||||||
|
const [pending, setPending] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setPending(await getPending());
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
async function handle(id, status) {
|
||||||
|
await setStatus(id, status);
|
||||||
|
await load();
|
||||||
|
onApproved();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal admin-panel" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="admin-header">
|
||||||
|
<h2>Propuestas pendientes</h2>
|
||||||
|
<button className="close" onClick={onClose} aria-label="Cerrar">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <p className="admin-empty">Cargando…</p>}
|
||||||
|
|
||||||
|
{!loading && pending.length === 0 && (
|
||||||
|
<p className="admin-empty">No hay propuestas pendientes.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && pending.map((b) => (
|
||||||
|
<div key={b.id} className="admin-item">
|
||||||
|
<div className="admin-item-info">
|
||||||
|
<strong>{b.name}</strong>
|
||||||
|
<span className="badge">{b.category}</span>
|
||||||
|
<p>{b.address}</p>
|
||||||
|
<p className="admin-desc">{b.description}</p>
|
||||||
|
{b.contact && <p className="admin-contact">{b.contact}</p>}
|
||||||
|
<p className="admin-coords">{b.lat.toFixed(4)}, {b.lng.toFixed(4)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-actions">
|
||||||
|
<button className="approve" onClick={() => handle(b.id, "approved")}>Aprobar</button>
|
||||||
|
<button className="reject" onClick={() => handle(b.id, "rejected")}>Rechazar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { MapContainer, TileLayer, Marker, useMapEvents } from "react-leaflet";
|
||||||
|
import L from "leaflet";
|
||||||
|
|
||||||
|
// Configuración del icono por defecto. Leaflet no encuentra sus iconos cuando
|
||||||
|
// se empaqueta con Vite; los referenciamos desde el CDN para no copiar
|
||||||
|
// archivos a public/.
|
||||||
|
const icon = new L.Icon({
|
||||||
|
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||||||
|
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||||||
|
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||||
|
iconSize: [25, 41],
|
||||||
|
iconAnchor: [12, 41],
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEFAULT_CENTER = [40.4168, -3.7038];
|
||||||
|
const DEFAULT_ZOOM = 13;
|
||||||
|
|
||||||
|
function LocationPicker({ onPick }) {
|
||||||
|
useMapEvents({
|
||||||
|
click(e) {
|
||||||
|
onPick({ lat: e.latlng.lat, lng: e.latlng.lng });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MapView({
|
||||||
|
businesses,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
pickingLocation,
|
||||||
|
onPickLocation,
|
||||||
|
pickedLocation,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="map">
|
||||||
|
<MapContainer
|
||||||
|
center={DEFAULT_CENTER}
|
||||||
|
zoom={DEFAULT_ZOOM}
|
||||||
|
style={{ height: "100%", width: "100%" }}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
{businesses.map((b) => (
|
||||||
|
<Marker
|
||||||
|
key={b.id}
|
||||||
|
position={[b.lat, b.lng]}
|
||||||
|
icon={icon}
|
||||||
|
eventHandlers={{ click: () => onSelect(b.id) }}
|
||||||
|
opacity={selectedId && selectedId !== b.id ? 0.6 : 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{pickingLocation && <LocationPicker onPick={onPickLocation} />}
|
||||||
|
{pickedLocation && (
|
||||||
|
<Marker
|
||||||
|
position={[pickedLocation.lat, pickedLocation.lng]}
|
||||||
|
icon={icon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</MapContainer>
|
||||||
|
{pickingLocation && (
|
||||||
|
<div className="map-hint">Haz clic en el mapa para fijar la ubicación</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export default function Sidebar({ business, onContribute, onClose, picking, onCancelPicking, onAdmin }) {
|
||||||
|
return (
|
||||||
|
<aside className="sidebar">
|
||||||
|
<header className="sidebar-header">
|
||||||
|
<h1>Business Map</h1>
|
||||||
|
<p className="tagline">Registro colaborativo de negocios</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="sidebar-body">
|
||||||
|
{picking ? (
|
||||||
|
<div className="empty">
|
||||||
|
<p>Haz clic en el mapa para fijar la ubicación del negocio.</p>
|
||||||
|
</div>
|
||||||
|
) : business ? (
|
||||||
|
<BusinessDetails business={business} onClose={onClose} />
|
||||||
|
) : (
|
||||||
|
<Empty />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="sidebar-footer">
|
||||||
|
{picking ? (
|
||||||
|
<button onClick={onCancelPicking}>Cancelar</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button className="primary" onClick={onContribute}>
|
||||||
|
+ Añadir un negocio
|
||||||
|
</button>
|
||||||
|
<button className="admin-btn" onClick={onAdmin} title="Panel de administración">
|
||||||
|
⚙ Admin
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty() {
|
||||||
|
return (
|
||||||
|
<div className="empty">
|
||||||
|
<p>Selecciona un punto del mapa para ver sus detalles.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BusinessDetails({ business, onClose }) {
|
||||||
|
return (
|
||||||
|
<article className="details">
|
||||||
|
<button className="close" onClick={onClose} aria-label="Cerrar">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<span className="badge">{business.category}</span>
|
||||||
|
<h2>{business.name}</h2>
|
||||||
|
<p className="address">{business.address}</p>
|
||||||
|
<p className="description">{business.description}</p>
|
||||||
|
{business.contact && (
|
||||||
|
<p className="contact">
|
||||||
|
<strong>Contacto:</strong> {business.contact}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const EMPTY = {
|
||||||
|
name: "",
|
||||||
|
category: "",
|
||||||
|
address: "",
|
||||||
|
description: "",
|
||||||
|
contact: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SubmitForm({ pickedLocation, onSubmit, onCancel, onRepick }) {
|
||||||
|
const [values, setValues] = useState(EMPTY);
|
||||||
|
|
||||||
|
function update(field) {
|
||||||
|
return (e) => setValues({ ...values, [field]: e.target.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit({ ...values, lat: pickedLocation.lat, lng: pickedLocation.lng });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onCancel}>
|
||||||
|
<form
|
||||||
|
className="modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
<h2>Proponer un negocio</h2>
|
||||||
|
<p className="hint">
|
||||||
|
Los datos se revisarán antes de aparecer en el mapa.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Nombre
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={values.name}
|
||||||
|
onChange={update("name")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Categoría
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
placeholder="Cafetería, librería, taller…"
|
||||||
|
value={values.category}
|
||||||
|
onChange={update("category")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Dirección
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={values.address}
|
||||||
|
onChange={update("address")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Descripción
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
rows={3}
|
||||||
|
value={values.description}
|
||||||
|
onChange={update("description")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Contacto (opcional)
|
||||||
|
<input
|
||||||
|
placeholder="Email, teléfono o web"
|
||||||
|
value={values.contact}
|
||||||
|
onChange={update("contact")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="location-status">
|
||||||
|
{`Ubicación: ${pickedLocation.lat.toFixed(4)}, ${pickedLocation.lng.toFixed(4)}`}
|
||||||
|
<button type="button" className="repick-link" onClick={onRepick}>
|
||||||
|
Cambiar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" onClick={onCancel}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="primary">
|
||||||
|
Enviar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
// 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";
|
|
||||||
+8
-16
@@ -1,18 +1,10 @@
|
|||||||
import { StrictMode } from "react";
|
import React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App.jsx";
|
import App from "./App.jsx";
|
||||||
import AppMovil from "./AppMovil.jsx";
|
import "./styles.css";
|
||||||
import AdminPanel from "./AdminPanel.jsx";
|
|
||||||
|
|
||||||
// Enrutador mínimo sin dependencias extra
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
const ruta = window.location.pathname.replace(/\/$/, "");
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
function Root() {
|
</React.StrictMode>
|
||||||
if (ruta === "/admin") return <AdminPanel />;
|
|
||||||
if (ruta === "/movil") return <AppMovil />;
|
|
||||||
return <App />;
|
|
||||||
}
|
|
||||||
|
|
||||||
createRoot(document.getElementById("root")).render(
|
|
||||||
<StrictMode><Root /></StrictMode>
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export async function getApproved() {
|
||||||
|
const res = await fetch(`${import.meta.env.BASE_URL}api/businesses`);
|
||||||
|
if (!res.ok) throw new Error("Error cargando negocios");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submit(data) {
|
||||||
|
const res = await fetch(`${import.meta.env.BASE_URL}api/businesses`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Error enviando propuesta");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPending() {
|
||||||
|
const res = await fetch(`${import.meta.env.BASE_URL}api/admin/pending`);
|
||||||
|
if (!res.ok) throw new Error("Error cargando pendientes");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setStatus(id, status) {
|
||||||
|
const res = await fetch(`${import.meta.env.BASE_URL}api/admin/businesses/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Error actualizando estado");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
+377
@@ -0,0 +1,377 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #fafaf7;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--muted: #6b6b6b;
|
||||||
|
--border: #e6e6e2;
|
||||||
|
--accent: #2f6f4f;
|
||||||
|
--accent-hover: #245a3f;
|
||||||
|
--shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
--sidebar-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
background: var(--surface);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detalles */
|
||||||
|
.details {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details h2 {
|
||||||
|
margin: 12px 0 4px;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mapa */
|
||||||
|
.map {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-hint {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Botones */
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2000;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 28px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal .hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal input,
|
||||||
|
.modal textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-top: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal input:focus,
|
||||||
|
.modal textarea:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -1px;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-status {
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repick-link {
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border-color: var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repick-link:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admin */
|
||||||
|
.admin-btn {
|
||||||
|
margin-top: 8px;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel {
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header .close {
|
||||||
|
position: static;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-empty {
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-item-info {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-item-info strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-item-info p {
|
||||||
|
margin: 2px 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-desc {
|
||||||
|
color: var(--text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-contact, .admin-coords {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions .approve {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions .approve:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions .reject {
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #c0392b;
|
||||||
|
border-color: #e0b0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions .reject:hover {
|
||||||
|
background: #fff5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.app {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 45%;
|
||||||
|
border-right: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-10
@@ -1,14 +1,23 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from "vite";
|
||||||
import react from '@vitejs/plugin-react'
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
base: "/",
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
host: "0.0.0.0",
|
||||||
'/api': {
|
strictPort: true,
|
||||||
target: 'http://localhost:3000',
|
allowedHosts: true,
|
||||||
changeOrigin: 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user