Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e08d959cf9 | ||
|
|
c06300833b |
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: Bug
|
||||
about: Reportar un error
|
||||
labels: bug
|
||||
---
|
||||
|
||||
**Qué pasa**
|
||||
Descripción breve.
|
||||
|
||||
**Pasos para reproducir**
|
||||
1.
|
||||
2.
|
||||
|
||||
**Qué esperabas**
|
||||
|
||||
**Entorno**
|
||||
- Navegador:
|
||||
- Sistema operativo:
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: Propuesta de mejora
|
||||
about: Sugerir una funcionalidad o mejora
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
**Problema o necesidad**
|
||||
¿Qué te falta? ¿Quién la usaría?
|
||||
|
||||
**Propuesta**
|
||||
Cómo lo resolverías. Mantenlo simple: si la propuesta requiere muchas piezas
|
||||
nuevas, conviene discutirla antes en Discussions.
|
||||
|
||||
**Alternativas que has considerado**
|
||||
@@ -0,0 +1,13 @@
|
||||
## Qué cambia
|
||||
|
||||
<!-- En una o dos frases. -->
|
||||
|
||||
## Por qué
|
||||
|
||||
<!-- Issue relacionado o motivación. -->
|
||||
|
||||
## Notas para el revisor
|
||||
|
||||
- [ ] La app arranca con `npm run dev`.
|
||||
- [ ] No he añadido dependencias innecesarias.
|
||||
- [ ] He seguido la estructura existente.
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
*.log
|
||||
.vscode
|
||||
.idea
|
||||
.env
|
||||
.env.local
|
||||
@@ -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,38 @@
|
||||
# LocalESP
|
||||
Registro colaborativo de negocios españoles
|
||||
# Business Map
|
||||
|
||||
Registro colaborativo de negocios, en un mapa abierto.
|
||||
|
||||
Prototipo open source mantenido por **[ORGANIZACIÓN]**. Cualquiera puede ver
|
||||
los negocios registrados y proponer nuevos; las propuestas son revisadas
|
||||
antes de aparecer públicamente.
|
||||
|
||||
## Demo local
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Abre la URL que indique Vite (normalmente `http://localhost:5173`).
|
||||
|
||||
## Stack
|
||||
|
||||
- React 18 + Vite
|
||||
- Leaflet + OpenStreetMap (mapa)
|
||||
- CSS plano
|
||||
|
||||
Sin backend en el prototipo: los datos vienen de un mock en memoria. Ver
|
||||
`docs/02-implementation-alternatives.md` para la decisión de backend.
|
||||
|
||||
## Documentación
|
||||
|
||||
- [`docs/01-use-cases.md`](docs/01-use-cases.md) – qué hace y qué no.
|
||||
- [`docs/02-implementation-alternatives.md`](docs/02-implementation-alternatives.md) – decisiones técnicas.
|
||||
- [`docs/03-decisions.md`](docs/03-decisions.md) – ADRs.
|
||||
- [`docs/04-governance.md`](docs/04-governance.md) – cómo se gobierna el proyecto.
|
||||
- [`docs/05-contributing.md`](docs/05-contributing.md) – cómo contribuir.
|
||||
- [`docs/06-github-setup.md`](docs/06-github-setup.md) – configuración inicial del repo.
|
||||
|
||||
## 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.
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Business Map</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
crossorigin=""
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3241
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "business-map",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"server": "node server/index.js",
|
||||
"dev:all": "concurrently -k -n vite,api -c cyan,green \"npm run dev\" \"npm run server\"",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"express": "^5.2.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"concurrently": "^10.0.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const db = new Database(join(__dirname, "..", "localesp.db"));
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS businesses (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
contact TEXT NOT NULL DEFAULT '',
|
||||
lat REAL NOT NULL,
|
||||
lng REAL NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
createdAt TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,51 @@
|
||||
import express from "express";
|
||||
import { randomUUID } from "crypto";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
import db from "./db.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(express.static(join(__dirname, "../dist")));
|
||||
|
||||
app.get("/api/businesses", (_req, res) => {
|
||||
res.json(db.prepare("SELECT * FROM businesses WHERE status = 'approved' ORDER BY createdAt DESC").all());
|
||||
});
|
||||
|
||||
app.post("/api/businesses", (req, res) => {
|
||||
const { name, category, address, description, contact, lat, lng } = req.body;
|
||||
const business = {
|
||||
id: randomUUID(),
|
||||
name, category, address, description,
|
||||
contact: contact || "",
|
||||
lat, lng,
|
||||
status: "pending",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
db.prepare(`
|
||||
INSERT INTO businesses (id, name, category, address, description, contact, lat, lng, status, createdAt)
|
||||
VALUES (@id, @name, @category, @address, @description, @contact, @lat, @lng, @status, @createdAt)
|
||||
`).run(business);
|
||||
res.status(201).json(business);
|
||||
});
|
||||
|
||||
app.get("/api/admin/pending", (_req, res) => {
|
||||
res.json(db.prepare("SELECT * FROM businesses WHERE status = 'pending' ORDER BY createdAt ASC").all());
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
app.get(/^\/(?!api\/).*/, (_req, res) => {
|
||||
res.sendFile(join(__dirname, "../dist/index.html"));
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => console.log(`API listening on http://localhost:${PORT}`));
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import MapView from "./components/MapView.jsx";
|
||||
import Sidebar from "./components/Sidebar.jsx";
|
||||
import SubmitForm from "./components/SubmitForm.jsx";
|
||||
import AdminPanel from "./components/AdminPanel.jsx";
|
||||
import { getApproved, submit } from "./services/businessRepository.js";
|
||||
|
||||
export default function App() {
|
||||
const [businesses, setBusinesses] = useState([]);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [formStep, setFormStep] = useState(null); // null | 'picking' | 'editing'
|
||||
const [pickedLocation, setPickedLocation] = useState(null);
|
||||
const [adminOpen, setAdminOpen] = useState(false);
|
||||
|
||||
async function loadBusinesses() {
|
||||
setBusinesses(await getApproved());
|
||||
}
|
||||
|
||||
useEffect(() => { loadBusinesses(); }, []);
|
||||
|
||||
const selected = businesses.find((b) => b.id === selectedId) || null;
|
||||
|
||||
function handlePickLocation(loc) {
|
||||
setPickedLocation(loc);
|
||||
if (formStep === "picking") setFormStep("editing");
|
||||
}
|
||||
|
||||
async function handleSubmit(data) {
|
||||
await submit(data);
|
||||
setFormStep(null);
|
||||
setPickedLocation(null);
|
||||
alert("¡Gracias! Tu propuesta queda pendiente de revisión.");
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setFormStep(null);
|
||||
setPickedLocation(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Sidebar
|
||||
business={selected}
|
||||
onContribute={() => setFormStep("picking")}
|
||||
onClose={() => setSelectedId(null)}
|
||||
picking={formStep === "picking"}
|
||||
onCancelPicking={handleCancel}
|
||||
onAdmin={() => setAdminOpen(true)}
|
||||
/>
|
||||
<MapView
|
||||
businesses={businesses}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
pickingLocation={formStep === "picking"}
|
||||
onPickLocation={handlePickLocation}
|
||||
pickedLocation={pickedLocation}
|
||||
/>
|
||||
{formStep === "editing" && (
|
||||
<SubmitForm
|
||||
pickedLocation={pickedLocation}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
onRepick={() => setFormStep("picking")}
|
||||
/>
|
||||
)}
|
||||
{adminOpen && (
|
||||
<AdminPanel
|
||||
onClose={() => setAdminOpen(false)}
|
||||
onApproved={loadBusinesses}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.jsx";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: "/",
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
strictPort: true,
|
||||
allowedHosts: true,
|
||||
cors: {
|
||||
origin: "*",
|
||||
methods: "GET,HEAD,PUT,PATCH,POST,DELETE",
|
||||
preflightContinue: false,
|
||||
optionsSuccessStatus: 204,
|
||||
},
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET,HEAD,PUT,PATCH,POST,DELETE",
|
||||
"Access-Control-Allow-Headers": "Content-Type,Authorization",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user