Despliegue en VPS
This commit is contained in:
Generated
+3241
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,14 @@
|
||||
"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",
|
||||
@@ -16,6 +20,7 @@
|
||||
},
|
||||
"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}`));
|
||||
+36
-15
@@ -2,50 +2,71 @@ 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 [formOpen, setFormOpen] = useState(false);
|
||||
const [formStep, setFormStep] = useState(null); // null | 'picking' | 'editing'
|
||||
const [pickedLocation, setPickedLocation] = useState(null);
|
||||
const [adminOpen, setAdminOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setBusinesses(getApproved());
|
||||
}, []);
|
||||
async function loadBusinesses() {
|
||||
setBusinesses(await getApproved());
|
||||
}
|
||||
|
||||
useEffect(() => { loadBusinesses(); }, []);
|
||||
|
||||
const selected = businesses.find((b) => b.id === selectedId) || null;
|
||||
|
||||
function handleSubmit(data) {
|
||||
submit(data);
|
||||
setFormOpen(false);
|
||||
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={() => setFormOpen(true)}
|
||||
onContribute={() => setFormStep("picking")}
|
||||
onClose={() => setSelectedId(null)}
|
||||
picking={formStep === "picking"}
|
||||
onCancelPicking={handleCancel}
|
||||
onAdmin={() => setAdminOpen(true)}
|
||||
/>
|
||||
<MapView
|
||||
businesses={businesses}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
pickingLocation={formOpen}
|
||||
onPickLocation={setPickedLocation}
|
||||
pickingLocation={formStep === "picking"}
|
||||
onPickLocation={handlePickLocation}
|
||||
pickedLocation={pickedLocation}
|
||||
/>
|
||||
{formOpen && (
|
||||
{formStep === "editing" && (
|
||||
<SubmitForm
|
||||
pickedLocation={pickedLocation}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => {
|
||||
setFormOpen(false);
|
||||
setPickedLocation(null);
|
||||
}}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export default function Sidebar({ business, onContribute, onClose }) {
|
||||
export default function Sidebar({ business, onContribute, onClose, picking, onCancelPicking, onAdmin }) {
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<header className="sidebar-header">
|
||||
@@ -7,7 +7,11 @@ export default function Sidebar({ business, onContribute, onClose }) {
|
||||
</header>
|
||||
|
||||
<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} />
|
||||
) : (
|
||||
<Empty />
|
||||
@@ -15,9 +19,18 @@ export default function Sidebar({ business, onContribute, onClose }) {
|
||||
</div>
|
||||
|
||||
<footer className="sidebar-footer">
|
||||
<button className="primary" onClick={onContribute}>
|
||||
+ Añadir un negocio
|
||||
</button>
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ const EMPTY = {
|
||||
contact: "",
|
||||
};
|
||||
|
||||
export default function SubmitForm({ pickedLocation, onSubmit, onCancel }) {
|
||||
export default function SubmitForm({ pickedLocation, onSubmit, onCancel, onRepick }) {
|
||||
const [values, setValues] = useState(EMPTY);
|
||||
|
||||
function update(field) {
|
||||
@@ -17,10 +17,6 @@ export default function SubmitForm({ pickedLocation, onSubmit, onCancel }) {
|
||||
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (!pickedLocation) {
|
||||
alert("Selecciona una ubicación haciendo clic en el mapa.");
|
||||
return;
|
||||
}
|
||||
onSubmit({ ...values, lat: pickedLocation.lat, lng: pickedLocation.lng });
|
||||
}
|
||||
|
||||
@@ -84,9 +80,10 @@ export default function SubmitForm({ pickedLocation, onSubmit, onCancel }) {
|
||||
</label>
|
||||
|
||||
<div className="location-status">
|
||||
{pickedLocation
|
||||
? `Ubicación: ${pickedLocation.lat.toFixed(4)}, ${pickedLocation.lng.toFixed(4)}`
|
||||
: "Haz clic en el mapa para fijar la ubicación."}
|
||||
{`Ubicación: ${pickedLocation.lat.toFixed(4)}, ${pickedLocation.lng.toFixed(4)}`}
|
||||
<button type="button" className="repick-link" onClick={onRepick}>
|
||||
Cambiar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="actions">
|
||||
|
||||
@@ -1,65 +1,31 @@
|
||||
// Capa de acceso a datos. Toda la app habla con el backend a través de este
|
||||
// módulo. Para cambiar a Supabase u otro BaaS, basta con reescribir las dos
|
||||
// funciones exportadas manteniendo la misma firma.
|
||||
//
|
||||
// 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 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 function submit(data) {
|
||||
const newBusiness = {
|
||||
...data,
|
||||
id: crypto.randomUUID(),
|
||||
status: "pending",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
businesses = [...businesses, newBusiness];
|
||||
return newBusiness;
|
||||
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();
|
||||
}
|
||||
|
||||
+115
@@ -240,6 +240,25 @@ button.primary:hover {
|
||||
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 {
|
||||
@@ -248,6 +267,102 @@ button.primary:hover {
|
||||
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 {
|
||||
|
||||
@@ -3,4 +3,21 @@ 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