Despliegue en VPS
This commit is contained in:
+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 {
|
||||
|
||||
Reference in New Issue
Block a user