Author SHA1 Message Date
edgar.friendly e08d959cf9 Despliegue en VPS 2026-06-07 21:25:45 +02:00
edgar.friendly c06300833b Primer prototipo 2026-05-20 14:05:55 +02:00
26 changed files with 4634 additions and 2 deletions
+18
View File
@@ -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:
+14
View File
@@ -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**
+13
View File
@@ -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.
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
.DS_Store
*.log
.vscode
.idea
.env
.env.local
+21
View File
@@ -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.
+38 -2
View File
@@ -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).
+51
View File
@@ -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.
+85
View File
@@ -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`.
+49
View File
@@ -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.
+40
View File
@@ -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.
+46
View File
@@ -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.
+76
View File
@@ -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
View File
@@ -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>
+3241
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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"
}
}
+38
View File
@@ -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;
+51
View File
@@ -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
View File
@@ -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>
);
}
+55
View File
@@ -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>
);
}
+68
View File
@@ -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='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{businesses.map((b) => (
<Marker
key={b.id}
position={[b.lat, b.lng]}
icon={icon}
eventHandlers={{ click: () => onSelect(b.id) }}
opacity={selectedId && selectedId !== b.id ? 0.6 : 1}
/>
))}
{pickingLocation && <LocationPicker onPick={onPickLocation} />}
{pickedLocation && (
<Marker
position={[pickedLocation.lat, pickedLocation.lng]}
icon={icon}
/>
)}
</MapContainer>
{pickingLocation && (
<div className="map-hint">Haz clic en el mapa para fijar la ubicación</div>
)}
</div>
);
}
+64
View File
@@ -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>
);
}
+100
View File
@@ -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>
);
}
+10
View File
@@ -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>
);
+31
View File
@@ -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
View File
@@ -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);
}
}
+23
View File
@@ -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",
},
},
});