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
+36 -15
View File
@@ -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>
+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 (
<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>
);
+5 -8
View File
@@ -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">
+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
// 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
View File
@@ -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 {