Despliegue en VPS

This commit is contained in:
2026-06-07 21:25:45 +02:00
parent c06300833b
commit e08d959cf9
11 changed files with 3609 additions and 90 deletions
+3241
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -5,10 +5,14 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "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", "build": "vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^12.10.0",
"express": "^5.2.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -16,6 +20,7 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"concurrently": "^10.0.0",
"vite": "^5.4.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}`));
+36 -15
View File
@@ -2,50 +2,71 @@ import { useEffect, useState } from "react";
import MapView from "./components/MapView.jsx"; import MapView from "./components/MapView.jsx";
import Sidebar from "./components/Sidebar.jsx"; import Sidebar from "./components/Sidebar.jsx";
import SubmitForm from "./components/SubmitForm.jsx"; import SubmitForm from "./components/SubmitForm.jsx";
import AdminPanel from "./components/AdminPanel.jsx";
import { getApproved, submit } from "./services/businessRepository.js"; import { getApproved, submit } from "./services/businessRepository.js";
export default function App() { export default function App() {
const [businesses, setBusinesses] = useState([]); const [businesses, setBusinesses] = useState([]);
const [selectedId, setSelectedId] = useState(null); const [selectedId, setSelectedId] = useState(null);
const [formOpen, setFormOpen] = useState(false); const [formStep, setFormStep] = useState(null); // null | 'picking' | 'editing'
const [pickedLocation, setPickedLocation] = useState(null); const [pickedLocation, setPickedLocation] = useState(null);
const [adminOpen, setAdminOpen] = useState(false);
useEffect(() => { async function loadBusinesses() {
setBusinesses(getApproved()); setBusinesses(await getApproved());
}, []); }
useEffect(() => { loadBusinesses(); }, []);
const selected = businesses.find((b) => b.id === selectedId) || null; const selected = businesses.find((b) => b.id === selectedId) || null;
function handleSubmit(data) { function handlePickLocation(loc) {
submit(data); setPickedLocation(loc);
setFormOpen(false); if (formStep === "picking") setFormStep("editing");
}
async function handleSubmit(data) {
await submit(data);
setFormStep(null);
setPickedLocation(null); setPickedLocation(null);
alert("¡Gracias! Tu propuesta queda pendiente de revisión."); alert("¡Gracias! Tu propuesta queda pendiente de revisión.");
} }
function handleCancel() {
setFormStep(null);
setPickedLocation(null);
}
return ( return (
<div className="app"> <div className="app">
<Sidebar <Sidebar
business={selected} business={selected}
onContribute={() => setFormOpen(true)} onContribute={() => setFormStep("picking")}
onClose={() => setSelectedId(null)} onClose={() => setSelectedId(null)}
picking={formStep === "picking"}
onCancelPicking={handleCancel}
onAdmin={() => setAdminOpen(true)}
/> />
<MapView <MapView
businesses={businesses} businesses={businesses}
selectedId={selectedId} selectedId={selectedId}
onSelect={setSelectedId} onSelect={setSelectedId}
pickingLocation={formOpen} pickingLocation={formStep === "picking"}
onPickLocation={setPickedLocation} onPickLocation={handlePickLocation}
pickedLocation={pickedLocation} pickedLocation={pickedLocation}
/> />
{formOpen && ( {formStep === "editing" && (
<SubmitForm <SubmitForm
pickedLocation={pickedLocation} pickedLocation={pickedLocation}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onCancel={() => { onCancel={handleCancel}
setFormOpen(false); onRepick={() => setFormStep("picking")}
setPickedLocation(null); />
}} )}
{adminOpen && (
<AdminPanel
onClose={() => setAdminOpen(false)}
onApproved={loadBusinesses}
/> />
)} )}
</div> </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>
);
}
+18 -5
View File
@@ -1,4 +1,4 @@
export default function Sidebar({ business, onContribute, onClose }) { export default function Sidebar({ business, onContribute, onClose, picking, onCancelPicking, onAdmin }) {
return ( return (
<aside className="sidebar"> <aside className="sidebar">
<header className="sidebar-header"> <header className="sidebar-header">
@@ -7,7 +7,11 @@ export default function Sidebar({ business, onContribute, onClose }) {
</header> </header>
<div className="sidebar-body"> <div className="sidebar-body">
{business ? ( {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} /> <BusinessDetails business={business} onClose={onClose} />
) : ( ) : (
<Empty /> <Empty />
@@ -15,9 +19,18 @@ export default function Sidebar({ business, onContribute, onClose }) {
</div> </div>
<footer className="sidebar-footer"> <footer className="sidebar-footer">
<button className="primary" onClick={onContribute}> {picking ? (
+ Añadir un negocio <button onClick={onCancelPicking}>Cancelar</button>
</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> </footer>
</aside> </aside>
); );
+5 -8
View File
@@ -8,7 +8,7 @@ const EMPTY = {
contact: "", contact: "",
}; };
export default function SubmitForm({ pickedLocation, onSubmit, onCancel }) { export default function SubmitForm({ pickedLocation, onSubmit, onCancel, onRepick }) {
const [values, setValues] = useState(EMPTY); const [values, setValues] = useState(EMPTY);
function update(field) { function update(field) {
@@ -17,10 +17,6 @@ export default function SubmitForm({ pickedLocation, onSubmit, onCancel }) {
function handleSubmit(e) { function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
if (!pickedLocation) {
alert("Selecciona una ubicación haciendo clic en el mapa.");
return;
}
onSubmit({ ...values, lat: pickedLocation.lat, lng: pickedLocation.lng }); onSubmit({ ...values, lat: pickedLocation.lat, lng: pickedLocation.lng });
} }
@@ -84,9 +80,10 @@ export default function SubmitForm({ pickedLocation, onSubmit, onCancel }) {
</label> </label>
<div className="location-status"> <div className="location-status">
{pickedLocation {`Ubicación: ${pickedLocation.lat.toFixed(4)}, ${pickedLocation.lng.toFixed(4)}`}
? `Ubicación: ${pickedLocation.lat.toFixed(4)}, ${pickedLocation.lng.toFixed(4)}` <button type="button" className="repick-link" onClick={onRepick}>
: "Haz clic en el mapa para fijar la ubicación."} Cambiar
</button>
</div> </div>
<div className="actions"> <div className="actions">
+28 -62
View File
@@ -1,65 +1,31 @@
// Capa de acceso a datos. Toda la app habla con el backend a través de este export async function getApproved() {
// módulo. Para cambiar a Supabase u otro BaaS, basta con reescribir las dos const res = await fetch(`${import.meta.env.BASE_URL}api/businesses`);
// funciones exportadas manteniendo la misma firma. if (!res.ok) throw new Error("Error cargando negocios");
// return res.json();
// Modelo de negocio:
// { id, name, category, address, description, contact, lat, lng, status, createdAt }
//
// `status` es uno de: "pending" | "approved" | "rejected".
const sampleBusinesses = [
{
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",
},
];
// Estado en memoria. Se reinicia al recargar. Suficiente para el prototipo.
let businesses = [...sampleBusinesses];
export function getApproved() {
return businesses.filter((b) => b.status === "approved");
} }
export function submit(data) { export async function submit(data) {
const newBusiness = { const res = await fetch(`${import.meta.env.BASE_URL}api/businesses`, {
...data, method: "POST",
id: crypto.randomUUID(), headers: { "Content-Type": "application/json" },
status: "pending", body: JSON.stringify(data),
createdAt: new Date().toISOString(), });
}; if (!res.ok) throw new Error("Error enviando propuesta");
businesses = [...businesses, newBusiness]; return res.json();
return newBusiness; }
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();
} }
+115
View File
@@ -240,6 +240,25 @@ button.primary:hover {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--muted); color: var(--muted);
margin-bottom: 16px; 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 { .actions {
@@ -248,6 +267,102 @@ button.primary:hover {
justify-content: flex-end; 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 */ /* Responsive */
@media (max-width: 720px) { @media (max-width: 720px) {
.app { .app {
+17
View File
@@ -3,4 +3,21 @@ import react from "@vitejs/plugin-react";
export default defineConfig({ export default defineConfig({
plugins: [react()], 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",
},
},
}); });