Prototipo YB01 #1
@@ -43,6 +43,15 @@ db.exec(`
|
|||||||
datos TEXT
|
datos TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS reportes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
localId TEXT NOT NULL,
|
||||||
|
motivo TEXT,
|
||||||
|
comentario TEXT,
|
||||||
|
fecha TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
estado TEXT DEFAULT 'pendiente'
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
email TEXT UNIQUE NOT NULL,
|
email TEXT UNIQUE NOT NULL,
|
||||||
@@ -69,5 +78,10 @@ for (const table of ["locales", "pendientes"]) {
|
|||||||
ensureColumn(table, "enlaceGoogleMaps", "TEXT");
|
ensureColumn(table, "enlaceGoogleMaps", "TEXT");
|
||||||
ensureColumn(table, "puntuacion", "INTEGER DEFAULT 0");
|
ensureColumn(table, "puntuacion", "INTEGER DEFAULT 0");
|
||||||
}
|
}
|
||||||
|
// fechaConfirmacion: última vez que alguien confirmó que el local sigue activo.
|
||||||
|
// Al aprobarse por primera vez se inicializa igual a "fecha"; luego se actualiza
|
||||||
|
// con /api/locales/:id/confirmar cada vez que un usuario pulsa "Sigue aquí".
|
||||||
|
ensureColumn("locales", "fechaConfirmacion", "TEXT");
|
||||||
|
db.prepare("UPDATE locales SET fechaConfirmacion = fecha WHERE fechaConfirmacion IS NULL").run();
|
||||||
|
|
||||||
export default db;
|
export default db;
|
||||||
|
|||||||
+81
-3
@@ -22,6 +22,41 @@ app.get("/api/pendientes", (_req, res) => {
|
|||||||
res.json(pendientes);
|
res.json(pendientes);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Utilidades para detectar duplicados
|
||||||
|
function normaliza(s) {
|
||||||
|
return (s || "").toString().toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim();
|
||||||
|
}
|
||||||
|
function distanciaMetros(lat1, lng1, lat2, lng2) {
|
||||||
|
const R = 6371000;
|
||||||
|
const toRad = (d) => (d * Math.PI) / 180;
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLng = toRad(lng2 - lng1);
|
||||||
|
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||||
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comprueba si ya existe un local con el mismo nombre (en la misma provincia o muy
|
||||||
|
// cerca geográficamente) entre los ya publicados y los pendientes de revisión.
|
||||||
|
app.post("/api/check-duplicado", (req, res) => {
|
||||||
|
const { nombre, provincia, lat, lng } = req.body;
|
||||||
|
const nombreN = normaliza(nombre);
|
||||||
|
if (!nombreN) return res.json({ duplicado: false, coincidencias: [] });
|
||||||
|
|
||||||
|
const candidatos = [
|
||||||
|
...db.prepare("SELECT id, nombre, provincia, direccion, categoria, lat, lng FROM locales").all().map((l) => ({ ...l, estado: "publicado" })),
|
||||||
|
...db.prepare("SELECT id, nombre, provincia, direccion, categoria, lat, lng FROM pendientes").all().map((l) => ({ ...l, estado: "pendiente" })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const coincidencias = candidatos.filter((l) => {
|
||||||
|
if (normaliza(l.nombre) !== nombreN) return false;
|
||||||
|
const mismaProvincia = provincia && normaliza(l.provincia) === normaliza(provincia);
|
||||||
|
const cerca = lat && lng && l.lat && l.lng && distanciaMetros(lat, lng, l.lat, l.lng) < 200;
|
||||||
|
return mismaProvincia || cerca;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ duplicado: coincidencias.length > 0, coincidencias });
|
||||||
|
});
|
||||||
|
|
||||||
// Enviar propuesta
|
// Enviar propuesta
|
||||||
app.post("/api/propuestas", (req, res) => {
|
app.post("/api/propuestas", (req, res) => {
|
||||||
const { nombre, descripcion, provincia, subcategoria, direccion, enlaceGoogleMaps, puntuacion, categoria, lat, lng } = req.body;
|
const { nombre, descripcion, provincia, subcategoria, direccion, enlaceGoogleMaps, puntuacion, categoria, lat, lng } = req.body;
|
||||||
@@ -47,9 +82,9 @@ app.post("/api/aprobar/:id", (req, res) => {
|
|||||||
const fecha = new Date().toISOString();
|
const fecha = new Date().toISOString();
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO locales (id, nombre, descripcion, provincia, subcategoria, direccion, enlaceGoogleMaps, puntuacion, categoria, fecha, lat, lng)
|
INSERT INTO locales (id, nombre, descripcion, provincia, subcategoria, direccion, enlaceGoogleMaps, puntuacion, categoria, fecha, lat, lng, fechaConfirmacion)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(localeId, propuesta.nombre, propuesta.descripcion, propuesta.provincia, propuesta.subcategoria, propuesta.direccion, propuesta.enlaceGoogleMaps, propuesta.puntuacion ?? 0, propuesta.categoria, fecha, propuesta.lat, propuesta.lng);
|
`).run(localeId, propuesta.nombre, propuesta.descripcion, propuesta.provincia, propuesta.subcategoria, propuesta.direccion, propuesta.enlaceGoogleMaps, propuesta.puntuacion ?? 0, propuesta.categoria, fecha, propuesta.lat, propuesta.lng, fecha);
|
||||||
|
|
||||||
db.prepare("DELETE FROM pendientes WHERE id = ?").run(id);
|
db.prepare("DELETE FROM pendientes WHERE id = ?").run(id);
|
||||||
|
|
||||||
@@ -67,6 +102,49 @@ app.post("/api/rechazar/:id", (req, res) => {
|
|||||||
app.delete("/api/locales/:id", (req, res) => {
|
app.delete("/api/locales/:id", (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
db.prepare("DELETE FROM locales WHERE id = ?").run(id);
|
db.prepare("DELETE FROM locales WHERE id = ?").run(id);
|
||||||
|
db.prepare("DELETE FROM reportes WHERE localId = ?").run(id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Confirmar que un local sigue activo (renueva fechaConfirmacion)
|
||||||
|
app.post("/api/locales/:id/confirmar", (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const local = db.prepare("SELECT id FROM locales WHERE id = ?").get(id);
|
||||||
|
if (!local) return res.status(404).json({ error: "Local no encontrado" });
|
||||||
|
const ahora = new Date().toISOString();
|
||||||
|
db.prepare("UPDATE locales SET fechaConfirmacion = ? WHERE id = ?").run(ahora, id);
|
||||||
|
res.json({ ok: true, fechaConfirmacion: ahora });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reportar un local (cerrado / datos incorrectos / inapropiado / otro)
|
||||||
|
app.post("/api/locales/:id/reportar", (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { motivo, comentario } = req.body;
|
||||||
|
const local = db.prepare("SELECT id FROM locales WHERE id = ?").get(id);
|
||||||
|
if (!local) return res.status(404).json({ error: "Local no encontrado" });
|
||||||
|
const reporteId = randomUUID();
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO reportes (id, localId, motivo, comentario, fecha, estado)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'pendiente')
|
||||||
|
`).run(reporteId, id, motivo || "otro", comentario || "", new Date().toISOString());
|
||||||
|
res.status(201).json({ ok: true, id: reporteId });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reportes (admin)
|
||||||
|
app.get("/api/reportes", (_req, res) => {
|
||||||
|
const reportes = db.prepare(`
|
||||||
|
SELECT r.*, l.nombre AS localNombre, l.provincia AS localProvincia, l.direccion AS localDireccion
|
||||||
|
FROM reportes r LEFT JOIN locales l ON l.id = r.localId
|
||||||
|
WHERE r.estado = 'pendiente'
|
||||||
|
ORDER BY r.fecha DESC
|
||||||
|
`).all();
|
||||||
|
res.json(reportes);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Descartar un reporte (el admin revisó y no hace falta actuar)
|
||||||
|
app.post("/api/reportes/:id/descartar", (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
db.prepare("UPDATE reportes SET estado = 'descartado' WHERE id = ?").run(id);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+56
-4
@@ -5,9 +5,11 @@ import {
|
|||||||
escucharAuth, loginAdmin, registrarAdmin, cerrarSesion,
|
escucharAuth, loginAdmin, registrarAdmin, cerrarSesion,
|
||||||
suscribirPendientes, aprobarPropuesta, rechazarPropuesta,
|
suscribirPendientes, aprobarPropuesta, rechazarPropuesta,
|
||||||
suscribirLocales, deleteLocal,
|
suscribirLocales, deleteLocal,
|
||||||
|
obtenerReportes, descartarReporte,
|
||||||
obtenerPalabrasFiltradas, guardarPalabrasFiltradas,
|
obtenerPalabrasFiltradas, guardarPalabrasFiltradas,
|
||||||
} from "./firebase.js";
|
} from "./firebase.js";
|
||||||
import { obtenerCategoria } from "./constants/categorias.js";
|
import { obtenerCategoria } from "./constants/categorias.js";
|
||||||
|
import { enlaceGoogleMapsDesdeLocal, enlaceWazeDesdeLocal } from "./utils/geo.js";
|
||||||
import StarRating from "./components/StarRating.jsx";
|
import StarRating from "./components/StarRating.jsx";
|
||||||
|
|
||||||
const S = {
|
const S = {
|
||||||
@@ -81,6 +83,8 @@ function PantallaAuth() {
|
|||||||
function TarjetaPropuesta({ p, onAprobar, onRechazar, busy }) {
|
function TarjetaPropuesta({ p, onAprobar, onRechazar, busy }) {
|
||||||
const cat = obtenerCategoria(p.categoria);
|
const cat = obtenerCategoria(p.categoria);
|
||||||
const fecha = new Date(p.fechaPropuesta).toLocaleDateString("es-ES", { day:"numeric", month:"short", hour:"2-digit", minute:"2-digit" });
|
const fecha = new Date(p.fechaPropuesta).toLocaleDateString("es-ES", { day:"numeric", month:"short", hour:"2-digit", minute:"2-digit" });
|
||||||
|
const gmUrl = p.enlaceGoogleMaps || enlaceGoogleMapsDesdeLocal(p);
|
||||||
|
const wazeUrl = enlaceWazeDesdeLocal(p);
|
||||||
return (
|
return (
|
||||||
<div style={{ ...S.card, borderLeft:`4px solid ${cat.color}`, marginBottom:10 }}>
|
<div style={{ ...S.card, borderLeft:`4px solid ${cat.color}`, marginBottom:10 }}>
|
||||||
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"flex-start", flexWrap:"wrap", gap:12 }}>
|
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"flex-start", flexWrap:"wrap", gap:12 }}>
|
||||||
@@ -92,12 +96,15 @@ function TarjetaPropuesta({ p, onAprobar, onRechazar, busy }) {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ display:"flex", flexWrap:"wrap", alignItems:"center", gap:16, fontSize:13, color:"#666" }}>
|
<div style={{ display:"flex", flexWrap:"wrap", alignItems:"center", gap:16, fontSize:13, color:"#666" }}>
|
||||||
<span>📍 {p.provincia}</span>
|
<span>📍 {p.provincia}</span>
|
||||||
{p.direccion && <span>🗺 {p.direccion}</span>}
|
{p.direccion && (gmUrl ? <a href={gmUrl} target="_blank" rel="noopener noreferrer" style={{ color:"#666", textDecoration:"underline" }}>🗺 {p.direccion}</a> : <span>🗺 {p.direccion}</span>)}
|
||||||
<StarRating value={p.puntuacion} readOnly size={14} />
|
<StarRating value={p.puntuacion} readOnly size={14} />
|
||||||
<span style={{ color:"#AAA" }}>Enviado: {fecha}</span>
|
<span style={{ color:"#AAA" }}>Enviado: {fecha}</span>
|
||||||
</div>
|
</div>
|
||||||
{p.descripcion && <p style={{ margin:"8px 0 0", fontSize:13, color:"#666", fontStyle:"italic", background:"#FAFAFA", padding:"8px 12px", borderRadius:8 }}>"{p.descripcion}"</p>}
|
{p.descripcion && <p style={{ margin:"8px 0 0", fontSize:13, color:"#666", fontStyle:"italic", background:"#FAFAFA", padding:"8px 12px", borderRadius:8 }}>"{p.descripcion}"</p>}
|
||||||
{p.enlaceGoogleMaps && <a href={p.enlaceGoogleMaps} target="_blank" rel="noopener noreferrer" style={{ display:"inline-block", marginTop:6, fontSize:12, color:"#1A73E8" }}>Ver en Google Maps ↗</a>}
|
<div style={{ display:"flex", gap:10 }}>
|
||||||
|
{gmUrl && <a href={gmUrl} target="_blank" rel="noopener noreferrer" style={{ display:"inline-block", marginTop:6, fontSize:12, color:"#1A73E8" }}>Ver en Google Maps ↗</a>}
|
||||||
|
{wazeUrl && <a href={wazeUrl} target="_blank" rel="noopener noreferrer" style={{ display:"inline-block", marginTop:6, fontSize:12, color:"#0A9FD9" }}>Ver en Waze ↗</a>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display:"flex", gap:8, flexShrink:0 }}>
|
<div style={{ display:"flex", gap:8, flexShrink:0 }}>
|
||||||
<button onClick={() => onAprobar(p)} disabled={busy} style={{ ...S.btn("#1E8449"), padding:"8px 16px", fontSize:13 }}>✓ Aprobar</button>
|
<button onClick={() => onAprobar(p)} disabled={busy} style={{ ...S.btn("#1E8449"), padding:"8px 16px", fontSize:13 }}>✓ Aprobar</button>
|
||||||
@@ -113,6 +120,7 @@ function PanelAdmin({ email }) {
|
|||||||
const [tab, setTab] = useState("pendientes");
|
const [tab, setTab] = useState("pendientes");
|
||||||
const [pendientes, setPendientes] = useState([]);
|
const [pendientes, setPendientes] = useState([]);
|
||||||
const [publicados, setPublicados] = useState([]);
|
const [publicados, setPublicados] = useState([]);
|
||||||
|
const [reportes, setReportes] = useState([]);
|
||||||
const [palabras, setPalabras] = useState([]);
|
const [palabras, setPalabras] = useState([]);
|
||||||
const [nuevaP, setNuevaP] = useState("");
|
const [nuevaP, setNuevaP] = useState("");
|
||||||
const [cargando, setCargando] = useState(true);
|
const [cargando, setCargando] = useState(true);
|
||||||
@@ -120,6 +128,8 @@ function PanelAdmin({ email }) {
|
|||||||
const [guardando, setGuardando] = useState(false);
|
const [guardando, setGuardando] = useState(false);
|
||||||
const [buscador, setBuscador] = useState("");
|
const [buscador, setBuscador] = useState("");
|
||||||
|
|
||||||
|
const cargarReportes = () => obtenerReportes().then(setReportes).catch(() => {});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCargando(true);
|
setCargando(true);
|
||||||
let cancelado = false;
|
let cancelado = false;
|
||||||
@@ -129,12 +139,14 @@ function PanelAdmin({ email }) {
|
|||||||
suscribirLocales(l => !cancelado && setPublicados(l), () => {})
|
suscribirLocales(l => !cancelado && setPublicados(l), () => {})
|
||||||
.then(fn => { if (cancelado) fn?.(); else limpiarPublicados = fn; });
|
.then(fn => { if (cancelado) fn?.(); else limpiarPublicados = fn; });
|
||||||
obtenerPalabrasFiltradas().then(p => !cancelado && setPalabras(p));
|
obtenerPalabrasFiltradas().then(p => !cancelado && setPalabras(p));
|
||||||
|
cargarReportes();
|
||||||
return () => { cancelado = true; limpiarPendientes?.(); limpiarPublicados?.(); };
|
return () => { cancelado = true; limpiarPendientes?.(); limpiarPublicados?.(); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const aprobar = async (p) => { setBusy(true); try { await aprobarPropuesta(p); } catch(e) { alert("Error: "+e.message); } setBusy(false); };
|
const aprobar = async (p) => { setBusy(true); try { await aprobarPropuesta(p); } catch(e) { alert("Error: "+e.message); } setBusy(false); };
|
||||||
const rechazar = async (id) => { if (!confirm("¿Rechazar y eliminar esta propuesta?")) return; try { await rechazarPropuesta(id); } catch(e) { alert("Error: "+e.message); } };
|
const rechazar = async (id) => { if (!confirm("¿Rechazar y eliminar esta propuesta?")) return; try { await rechazarPropuesta(id); } catch(e) { alert("Error: "+e.message); } };
|
||||||
const eliminar = async (id) => { if (!confirm("¿Eliminar este local publicado?")) return; try { await deleteLocal(id); } catch(e) { alert("Error: "+e.message); } };
|
const eliminar = async (id) => { if (!confirm("¿Eliminar este local publicado?")) return; try { await deleteLocal(id); cargarReportes(); } catch(e) { alert("Error: "+e.message); } };
|
||||||
|
const descartar = async (id) => { try { await descartarReporte(id); setReportes(r => r.filter(x => x.id !== id)); } catch(e) { alert("Error: "+e.message); } };
|
||||||
|
|
||||||
const addPalabra = () => {
|
const addPalabra = () => {
|
||||||
const p = nuevaP.trim().toLowerCase();
|
const p = nuevaP.trim().toLowerCase();
|
||||||
@@ -155,6 +167,7 @@ function PanelAdmin({ email }) {
|
|||||||
const TABS = [
|
const TABS = [
|
||||||
{ id:"pendientes", l:`Pendientes (${pendientes.length})` },
|
{ id:"pendientes", l:`Pendientes (${pendientes.length})` },
|
||||||
{ id:"publicados", l:`Publicados (${publicados.length})` },
|
{ id:"publicados", l:`Publicados (${publicados.length})` },
|
||||||
|
{ id:"reportes", l:`Reportes${reportes.length ? ` (${reportes.length})` : ""}` },
|
||||||
{ id:"filtro", l:"Filtro de palabras" },
|
{ id:"filtro", l:"Filtro de palabras" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -215,7 +228,12 @@ function PanelAdmin({ email }) {
|
|||||||
{l.categoria && <span style={S.badge(obtenerCategoria(l.categoria).textColor, obtenerCategoria(l.categoria).bgLight)}>{obtenerCategoria(l.categoria).emoji} {l.categoria}</span>}
|
{l.categoria && <span style={S.badge(obtenerCategoria(l.categoria).textColor, obtenerCategoria(l.categoria).bgLight)}>{obtenerCategoria(l.categoria).emoji} {l.categoria}</span>}
|
||||||
<span style={{ fontSize:12, color:"#AAA" }}>📍 {l.provincia} · {new Date(l.fecha).toLocaleDateString("es-ES",{day:"numeric",month:"short",year:"numeric"})}</span>
|
<span style={{ fontSize:12, color:"#AAA" }}>📍 {l.provincia} · {new Date(l.fecha).toLocaleDateString("es-ES",{day:"numeric",month:"short",year:"numeric"})}</span>
|
||||||
</div>
|
</div>
|
||||||
{l.direccion && <p style={{ margin:"3px 0 0", fontSize:13, color:"#888" }}>{l.direccion}</p>}
|
{l.direccion && (() => {
|
||||||
|
const url = l.enlaceGoogleMaps || enlaceGoogleMapsDesdeLocal(l);
|
||||||
|
return url
|
||||||
|
? <a href={url} target="_blank" rel="noopener noreferrer" style={{ margin:"3px 0 0", fontSize:13, color:"#888", textDecoration:"underline", display:"block" }}>{l.direccion}</a>
|
||||||
|
: <p style={{ margin:"3px 0 0", fontSize:13, color:"#888" }}>{l.direccion}</p>;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => eliminar(l.id)} style={{ background:"none", border:"1px solid #FADADD", borderRadius:8, padding:"6px 14px", color:"#C0392B", cursor:"pointer", fontSize:12, fontWeight:600, flexShrink:0 }}>Eliminar</button>
|
<button onClick={() => eliminar(l.id)} style={{ background:"none", border:"1px solid #FADADD", borderRadius:8, padding:"6px 14px", color:"#C0392B", cursor:"pointer", fontSize:12, fontWeight:600, flexShrink:0 }}>Eliminar</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,6 +243,40 @@ function PanelAdmin({ email }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Reportes ── */}
|
||||||
|
{tab === "reportes" && (
|
||||||
|
reportes.length === 0 ? (
|
||||||
|
<div style={{ textAlign:"center", padding:"4rem", color:"#AAA" }}>
|
||||||
|
<div style={{ fontSize:52, marginBottom:12 }}>🚩</div>
|
||||||
|
<p style={{ margin:0, fontSize:16, fontWeight:600, color:"#888" }}>Sin reportes pendientes</p>
|
||||||
|
<p style={{ margin:"6px 0 0", fontSize:13 }}>Cuando alguien reporte un local (cerrado, datos incorrectos...), aparecerá aquí.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p style={{ margin:"0 0 16px", fontSize:13, color:"#888" }}>{reportes.length} reporte{reportes.length!==1?"s":""} sin revisar.</p>
|
||||||
|
{reportes.map(r => (
|
||||||
|
<div key={r.id} style={{ ...S.card, borderLeft:"4px solid #C0392B", marginBottom:10 }}>
|
||||||
|
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"flex-start", flexWrap:"wrap", gap:12 }}>
|
||||||
|
<div style={{ flex:1 }}>
|
||||||
|
<div style={{ display:"flex", alignItems:"center", gap:8, flexWrap:"wrap", marginBottom:6 }}>
|
||||||
|
<span style={{ fontWeight:700, fontSize:16 }}>{r.localNombre || "(local ya eliminado)"}</span>
|
||||||
|
<span style={S.badge("#C0392B", "#FDEDEC")}>{{ cerrado:"Ha cerrado", datos_incorrectos:"Datos incorrectos", inapropiado:"Contenido inapropiado", otro:"Otro motivo" }[r.motivo] || r.motivo}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin:0, fontSize:13, color:"#666" }}>📍 {r.localProvincia} {r.localDireccion ? `· ${r.localDireccion}` : ""}</p>
|
||||||
|
{r.comentario && <p style={{ margin:"8px 0 0", fontSize:13, color:"#666", fontStyle:"italic", background:"#FAFAFA", padding:"8px 12px", borderRadius:8 }}>"{r.comentario}"</p>}
|
||||||
|
<p style={{ margin:"6px 0 0", fontSize:11, color:"#AAA" }}>Reportado: {new Date(r.fecha).toLocaleDateString("es-ES", { day:"numeric", month:"short", hour:"2-digit", minute:"2-digit" })}</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display:"flex", gap:8, flexShrink:0 }}>
|
||||||
|
{r.localId && <button onClick={() => eliminar(r.localId)} style={{ ...S.btn("#C0392B"), padding:"8px 16px", fontSize:13 }}>🗑 Eliminar local</button>}
|
||||||
|
<button onClick={() => descartar(r.id)} style={{ ...S.btn("#EEE","#666"), padding:"8px 16px", fontSize:13 }}>Descartar aviso</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Filtro de palabras ── */}
|
{/* ── Filtro de palabras ── */}
|
||||||
{tab === "filtro" && (
|
{tab === "filtro" && (
|
||||||
<div style={{ maxWidth:620 }}>
|
<div style={{ maxWidth:620 }}>
|
||||||
|
|||||||
+162
-32
@@ -1,12 +1,17 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { contienepalabrasProhibidas, enviarPropuesta, obtenerPalabrasFiltradas, suscribirLocales } from "./firebase.js";
|
import { comprobarDuplicado, contienepalabrasProhibidas, enviarPropuesta, obtenerPalabrasFiltradas, suscribirLocales } from "./firebase.js";
|
||||||
import { CATEGORIAS, COLORES_MAPA, NOMBRES_CATEGORIAS, obtenerCategoria } from "./constants/categorias.js";
|
import { CATEGORIAS, COLORES_MAPA, NOMBRES_CATEGORIAS, obtenerCategoria } from "./constants/categorias.js";
|
||||||
import { COORDS_PROVINCIAS, PROVINCIAS, CENTRO_ESPANA } from "./constants/provincias.js";
|
import { COORDS_PROVINCIAS, PROVINCIAS, CENTRO_ESPANA } from "./constants/provincias.js";
|
||||||
|
import { distanciaKm as calcularDistanciaKm, enlaceGoogleMapsDesdeLocal, enlaceWazeDesdeLocal } from "./utils/geo.js";
|
||||||
import { inputStyle } from "./styles/shared.js";
|
import { inputStyle } from "./styles/shared.js";
|
||||||
import StarRating from "./components/StarRating.jsx";
|
import StarRating from "./components/StarRating.jsx";
|
||||||
import LocalCard from "./components/LocalCard.jsx";
|
import LocalCard from "./components/LocalCard.jsx";
|
||||||
import CampoUbicacion from "./components/CampoUbicacion.jsx";
|
import CampoUbicacion from "./components/CampoUbicacion.jsx";
|
||||||
|
import AutocompletarLocal from "./components/AutocompletarLocal.jsx";
|
||||||
import BannerMovil from "./components/BannerMovil.jsx";
|
import BannerMovil from "./components/BannerMovil.jsx";
|
||||||
|
import ModalPrivacidad from "./components/ModalPrivacidad.jsx";
|
||||||
|
import useFavoritos from "./hooks/useFavoritos.js";
|
||||||
|
import useUbicacion from "./hooks/useUbicacion.js";
|
||||||
|
|
||||||
const FORM_VACIO = {
|
const FORM_VACIO = {
|
||||||
nombre: "", provincia: "", categoria: "", subcategoria: "",
|
nombre: "", provincia: "", categoria: "", subcategoria: "",
|
||||||
@@ -29,6 +34,14 @@ export default function App() {
|
|||||||
const [filtroProvincia, setFiltroProvincia] = useState("");
|
const [filtroProvincia, setFiltroProvincia] = useState("");
|
||||||
const [orden, setOrden] = useState("fecha");
|
const [orden, setOrden] = useState("fecha");
|
||||||
const [palabrasProhibidas, setPalabrasProhibidas] = useState([]);
|
const [palabrasProhibidas, setPalabrasProhibidas] = useState([]);
|
||||||
|
const [comprobandoDuplicado, setComprobandoDuplicado] = useState(false);
|
||||||
|
const [posibleDuplicado, setPosibleDuplicado] = useState(null); // { coincidencias, lat, lng }
|
||||||
|
const [soloFavoritos, setSoloFavoritos] = useState(false);
|
||||||
|
const [aceptaPrivacidad, setAceptaPrivacidad] = useState(false);
|
||||||
|
const [mostrarPrivacidad, setMostrarPrivacidad] = useState(false);
|
||||||
|
const [localDestacado, setLocalDestacado] = useState(null); // id del local abierto vía enlace compartido
|
||||||
|
const { favoritos, esFavorito } = useFavoritos();
|
||||||
|
const { ubicacion, buscando: buscandoUbicacion, error: errorUbicacion, pedirUbicacion, limpiarUbicacion } = useUbicacion();
|
||||||
const mapRef = useRef(null);
|
const mapRef = useRef(null);
|
||||||
const mapInstanceRef = useRef(null);
|
const mapInstanceRef = useRef(null);
|
||||||
const markersRef = useRef([]);
|
const markersRef = useRef([]);
|
||||||
@@ -55,10 +68,46 @@ export default function App() {
|
|||||||
obtenerPalabrasFiltradas().then((p) => setPalabrasProhibidas(p));
|
obtenerPalabrasFiltradas().then((p) => setPalabrasProhibidas(p));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Enlace compartido (?local=ID): al cargar, resalta y desplaza hasta esa ficha
|
||||||
|
useEffect(() => {
|
||||||
|
const id = new URLSearchParams(window.location.search).get("local");
|
||||||
|
if (!id || loading || locales.length === 0) return;
|
||||||
|
setLocalDestacado(id);
|
||||||
|
const el = document.getElementById(`local-${id}`);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
const timer = setTimeout(() => setLocalDestacado(null), 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [loading, locales]);
|
||||||
|
|
||||||
const resetForm = () => setForm(FORM_VACIO);
|
const resetForm = () => setForm(FORM_VACIO);
|
||||||
|
|
||||||
|
const guardarPropuesta = async (lat, lng) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await enviarPropuesta({
|
||||||
|
nombre: form.nombre, provincia: form.provincia,
|
||||||
|
categoria: form.categoria, subcategoria: form.subcategoria,
|
||||||
|
direccion: form.direccion, enlaceGoogleMaps: form.enlaceGoogleMaps,
|
||||||
|
puntuacion: form.puntuacion, descripcion: form.descripcion,
|
||||||
|
lat, lng,
|
||||||
|
});
|
||||||
|
setEnviado(true);
|
||||||
|
resetForm();
|
||||||
|
setAceptaPrivacidad(false);
|
||||||
|
setMostrarForm(false);
|
||||||
|
setPosibleDuplicado(null);
|
||||||
|
setTimeout(() => setEnviado(false), 5000);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
const enviarLocal = async () => {
|
const enviarLocal = async () => {
|
||||||
setErrorFiltro("");
|
setErrorFiltro("");
|
||||||
|
setPosibleDuplicado(null);
|
||||||
// Comprobar filtro de palabras
|
// Comprobar filtro de palabras
|
||||||
const textoCompleto = [form.nombre, form.descripcion, form.direccion].join(" ");
|
const textoCompleto = [form.nombre, form.descripcion, form.direccion].join(" ");
|
||||||
if (contienepalabrasProhibidas(textoCompleto, palabrasProhibidas)) {
|
if (contienepalabrasProhibidas(textoCompleto, palabrasProhibidas)) {
|
||||||
@@ -73,23 +122,27 @@ export default function App() {
|
|||||||
lng = fallback[1] + (Math.random() - 0.5) * 0.04;
|
lng = fallback[1] + (Math.random() - 0.5) * 0.04;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true);
|
// Comprobar si ya existe un local igual (mismo nombre + provincia, o muy cerca)
|
||||||
|
setComprobandoDuplicado(true);
|
||||||
try {
|
try {
|
||||||
await enviarPropuesta({
|
const chequeo = await comprobarDuplicado({ nombre: form.nombre, provincia: form.provincia, lat, lng });
|
||||||
nombre: form.nombre, provincia: form.provincia,
|
setComprobandoDuplicado(false);
|
||||||
categoria: form.categoria, subcategoria: form.subcategoria,
|
if (chequeo?.duplicado) {
|
||||||
direccion: form.direccion, enlaceGoogleMaps: form.enlaceGoogleMaps,
|
setPosibleDuplicado({ coincidencias: chequeo.coincidencias, lat, lng });
|
||||||
puntuacion: form.puntuacion, descripcion: form.descripcion,
|
return;
|
||||||
lat, lng,
|
}
|
||||||
});
|
|
||||||
setEnviado(true);
|
|
||||||
resetForm();
|
|
||||||
setMostrarForm(false);
|
|
||||||
setTimeout(() => setEnviado(false), 5000);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
setComprobandoDuplicado(false);
|
||||||
|
// Si falla la comprobación, se permite continuar para no bloquear el envío
|
||||||
}
|
}
|
||||||
setSaving(false);
|
|
||||||
|
await guardarPropuesta(lat, lng);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmarPeseADuplicado = () => {
|
||||||
|
if (!posibleDuplicado) return;
|
||||||
|
guardarPropuesta(posibleDuplicado.lat, posibleDuplicado.lng);
|
||||||
};
|
};
|
||||||
|
|
||||||
const subcatsFiltro = filtroCategoria ? CATEGORIAS[filtroCategoria]?.subcategorias || [] : [];
|
const subcatsFiltro = filtroCategoria ? CATEGORIAS[filtroCategoria]?.subcategorias || [] : [];
|
||||||
@@ -100,9 +153,15 @@ export default function App() {
|
|||||||
return (!q || l.nombre.toLowerCase().includes(q) || l.provincia.toLowerCase().includes(q) || (l.categoria || "").toLowerCase().includes(q) || (l.subcategoria || "").toLowerCase().includes(q) || (l.direccion || "").toLowerCase().includes(q))
|
return (!q || l.nombre.toLowerCase().includes(q) || l.provincia.toLowerCase().includes(q) || (l.categoria || "").toLowerCase().includes(q) || (l.subcategoria || "").toLowerCase().includes(q) || (l.direccion || "").toLowerCase().includes(q))
|
||||||
&& (!filtroCategoria || l.categoria === filtroCategoria)
|
&& (!filtroCategoria || l.categoria === filtroCategoria)
|
||||||
&& (!filtroSubcat || l.subcategoria === filtroSubcat)
|
&& (!filtroSubcat || l.subcategoria === filtroSubcat)
|
||||||
&& (!filtroProvincia || l.provincia === filtroProvincia);
|
&& (!filtroProvincia || l.provincia === filtroProvincia)
|
||||||
|
&& (!soloFavoritos || esFavorito(l.id));
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
|
if (orden === "distancia" && ubicacion) {
|
||||||
|
const da = a.lat && a.lng ? calcularDistanciaKm(ubicacion.lat, ubicacion.lng, a.lat, a.lng) : Infinity;
|
||||||
|
const db = b.lat && b.lng ? calcularDistanciaKm(ubicacion.lat, ubicacion.lng, b.lat, b.lng) : Infinity;
|
||||||
|
return da - db;
|
||||||
|
}
|
||||||
if (orden === "fecha") return new Date(b.fecha) - new Date(a.fecha);
|
if (orden === "fecha") return new Date(b.fecha) - new Date(a.fecha);
|
||||||
if (orden === "puntuacion") return b.puntuacion - a.puntuacion;
|
if (orden === "puntuacion") return b.puntuacion - a.puntuacion;
|
||||||
return a.nombre.localeCompare(b.nombre);
|
return a.nombre.localeCompare(b.nombre);
|
||||||
@@ -131,7 +190,7 @@ export default function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (vista === "mapa" && mapInstanceRef.current && window.L) updateMarkers();
|
if (vista === "mapa" && mapInstanceRef.current && window.L) updateMarkers();
|
||||||
}, [locales, vista]);
|
}, [locales, vista, filtroCategoria, filtroSubcat, filtroProvincia, busqueda, soloFavoritos, favoritos]);
|
||||||
|
|
||||||
function initMap() {
|
function initMap() {
|
||||||
if (!mapRef.current || mapInstanceRef.current) return;
|
if (!mapRef.current || mapInstanceRef.current) return;
|
||||||
@@ -148,7 +207,7 @@ export default function App() {
|
|||||||
if (!L || !map) return;
|
if (!L || !map) return;
|
||||||
markersRef.current.forEach((m) => m.remove());
|
markersRef.current.forEach((m) => m.remove());
|
||||||
markersRef.current = [];
|
markersRef.current = [];
|
||||||
locales.forEach((local) => {
|
localesFiltrados.forEach((local) => {
|
||||||
if (!local.lat || !local.lng) return;
|
if (!local.lat || !local.lng) return;
|
||||||
const cat = obtenerCategoria(local.categoria);
|
const cat = obtenerCategoria(local.categoria);
|
||||||
const color = COLORES_MAPA[local.categoria] || cat.color;
|
const color = COLORES_MAPA[local.categoria] || cat.color;
|
||||||
@@ -158,8 +217,13 @@ export default function App() {
|
|||||||
const marker = L.marker([local.lat, local.lng], {
|
const marker = L.marker([local.lat, local.lng], {
|
||||||
icon: L.divIcon({ className: "", html: `<div style="background:${color};color:white;border-radius:8px 8px 8px 0;padding:5px 10px;font-size:12px;font-weight:600;white-space:nowrap;box-shadow:0 2px 8px rgba(0,0,0,0.3);border:2px solid white;max-width:220px;overflow:hidden;text-overflow:ellipsis;">${cat.emoji} ${local.nombre}${labelDir}</div>`, iconAnchor: [0, 32] }),
|
icon: L.divIcon({ className: "", html: `<div style="background:${color};color:white;border-radius:8px 8px 8px 0;padding:5px 10px;font-size:12px;font-weight:600;white-space:nowrap;box-shadow:0 2px 8px rgba(0,0,0,0.3);border:2px solid white;max-width:220px;overflow:hidden;text-overflow:ellipsis;">${cat.emoji} ${local.nombre}${labelDir}</div>`, iconAnchor: [0, 32] }),
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
const gmLink = local.enlaceGoogleMaps || (local.direccion ? `https://www.google.com/maps/search/${encodeURIComponent(`${local.nombre} ${local.direccion}`)}` : null);
|
const gmLink = local.enlaceGoogleMaps || enlaceGoogleMapsDesdeLocal(local);
|
||||||
marker.bindPopup(`<div style="font-family:sans-serif;min-width:200px;max-width:260px"><strong style="font-size:15px">${local.nombre}</strong><br><span style="color:#666;font-size:12px">${local.provincia}</span><br><span style="background:${cat.bgLight};color:${color};font-size:12px;padding:2px 8px;border-radius:4px;display:inline-block;margin:4px 0;font-weight:500">${cat.emoji} ${local.categoria}</span>${local.subcategoria ? `<span style="font-size:11px;color:#666;margin-left:4px">${local.subcategoria}</span>` : ""}${local.direccion ? `<br><span style="font-size:12px;color:#444;margin-top:4px;display:block">📍 ${local.direccion}</span>` : ""}${gmLink ? `<br><a href="${gmLink}" target="_blank" style="display:inline-block;margin-top:6px;font-size:12px;color:#1A73E8;font-weight:500;text-decoration:none;background:#E8F0FE;padding:3px 8px;border-radius:4px">Ver en Google Maps ↗</a>` : ""}<br><span style="color:#D4AF37;font-size:16px;display:block;margin-top:4px">${stars}</span>${local.descripcion ? `<em style="font-size:12px;color:#555">"${local.descripcion}"</em>` : ""}</div>`);
|
const wazeLink = enlaceWazeDesdeLocal(local);
|
||||||
|
const direccionHtml = local.direccion
|
||||||
|
? `<br><span style="font-size:12px;color:#444;margin-top:4px;display:block">📍 ${gmLink ? `<a href="${gmLink}" target="_blank" style="color:#444;text-decoration:underline">${local.direccion}</a>` : local.direccion}</span>`
|
||||||
|
: "";
|
||||||
|
const enlacesHtml = `${gmLink ? `<a href="${gmLink}" target="_blank" style="display:inline-block;margin-top:6px;margin-right:6px;font-size:12px;color:#1A73E8;font-weight:500;text-decoration:none;background:#E8F0FE;padding:3px 8px;border-radius:4px">Google Maps ↗</a>` : ""}${wazeLink ? `<a href="${wazeLink}" target="_blank" style="display:inline-block;margin-top:6px;font-size:12px;color:#0A9FD9;font-weight:500;text-decoration:none;background:#E6FAFF;padding:3px 8px;border-radius:4px">Waze ↗</a>` : ""}`;
|
||||||
|
marker.bindPopup(`<div style="font-family:sans-serif;min-width:200px;max-width:260px"><strong style="font-size:15px">${local.nombre}</strong><br><span style="color:#666;font-size:12px">${local.provincia}</span><br><span style="background:${cat.bgLight};color:${color};font-size:12px;padding:2px 8px;border-radius:4px;display:inline-block;margin:4px 0;font-weight:500">${cat.emoji} ${local.categoria}</span>${local.subcategoria ? `<span style="font-size:11px;color:#666;margin-left:4px">${local.subcategoria}</span>` : ""}${direccionHtml}<br>${enlacesHtml}<br><span style="color:#D4AF37;font-size:16px;display:block;margin-top:4px">${stars}</span>${local.descripcion ? `<em style="font-size:12px;color:#555">"${local.descripcion}"</em>` : ""}</div>`);
|
||||||
markersRef.current.push(marker);
|
markersRef.current.push(marker);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -169,7 +233,7 @@ export default function App() {
|
|||||||
const mediaGlobal = locales.length ? (locales.reduce((s, l) => s + l.puntuacion, 0) / locales.length).toFixed(1) : "—";
|
const mediaGlobal = locales.length ? (locales.reduce((s, l) => s + l.puntuacion, 0) / locales.length).toFixed(1) : "—";
|
||||||
|
|
||||||
const tieneUbicacion = !!(form.direccion || form.enlaceGoogleMaps);
|
const tieneUbicacion = !!(form.direccion || form.enlaceGoogleMaps);
|
||||||
const formValido = form.nombre && form.provincia && form.categoria && form.puntuacion > 0 && tieneUbicacion;
|
const formValido = form.nombre && form.provincia && form.categoria && form.puntuacion > 0 && tieneUbicacion && aceptaPrivacidad;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 720, margin: "0 auto", padding: "1.5rem 1rem", fontFamily: "var(--font-sans)" }}>
|
<div style={{ maxWidth: 720, margin: "0 auto", padding: "1.5rem 1rem", fontFamily: "var(--font-sans)" }}>
|
||||||
@@ -239,10 +303,7 @@ export default function App() {
|
|||||||
<p style={{ margin: "0 0 1rem", fontSize: 13, color: "var(--color-text-secondary)" }}>El administrador revisará tu propuesta antes de publicarla.</p>
|
<p style={{ margin: "0 0 1rem", fontSize: 13, color: "var(--color-text-secondary)" }}>El administrador revisará tu propuesta antes de publicarla.</p>
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
||||||
<div>
|
<AutocompletarLocal form={form} setForm={setForm} />
|
||||||
<label htmlFor="campo-nombre" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Nombre del local *</label>
|
|
||||||
<input id="campo-nombre" style={inputStyle} placeholder="Bar El Olivo, Clínica San José..." value={form.nombre} onChange={(e) => setForm((f) => ({ ...f, nombre: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="campo-provincia" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Provincia *</label>
|
<label htmlFor="campo-provincia" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Provincia *</label>
|
||||||
<select id="campo-provincia" style={inputStyle} value={form.provincia} onChange={(e) => setForm((f) => ({ ...f, provincia: e.target.value }))}>
|
<select id="campo-provincia" style={inputStyle} value={form.provincia} onChange={(e) => setForm((f) => ({ ...f, provincia: e.target.value }))}>
|
||||||
@@ -276,6 +337,17 @@ export default function App() {
|
|||||||
<StarRating value={form.puntuacion} onChange={(v) => setForm((f) => ({ ...f, puntuacion: v }))} />
|
<StarRating value={form.puntuacion} onChange={(v) => setForm((f) => ({ ...f, puntuacion: v }))} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label style={{ display: "flex", alignItems: "flex-start", gap: 8, marginTop: 12, fontSize: 12, color: "var(--color-text-secondary)", cursor: "pointer" }}>
|
||||||
|
<input type="checkbox" checked={aceptaPrivacidad} onChange={(e) => setAceptaPrivacidad(e.target.checked)} style={{ marginTop: 2 }} />
|
||||||
|
<span>
|
||||||
|
He leído y acepto la{" "}
|
||||||
|
<button type="button" onClick={() => setMostrarPrivacidad(true)} style={{ background: "none", border: "none", padding: 0, color: "#8B0000", textDecoration: "underline", cursor: "pointer", fontSize: 12 }}>
|
||||||
|
política de privacidad
|
||||||
|
</button>
|
||||||
|
{" "}sobre cómo se usan estos datos.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
{/* Error filtro palabras */}
|
{/* Error filtro palabras */}
|
||||||
{errorFiltro && (
|
{errorFiltro && (
|
||||||
<div role="alert" style={{ marginTop: 12, background: "#FDEDEC", border: "1px solid #F5B7B1", borderRadius: "var(--border-radius-md)", padding: "10px 14px", fontSize: 13, color: "#922B21", display: "flex", alignItems: "center", gap: 8 }}>
|
<div role="alert" style={{ marginTop: 12, background: "#FDEDEC", border: "1px solid #F5B7B1", borderRadius: "var(--border-radius-md)", padding: "10px 14px", fontSize: 13, color: "#922B21", display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
@@ -283,12 +355,33 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Aviso de posible duplicado */}
|
||||||
|
{posibleDuplicado && (
|
||||||
|
<div role="alert" style={{ marginTop: 12, background: "#FDF2E9", border: "1px solid #F5CBA7", borderRadius: "var(--border-radius-md)", padding: "10px 14px", fontSize: 13, color: "#784212" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: 500 }}>
|
||||||
|
<i className="ti ti-alert-triangle" aria-hidden="true"></i> Ya existe un local parecido
|
||||||
|
</div>
|
||||||
|
<ul style={{ margin: "6px 0", paddingLeft: 20 }}>
|
||||||
|
{posibleDuplicado.coincidencias.map((c) => (
|
||||||
|
<li key={c.id}>{c.nombre} · {c.provincia}{c.direccion ? ` · ${c.direccion}` : ""} ({c.estado === "pendiente" ? "pendiente de revisión" : "ya publicado"})</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div style={{ display: "flex", gap: 8, marginTop: 4 }}>
|
||||||
|
<button onClick={confirmarPeseADuplicado} disabled={saving}
|
||||||
|
style={{ background: "#8B0000", color: "white", border: "none", borderRadius: "var(--border-radius-md)", padding: "6px 14px", fontSize: 12, fontWeight: 500, cursor: "pointer" }}>
|
||||||
|
{saving ? "Enviando..." : "Enviar de todos modos"}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setPosibleDuplicado(null)} style={{ background: "none", border: "0.5px solid var(--color-border-secondary)", borderRadius: "var(--border-radius-md)", padding: "6px 14px", fontSize: 12, cursor: "pointer", color: "var(--color-text-secondary)" }}>Revisar datos</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: 8, marginTop: "1rem" }}>
|
<div style={{ display: "flex", gap: 8, marginTop: "1rem" }}>
|
||||||
<button onClick={enviarLocal} disabled={!formValido || saving}
|
<button onClick={enviarLocal} disabled={!formValido || saving || comprobandoDuplicado}
|
||||||
style={{ background: formValido ? "#8B0000" : "var(--color-background-secondary)", color: formValido ? "white" : "var(--color-text-tertiary)", border: "none", borderRadius: "var(--border-radius-md)", padding: "8px 20px", fontSize: 14, fontWeight: 500, cursor: formValido ? "pointer" : "not-allowed" }}>
|
style={{ background: formValido ? "#8B0000" : "var(--color-background-secondary)", color: formValido ? "white" : "var(--color-text-tertiary)", border: "none", borderRadius: "var(--border-radius-md)", padding: "8px 20px", fontSize: 14, fontWeight: 500, cursor: formValido ? "pointer" : "not-allowed" }}>
|
||||||
{saving ? "Enviando..." : "📨 Enviar propuesta"}
|
{comprobandoDuplicado ? "Comprobando..." : saving ? "Enviando..." : "📨 Enviar propuesta"}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => { setMostrarForm(false); setErrorFiltro(""); }} style={{ background: "none", border: "0.5px solid var(--color-border-secondary)", borderRadius: "var(--border-radius-md)", padding: "8px 16px", fontSize: 14, cursor: "pointer", color: "var(--color-text-secondary)" }}>Cancelar</button>
|
<button onClick={() => { setMostrarForm(false); setErrorFiltro(""); setPosibleDuplicado(null); }} style={{ background: "none", border: "0.5px solid var(--color-border-secondary)", borderRadius: "var(--border-radius-md)", padding: "8px 16px", fontSize: 14, cursor: "pointer", color: "var(--color-text-secondary)" }}>Cancelar</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -305,7 +398,7 @@ export default function App() {
|
|||||||
|
|
||||||
{vista === "lista" && (
|
{vista === "lista" && (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr auto", gap: 8, marginBottom: "1rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr auto", gap: 8, marginBottom: 8 }}>
|
||||||
<input style={inputStyle} placeholder="🔍 Buscar por nombre, dirección..." value={busqueda} onChange={(e) => setBusqueda(e.target.value)} aria-label="Buscar por nombre, dirección o categoría" />
|
<input style={inputStyle} placeholder="🔍 Buscar por nombre, dirección..." value={busqueda} onChange={(e) => setBusqueda(e.target.value)} aria-label="Buscar por nombre, dirección o categoría" />
|
||||||
<select style={{ ...inputStyle, opacity: filtroCategoria ? 1 : 0.6 }} value={filtroSubcat} onChange={(e) => setFiltroSubcat(e.target.value)} disabled={!filtroCategoria} aria-label="Filtrar por tipo específico">
|
<select style={{ ...inputStyle, opacity: filtroCategoria ? 1 : 0.6 }} value={filtroSubcat} onChange={(e) => setFiltroSubcat(e.target.value)} disabled={!filtroCategoria} aria-label="Filtrar por tipo específico">
|
||||||
<option value="">{filtroCategoria ? "Todos los tipos" : "Elige categoría arriba"}</option>
|
<option value="">{filtroCategoria ? "Todos los tipos" : "Elige categoría arriba"}</option>
|
||||||
@@ -319,9 +412,29 @@ export default function App() {
|
|||||||
<option value="fecha">Reciente</option>
|
<option value="fecha">Reciente</option>
|
||||||
<option value="puntuacion">★ Mejor</option>
|
<option value="puntuacion">★ Mejor</option>
|
||||||
<option value="nombre">A-Z</option>
|
<option value="nombre">A-Z</option>
|
||||||
|
{ubicacion && <option value="distancia">📍 Más cerca</option>}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap", marginBottom: "1rem" }}>
|
||||||
|
{!ubicacion ? (
|
||||||
|
<button onClick={pedirUbicacion} disabled={buscandoUbicacion}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 5, background: "none", border: "0.5px solid var(--color-border-secondary)", borderRadius: 20, padding: "5px 12px", fontSize: 12, color: "var(--color-text-secondary)", cursor: "pointer" }}>
|
||||||
|
<i className="ti ti-current-location" aria-hidden="true"></i> {buscandoUbicacion ? "Localizando..." : "Cerca de mí"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => { limpiarUbicacion(); if (orden === "distancia") setOrden("fecha"); }}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 5, background: "#EAFAF1", border: "0.5px solid #A9DFBF", borderRadius: 20, padding: "5px 12px", fontSize: 12, color: "#1E8449", cursor: "pointer" }}>
|
||||||
|
<i className="ti ti-map-pin-check" aria-hidden="true"></i> Ubicación activada ✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{errorUbicacion && <span style={{ fontSize: 12, color: "#922B21" }}>{errorUbicacion}</span>}
|
||||||
|
<button onClick={() => setSoloFavoritos((v) => !v)} aria-pressed={soloFavoritos}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 5, background: soloFavoritos ? "#FDEDEC" : "none", border: `0.5px solid ${soloFavoritos ? "#8B0000" : "var(--color-border-secondary)"}`, borderRadius: 20, padding: "5px 12px", fontSize: 12, color: soloFavoritos ? "#8B0000" : "var(--color-text-secondary)", cursor: "pointer" }}>
|
||||||
|
<i className={soloFavoritos ? "ti ti-heart-filled" : "ti ti-heart"} aria-hidden="true"></i> Favoritos {favoritos.length > 0 && `(${favoritos.length})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ textAlign: "center", padding: "3rem", color: "var(--color-text-tertiary)", fontSize: 14 }}>
|
<div style={{ textAlign: "center", padding: "3rem", color: "var(--color-text-tertiary)", fontSize: 14 }}>
|
||||||
<i className="ti ti-loader" style={{ fontSize: 24, display: "block", marginBottom: 8 }} aria-hidden="true"></i>
|
<i className="ti ti-loader" style={{ fontSize: 24, display: "block", marginBottom: 8 }} aria-hidden="true"></i>
|
||||||
@@ -330,12 +443,21 @@ export default function App() {
|
|||||||
) : localesFiltrados.length === 0 ? (
|
) : localesFiltrados.length === 0 ? (
|
||||||
<div style={{ textAlign: "center", padding: "3rem", color: "var(--color-text-tertiary)" }}>
|
<div style={{ textAlign: "center", padding: "3rem", color: "var(--color-text-tertiary)" }}>
|
||||||
<div style={{ fontSize: 40, marginBottom: 12 }} aria-hidden="true">🏘️</div>
|
<div style={{ fontSize: 40, marginBottom: 12 }} aria-hidden="true">🏘️</div>
|
||||||
<p style={{ margin: 0, fontWeight: 500 }}>{locales.length === 0 ? "Todavía no hay locales publicados" : "No hay locales que coincidan"}</p>
|
<p style={{ margin: 0, fontWeight: 500 }}>
|
||||||
<p style={{ margin: "6px 0 0", fontSize: 13 }}>Sé el primero en proponer uno — el administrador lo revisará y publicará</p>
|
{locales.length === 0 ? "Todavía no hay locales publicados" : soloFavoritos ? "Aún no tienes favoritos guardados" : "No hay locales que coincidan"}
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: "6px 0 0", fontSize: 13 }}>{soloFavoritos ? "Pulsa el corazón ♥ en un local para guardarlo aquí" : "Sé el primero en proponer uno — el administrador lo revisará y publicará"}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: "grid", gap: 10 }}>
|
<div style={{ display: "grid", gap: 10 }}>
|
||||||
{localesFiltrados.map((local) => <LocalCard key={local.id} local={local} />)}
|
{localesFiltrados.map((local) => (
|
||||||
|
<div key={local.id} style={localDestacado === local.id ? { outline: "2px solid #8B0000", borderRadius: "var(--border-radius-lg)" } : undefined}>
|
||||||
|
<LocalCard
|
||||||
|
local={local}
|
||||||
|
distanciaKm={ubicacion && local.lat && local.lng ? calcularDistanciaKm(ubicacion.lat, ubicacion.lng, local.lat, local.lng) : null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
{localesFiltrados.length < locales.length && (
|
{localesFiltrados.length < locales.length && (
|
||||||
<p style={{ textAlign: "center", fontSize: 12, color: "var(--color-text-tertiary)", margin: "4px 0" }}>
|
<p style={{ textAlign: "center", fontSize: 12, color: "var(--color-text-tertiary)", margin: "4px 0" }}>
|
||||||
Mostrando {localesFiltrados.length} de {locales.length} locales
|
Mostrando {localesFiltrados.length} de {locales.length} locales
|
||||||
@@ -357,11 +479,13 @@ export default function App() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{locales.length === 0 && <div style={{ textAlign: "center", padding: "1rem", background: "var(--color-background-secondary)", fontSize: 13, color: "var(--color-text-secondary)" }}>Todavía no hay locales publicados</div>}
|
{locales.length === 0 && <div style={{ textAlign: "center", padding: "1rem", background: "var(--color-background-secondary)", fontSize: 13, color: "var(--color-text-secondary)" }}>Todavía no hay locales publicados</div>}
|
||||||
|
{locales.length > 0 && localesFiltrados.length === 0 && <div style={{ textAlign: "center", padding: "1rem", background: "var(--color-background-secondary)", fontSize: 13, color: "var(--color-text-secondary)" }}>Ningún local coincide con el filtro seleccionado</div>}
|
||||||
<div ref={mapRef} style={{ height: 440, width: "100%" }} role="img" aria-label="Mapa de España con los locales publicados"></div>
|
<div ref={mapRef} style={{ height: 440, width: "100%" }} role="img" aria-label="Mapa de España con los locales publicados"></div>
|
||||||
<div style={{ padding: "8px 12px", background: "var(--color-background-secondary)", borderTop: "0.5px solid var(--color-border-tertiary)", display: "flex", gap: 12, alignItems: "center" }}>
|
<div style={{ padding: "8px 12px", background: "var(--color-background-secondary)", borderTop: "0.5px solid var(--color-border-tertiary)", display: "flex", gap: 12, alignItems: "center" }}>
|
||||||
<span style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
|
<span style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
|
||||||
<i className="ti ti-map-pin" aria-hidden="true" style={{ marginRight: 4 }}></i>
|
<i className="ti ti-map-pin" aria-hidden="true" style={{ marginRight: 4 }}></i>
|
||||||
{locales.length} {locales.length === 1 ? "local marcado" : "locales marcados"}
|
{localesFiltrados.length} {localesFiltrados.length === 1 ? "local marcado" : "locales marcados"}
|
||||||
|
{localesFiltrados.length !== locales.length && ` de ${locales.length}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -369,8 +493,14 @@ export default function App() {
|
|||||||
|
|
||||||
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", textAlign: "center", marginTop: "1.5rem" }}>
|
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", textAlign: "center", marginTop: "1.5rem" }}>
|
||||||
Los locales pasan por revisión antes de publicarse · ¿Tienes un negocio? Proponlo arriba
|
Los locales pasan por revisión antes de publicarse · ¿Tienes un negocio? Proponlo arriba
|
||||||
|
{" · "}
|
||||||
|
<button type="button" onClick={() => setMostrarPrivacidad(true)} style={{ background: "none", border: "none", padding: 0, fontSize: 11, color: "var(--color-text-tertiary)", textDecoration: "underline", cursor: "pointer" }}>
|
||||||
|
Política de privacidad
|
||||||
|
</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{mostrarPrivacidad && <ModalPrivacidad onCerrar={() => setMostrarPrivacidad(false)} />}
|
||||||
|
|
||||||
<BannerMovil />
|
<BannerMovil />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+33
@@ -54,6 +54,15 @@ export async function enviarPropuesta(local) {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function comprobarDuplicado({ nombre, provincia, lat, lng }) {
|
||||||
|
const response = await fetch(`${API_URL}/check-duplicado`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ nombre, provincia, lat, lng }),
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
export async function aprobarPropuesta(p) {
|
export async function aprobarPropuesta(p) {
|
||||||
const response = await fetch(`${API_URL}/aprobar/${p.id}`, {
|
const response = await fetch(`${API_URL}/aprobar/${p.id}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -77,6 +86,30 @@ export async function deleteLocal(id) {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function confirmarLocal(id) {
|
||||||
|
const response = await fetch(`${API_URL}/locales/${id}/confirmar`, { method: "POST" });
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reportarLocal(id, motivo, comentario) {
|
||||||
|
const response = await fetch(`${API_URL}/locales/${id}/reportar`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ motivo, comentario }),
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function obtenerReportes() {
|
||||||
|
const response = await fetch(`${API_URL}/reportes`);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function descartarReporte(id) {
|
||||||
|
const response = await fetch(`${API_URL}/reportes/${id}/descartar`, { method: "POST" });
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
export async function obtenerPalabrasFiltradas() {
|
export async function obtenerPalabrasFiltradas() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/config/filtro_palabras`);
|
const response = await fetch(`${API_URL}/config/filtro_palabras`);
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { buscarLugares } from "../utils/geo.js";
|
||||||
|
import { PROVINCIAS } from "../constants/provincias.js";
|
||||||
|
import { inputStyle } from "../styles/shared.js";
|
||||||
|
|
||||||
|
const normaliza = (s) => (s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim();
|
||||||
|
|
||||||
|
function emparejarProvincia(texto) {
|
||||||
|
const t = normaliza(texto);
|
||||||
|
if (!t) return "";
|
||||||
|
return PROVINCIAS.find((p) => normaliza(p) === t || t.includes(normaliza(p))) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Campo "Nombre del local" con autocompletar: al escribir, busca lugares (OpenStreetMap)
|
||||||
|
// y permite seleccionar uno para rellenar automáticamente nombre, dirección, provincia y coordenadas.
|
||||||
|
export default function AutocompletarLocal({ form, setForm }) {
|
||||||
|
const [sugerencias, setSugerencias] = useState([]);
|
||||||
|
const [abierto, setAbierto] = useState(false);
|
||||||
|
const [buscando, setBuscando] = useState(false);
|
||||||
|
const debounceRef = useRef(null);
|
||||||
|
const cajaRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function alClicFuera(e) {
|
||||||
|
if (cajaRef.current && !cajaRef.current.contains(e.target)) setAbierto(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", alClicFuera);
|
||||||
|
return () => document.removeEventListener("mousedown", alClicFuera);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = (val) => {
|
||||||
|
setForm((f) => ({ ...f, nombre: val }));
|
||||||
|
setAbierto(true);
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
if (val.trim().length < 3) { setSugerencias([]); return; }
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
setBuscando(true);
|
||||||
|
try {
|
||||||
|
setSugerencias(await buscarLugares(val));
|
||||||
|
} catch {
|
||||||
|
setSugerencias([]);
|
||||||
|
}
|
||||||
|
setBuscando(false);
|
||||||
|
}, 450);
|
||||||
|
};
|
||||||
|
|
||||||
|
const seleccionar = (lugar) => {
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
nombre: lugar.nombre,
|
||||||
|
direccion: lugar.direccion,
|
||||||
|
enlaceGoogleMaps: "",
|
||||||
|
lat: lugar.lat,
|
||||||
|
lng: lugar.lng,
|
||||||
|
provincia: emparejarProvincia(lugar.provincia) || f.provincia,
|
||||||
|
}));
|
||||||
|
setSugerencias([]);
|
||||||
|
setAbierto(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={cajaRef} style={{ position: "relative" }}>
|
||||||
|
<label htmlFor="campo-nombre" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Nombre del local *</label>
|
||||||
|
<input
|
||||||
|
id="campo-nombre"
|
||||||
|
style={inputStyle}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="Bar El Olivo, Clínica San José..."
|
||||||
|
value={form.nombre}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
onFocus={() => sugerencias.length && setAbierto(true)}
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={abierto && sugerencias.length > 0}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
/>
|
||||||
|
{buscando && (
|
||||||
|
<i className="ti ti-loader" aria-hidden="true" style={{ position: "absolute", right: 10, top: 32, fontSize: 14, color: "var(--color-text-tertiary)" }}></i>
|
||||||
|
)}
|
||||||
|
{abierto && sugerencias.length > 0 && (
|
||||||
|
<div role="listbox" style={{
|
||||||
|
position: "absolute", zIndex: 20, top: "100%", left: 0, right: 0, marginTop: 4,
|
||||||
|
background: "var(--color-background-primary)", border: "0.5px solid var(--color-border-secondary)",
|
||||||
|
borderRadius: "var(--border-radius-md)", boxShadow: "0 6px 18px rgba(0,0,0,0.15)",
|
||||||
|
maxHeight: 240, overflowY: "auto",
|
||||||
|
}}>
|
||||||
|
{sugerencias.map((s) => (
|
||||||
|
<button key={s.id} type="button" role="option" onClick={() => seleccionar(s)}
|
||||||
|
style={{
|
||||||
|
display: "block", width: "100%", textAlign: "left", background: "none", border: "none",
|
||||||
|
borderBottom: "0.5px solid var(--color-border-tertiary)", padding: "8px 10px", cursor: "pointer",
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--color-text-primary)" }}>{s.nombre}</div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--color-text-secondary)", marginTop: 2 }}>{s.direccion}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: "4px 0 0" }}>
|
||||||
|
Escribe el nombre y elige una opción para rellenar dirección y ubicación automáticamente.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,59 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import usePwaInstall from "../hooks/usePwaInstall.js";
|
import usePwaInstall from "../hooks/usePwaInstall.js";
|
||||||
|
|
||||||
|
const CLAVE_VISITAS = "localesp_visitas";
|
||||||
|
const CLAVE_CERRADO = "localesp_banner_cerrado_hasta";
|
||||||
|
const VISITAS_MINIMAS = 2;
|
||||||
|
const DIAS_OCULTO_TRAS_CERRAR = 14;
|
||||||
|
|
||||||
|
function registrarVisitaYContar() {
|
||||||
|
try {
|
||||||
|
const actual = parseInt(localStorage.getItem(CLAVE_VISITAS) || "0", 10) + 1;
|
||||||
|
localStorage.setItem(CLAVE_VISITAS, String(actual));
|
||||||
|
return actual;
|
||||||
|
} catch {
|
||||||
|
return VISITAS_MINIMAS; // si localStorage falla, no bloqueamos el banner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function estaCerradoRecientemente() {
|
||||||
|
try {
|
||||||
|
const hasta = parseInt(localStorage.getItem(CLAVE_CERRADO) || "0", 10);
|
||||||
|
return Date.now() < hasta;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Banner flotante para instalar/abrir la PWA. Usa el service worker +
|
// Banner flotante para instalar/abrir la PWA. Usa el service worker +
|
||||||
// manifest.webmanifest ya registrados en main.jsx/index.html: si el
|
// manifest.webmanifest ya registrados en main.jsx/index.html: si el
|
||||||
// navegador soporta la instalación nativa (evento beforeinstallprompt),
|
// navegador soporta la instalación nativa (evento beforeinstallprompt),
|
||||||
// el botón la dispara directamente. Si la app ya se está ejecutando en
|
// el botón la dispara directamente. Si la app ya se está ejecutando en
|
||||||
// modo standalone (o el navegador confirma que está instalada), el banner
|
// modo standalone (o el navegador confirma que está instalada), el banner
|
||||||
// se oculta porque ya no aporta nada mostrarlo.
|
// se oculta porque ya no aporta nada mostrarlo.
|
||||||
|
//
|
||||||
|
// Para no ser intrusivo, solo se muestra a partir de la 2ª visita del
|
||||||
|
// usuario (contador en localStorage) y, si lo cierra, no vuelve a
|
||||||
|
// aparecer durante 14 días.
|
||||||
export default function BannerMovil() {
|
export default function BannerMovil() {
|
||||||
const [cerrado, setCerrado] = useState(false);
|
const [cerrado, setCerrado] = useState(estaCerradoRecientemente);
|
||||||
|
const [visitasSuficientes, setVisitasSuficientes] = useState(false);
|
||||||
const { isInstalled, canPromptInstall, promptInstall, isIos } = usePwaInstall();
|
const { isInstalled, canPromptInstall, promptInstall, isIos } = usePwaInstall();
|
||||||
const [instalando, setInstalando] = useState(false);
|
const [instalando, setInstalando] = useState(false);
|
||||||
const [resultado, setResultado] = useState(null); // "accepted" | "dismissed" | null
|
const [resultado, setResultado] = useState(null); // "accepted" | "dismissed" | null
|
||||||
|
|
||||||
if (cerrado || isInstalled) return null;
|
useEffect(() => {
|
||||||
|
setVisitasSuficientes(registrarVisitaYContar() >= VISITAS_MINIMAS);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (cerrado || isInstalled || !visitasSuficientes) return null;
|
||||||
|
|
||||||
|
const handleCerrar = () => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CLAVE_CERRADO, String(Date.now() + DIAS_OCULTO_TRAS_CERRAR * 24 * 60 * 60 * 1000));
|
||||||
|
} catch { /* localStorage no disponible: se cierra solo para esta sesión */ }
|
||||||
|
setCerrado(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleInstalar = async () => {
|
const handleInstalar = async () => {
|
||||||
setInstalando(true);
|
setInstalando(true);
|
||||||
@@ -24,7 +64,7 @@ export default function BannerMovil() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: "fixed", bottom: 20, right: 20, zIndex: 999, background: "white", border: "1px solid #E5DDD5", borderRadius: 14, padding: "14px 18px", boxShadow: "0 6px 24px rgba(0,0,0,0.10)", maxWidth: 280, fontFamily: "var(--font-sans)" }}>
|
<div style={{ position: "fixed", bottom: 20, right: 20, zIndex: 999, background: "white", border: "1px solid #E5DDD5", borderRadius: 14, padding: "14px 18px", boxShadow: "0 6px 24px rgba(0,0,0,0.10)", maxWidth: 280, fontFamily: "var(--font-sans)" }}>
|
||||||
<button onClick={() => setCerrado(true)} aria-label="Cerrar aviso de instalación"
|
<button onClick={handleCerrar} aria-label="Cerrar aviso de instalación"
|
||||||
style={{ position: "absolute", top: 8, right: 10, background: "none", border: "none", cursor: "pointer", color: "#CCC", fontSize: 16, padding: 0 }}>✕</button>
|
style={{ position: "absolute", top: 8, right: 10, background: "none", border: "none", cursor: "pointer", color: "#CCC", fontSize: 16, padding: 0 }}>✕</button>
|
||||||
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 10 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 10 }}>
|
||||||
|
|||||||
+114
-15
@@ -1,5 +1,12 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import CategoriaBadge from "./CategoriaBadge.jsx";
|
import CategoriaBadge from "./CategoriaBadge.jsx";
|
||||||
import StarRating from "./StarRating.jsx";
|
import StarRating from "./StarRating.jsx";
|
||||||
|
import ReportarLocal from "./ReportarLocal.jsx";
|
||||||
|
import useFavoritos from "../hooks/useFavoritos.js";
|
||||||
|
import { confirmarLocal } from "../firebase.js";
|
||||||
|
import { enlaceGoogleMapsDesdeLocal, enlaceWazeDesdeLocal, formatearDistancia } from "../utils/geo.js";
|
||||||
|
|
||||||
|
const MESES_PARA_CADUCAR = 6;
|
||||||
|
|
||||||
function formatearFecha(fecha) {
|
function formatearFecha(fecha) {
|
||||||
if (!fecha) return null;
|
if (!fecha) return null;
|
||||||
@@ -8,22 +15,78 @@ function formatearFecha(fecha) {
|
|||||||
return date.toLocaleDateString("es-ES", { day: "numeric", month: "long", year: "numeric" });
|
return date.toLocaleDateString("es-ES", { day: "numeric", month: "long", year: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LocalCard({ local }) {
|
function mesesDesde(fecha) {
|
||||||
const gmUrl = local.enlaceGoogleMaps || (local.direccion
|
if (!fecha) return null;
|
||||||
? `https://www.google.com/maps/search/${encodeURIComponent(`${local.nombre} ${local.direccion}`)}`
|
const date = new Date(fecha);
|
||||||
: null);
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
|
return (Date.now() - date.getTime()) / (1000 * 60 * 60 * 24 * 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LocalCard({ local, distanciaKm }) {
|
||||||
|
const { esFavorito, alternarFavorito } = useFavoritos();
|
||||||
|
const [confirmando, setConfirmando] = useState(false);
|
||||||
|
const [confirmado, setConfirmado] = useState(false);
|
||||||
|
const [fechaConfirmacion, setFechaConfirmacion] = useState(local.fechaConfirmacion || local.fecha);
|
||||||
|
|
||||||
|
const gmUrl = local.enlaceGoogleMaps || enlaceGoogleMapsDesdeLocal(local);
|
||||||
|
const wazeUrl = enlaceWazeDesdeLocal(local);
|
||||||
const fechaFormateada = formatearFecha(local.fecha);
|
const fechaFormateada = formatearFecha(local.fecha);
|
||||||
|
const meses = mesesDesde(fechaConfirmacion);
|
||||||
|
const sinConfirmarReciente = meses != null && meses >= MESES_PARA_CADUCAR;
|
||||||
|
const favorito = esFavorito(local.id);
|
||||||
|
|
||||||
|
const handleConfirmar = async () => {
|
||||||
|
setConfirmando(true);
|
||||||
|
try {
|
||||||
|
const res = await confirmarLocal(local.id);
|
||||||
|
setFechaConfirmacion(res?.fechaConfirmacion || new Date().toISOString());
|
||||||
|
setConfirmado(true);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
setConfirmando(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const compartir = async () => {
|
||||||
|
const url = `${window.location.origin}${window.location.pathname}?local=${local.id}`;
|
||||||
|
const texto = `${local.nombre}${local.provincia ? ` · ${local.provincia}` : ""}`;
|
||||||
|
if (navigator.share) {
|
||||||
|
try { await navigator.share({ title: local.nombre, text: texto, url }); } catch { /* usuario canceló */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
window.open(`https://wa.me/?text=${encodeURIComponent(`${texto} ${url}`)}`, "_blank", "noopener,noreferrer");
|
||||||
|
} catch {
|
||||||
|
window.open(`https://wa.me/?text=${encodeURIComponent(`${texto} ${url}`)}`, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
id={`local-${local.id}`}
|
||||||
style={{ background: "var(--color-background-primary)", border: "0.5px solid var(--color-border-tertiary)", borderRadius: "var(--border-radius-lg)", padding: "1rem 1.25rem", display: "flex", flexDirection: "column", gap: 8, transition: "border-color 0.2s" }}
|
style={{ background: "var(--color-background-primary)", border: "0.5px solid var(--color-border-tertiary)", borderRadius: "var(--border-radius-lg)", padding: "1rem 1.25rem", display: "flex", flexDirection: "column", gap: 8, transition: "border-color 0.2s" }}
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = "var(--color-border-secondary)"; }}
|
onMouseEnter={(e) => { e.currentTarget.style.borderColor = "var(--color-border-secondary)"; }}
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--color-border-tertiary)"; }}
|
onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--color-border-tertiary)"; }}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<p style={{ fontWeight: 500, fontSize: 16, margin: 0 }}>{local.nombre}</p>
|
<p style={{ fontWeight: 500, fontSize: 16, margin: 0 }}>{local.nombre}</p>
|
||||||
{local.provincia && <p style={{ fontSize: 13, color: "var(--color-text-secondary)", margin: "2px 0 0" }}>{local.provincia}</p>}
|
<p style={{ fontSize: 13, color: "var(--color-text-secondary)", margin: "2px 0 0" }}>
|
||||||
|
{local.provincia}
|
||||||
|
{distanciaKm != null && <span style={{ color: "var(--color-text-tertiary)" }}> · {formatearDistancia(distanciaKm)}</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
||||||
|
<button type="button" onClick={compartir} title="Compartir"
|
||||||
|
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 16, color: "var(--color-text-tertiary)", padding: 4 }}>
|
||||||
|
<i className="ti ti-share-2" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => alternarFavorito(local.id)} title={favorito ? "Quitar de favoritos" : "Guardar en favoritos"}
|
||||||
|
aria-pressed={favorito}
|
||||||
|
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 16, color: favorito ? "#8B0000" : "var(--color-text-tertiary)", padding: 4 }}>
|
||||||
|
<i className={favorito ? "ti ti-heart-filled" : "ti ti-heart"} aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -32,16 +95,34 @@ export default function LocalCard({ local }) {
|
|||||||
{local.direccion && (
|
{local.direccion && (
|
||||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 6 }}>
|
<div style={{ display: "flex", alignItems: "flex-start", gap: 6 }}>
|
||||||
<i className="ti ti-map-pin" aria-hidden="true" style={{ fontSize: 14, color: "var(--color-text-tertiary)", marginTop: 1, flexShrink: 0 }}></i>
|
<i className="ti ti-map-pin" aria-hidden="true" style={{ fontSize: 14, color: "var(--color-text-tertiary)", marginTop: 1, flexShrink: 0 }}></i>
|
||||||
<span style={{ fontSize: 13, color: "var(--color-text-secondary)" }}>{local.direccion}</span>
|
{gmUrl ? (
|
||||||
|
<a href={gmUrl} target="_blank" rel="noopener noreferrer" title="Abrir dirección en Google Maps"
|
||||||
|
style={{ fontSize: 13, color: "var(--color-text-secondary)", textDecoration: "underline", textDecorationStyle: "dotted", textUnderlineOffset: 2 }}>
|
||||||
|
{local.direccion}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: 13, color: "var(--color-text-secondary)" }}>{local.direccion}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{gmUrl && (
|
{(gmUrl || wazeUrl) && (
|
||||||
<a href={gmUrl} target="_blank" rel="noopener noreferrer"
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
style={{ display: "inline-flex", alignItems: "center", gap: 5, fontSize: 12, color: "#1A73E8", textDecoration: "none", width: "fit-content", background: "#E8F0FE", padding: "4px 10px", borderRadius: "var(--border-radius-md)", fontWeight: 500 }}>
|
{gmUrl && (
|
||||||
<i className="ti ti-brand-google-maps" aria-hidden="true" style={{ fontSize: 14 }}></i>
|
<a href={gmUrl} target="_blank" rel="noopener noreferrer"
|
||||||
Ver en Google Maps
|
style={{ display: "inline-flex", alignItems: "center", gap: 5, fontSize: 12, color: "#1A73E8", textDecoration: "none", width: "fit-content", background: "#E8F0FE", padding: "4px 10px", borderRadius: "var(--border-radius-md)", fontWeight: 500 }}>
|
||||||
</a>
|
<i className="ti ti-brand-google-maps" aria-hidden="true" style={{ fontSize: 14 }}></i>
|
||||||
|
Google Maps
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{wazeUrl && (
|
||||||
|
<a href={wazeUrl} target="_blank" rel="noopener noreferrer"
|
||||||
|
style={{ display: "inline-flex", alignItems: "center", gap: 5, fontSize: 12, color: "#33CCFF", textDecoration: "none", width: "fit-content", background: "#E6FAFF", padding: "4px 10px", borderRadius: "var(--border-radius-md)", fontWeight: 500 }}>
|
||||||
|
<i className="ti ti-brand-waze" aria-hidden="true" style={{ fontSize: 14 }}></i>
|
||||||
|
Waze
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<StarRating value={local.puntuacion} readOnly />
|
<StarRating value={local.puntuacion} readOnly />
|
||||||
@@ -50,9 +131,27 @@ export default function LocalCard({ local }) {
|
|||||||
<p style={{ fontSize: 13, color: "var(--color-text-secondary)", margin: 0, fontStyle: "italic" }}>"{local.descripcion}"</p>
|
<p style={{ fontSize: 13, color: "var(--color-text-secondary)", margin: 0, fontStyle: "italic" }}>"{local.descripcion}"</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{fechaFormateada && (
|
{sinConfirmarReciente && !confirmado && (
|
||||||
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: 0 }}>Publicado {fechaFormateada}</p>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, flexWrap: "wrap", background: "#FDF2E9", border: "1px solid #F5CBA7", borderRadius: "var(--border-radius-md)", padding: "6px 10px" }}>
|
||||||
|
<span style={{ fontSize: 12, color: "#784212", display: "flex", alignItems: "center", gap: 5 }}>
|
||||||
|
<i className="ti ti-clock-exclamation" aria-hidden="true"></i> Sin confirmar recientemente
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={handleConfirmar} disabled={confirmando}
|
||||||
|
style={{ fontSize: 12, background: "#1E8449", color: "white", border: "none", borderRadius: "var(--border-radius-md)", padding: "4px 10px", cursor: "pointer", fontWeight: 500 }}>
|
||||||
|
{confirmando ? "..." : "Sigue aquí ✅"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{confirmado && (
|
||||||
|
<p style={{ fontSize: 12, color: "#1E8449", margin: 0 }}>✅ Gracias por confirmar que sigue activo.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
{fechaFormateada && (
|
||||||
|
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: 0 }}>Publicado {fechaFormateada}</p>
|
||||||
|
)}
|
||||||
|
<ReportarLocal localId={local.id} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Modal con la política de privacidad. Contenido genérico orientativo: como se
|
||||||
|
// guardan datos de negocios (algunos de terceros, no del propio usuario) conviene
|
||||||
|
// que un profesional lo revise antes de publicar la app.
|
||||||
|
export default function ModalPrivacidad({ onCerrar }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog" aria-modal="true" aria-label="Política de privacidad"
|
||||||
|
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", zIndex: 1000, display: "flex", alignItems: "center", justifyContent: "center", padding: 16 }}
|
||||||
|
onClick={onCerrar}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ background: "var(--color-background-primary)", borderRadius: "var(--border-radius-lg)", maxWidth: 560, width: "100%", maxHeight: "85vh", overflowY: "auto", padding: "1.5rem", fontFamily: "var(--font-sans)" }}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 12 }}>
|
||||||
|
<p style={{ margin: 0, fontWeight: 600, fontSize: 17 }}>Privacidad y condiciones</p>
|
||||||
|
<button onClick={onCerrar} aria-label="Cerrar" style={{ background: "none", border: "none", cursor: "pointer", fontSize: 18, color: "var(--color-text-tertiary)" }}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 13, lineHeight: 1.6, color: "var(--color-text-secondary)", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
<p><strong>Qué datos guardamos.</strong> Cuando propones un local guardamos el nombre del negocio, su dirección o enlace de Google Maps, categoría, una descripción opcional y una puntuación. Estos datos suelen referirse a un negocio (a menudo de terceros), no a datos personales tuyos.</p>
|
||||||
|
<p><strong>Revisión previa.</strong> Toda propuesta pasa por un administrador antes de publicarse, y puede ser rechazada si incumple las normas (contenido inapropiado, información falsa, etc.).</p>
|
||||||
|
<p><strong>Ubicación.</strong> Si usas "cerca de mí", tu posición se usa solo en tu dispositivo para calcular distancias; no se envía a nuestro servidor ni se guarda.</p>
|
||||||
|
<p><strong>Datos públicos de negocios.</strong> Los datos de los negocios listados (nombre, dirección) son públicos y equivalentes a los que aparecen en Google Maps u otros directorios similares. Cualquiera puede solicitar la corrección o eliminación de una ficha usando el botón "Reportar" en cada local.</p>
|
||||||
|
<p><strong>Favoritos.</strong> Tu lista de favoritos se guarda únicamente en tu propio dispositivo (localStorage), no en nuestros servidores.</p>
|
||||||
|
<p style={{ fontSize: 12, color: "var(--color-text-tertiary)" }}>Este texto es orientativo y no sustituye asesoría legal. Antes de publicar la app conviene adaptarlo con un profesional, especialmente en lo referente a datos de terceros (dueños de negocios) y RGPD.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { reportarLocal } from "../firebase.js";
|
||||||
|
|
||||||
|
const MOTIVOS = [
|
||||||
|
{ id: "cerrado", label: "Ha cerrado / ya no existe" },
|
||||||
|
{ id: "datos_incorrectos", label: "Los datos son incorrectos" },
|
||||||
|
{ id: "inapropiado", label: "Contenido inapropiado" },
|
||||||
|
{ id: "otro", label: "Otro motivo" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ReportarLocal({ localId }) {
|
||||||
|
const [abierto, setAbierto] = useState(false);
|
||||||
|
const [motivo, setMotivo] = useState("cerrado");
|
||||||
|
const [comentario, setComentario] = useState("");
|
||||||
|
const [enviando, setEnviando] = useState(false);
|
||||||
|
const [enviado, setEnviado] = useState(false);
|
||||||
|
|
||||||
|
if (enviado) {
|
||||||
|
return <p style={{ fontSize: 12, color: "#1E8449", margin: 0 }}>✅ Gracias, hemos recibido tu aviso.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!abierto) {
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={() => setAbierto(true)}
|
||||||
|
style={{ display: "inline-flex", alignItems: "center", gap: 4, background: "none", border: "none", padding: 0, fontSize: 12, color: "var(--color-text-tertiary)", cursor: "pointer", width: "fit-content" }}>
|
||||||
|
<i className="ti ti-flag" aria-hidden="true"></i> Reportar
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const enviar = async () => {
|
||||||
|
setEnviando(true);
|
||||||
|
try {
|
||||||
|
await reportarLocal(localId, motivo, comentario);
|
||||||
|
setEnviado(true);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
setEnviando(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: "var(--color-background-secondary)", borderRadius: "var(--border-radius-md)", padding: "8px 10px", display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
<p style={{ margin: 0, fontSize: 12, fontWeight: 500 }}>¿Qué está mal con este local?</p>
|
||||||
|
<select value={motivo} onChange={(e) => setMotivo(e.target.value)}
|
||||||
|
style={{ fontSize: 12, padding: "5px 8px", borderRadius: "var(--border-radius-md)", border: "0.5px solid var(--color-border-secondary)", background: "var(--color-background-primary)", color: "var(--color-text-primary)" }}>
|
||||||
|
{MOTIVOS.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<textarea value={comentario} onChange={(e) => setComentario(e.target.value)} placeholder="Comentario opcional..."
|
||||||
|
style={{ fontSize: 12, padding: "5px 8px", borderRadius: "var(--border-radius-md)", border: "0.5px solid var(--color-border-secondary)", background: "var(--color-background-primary)", color: "var(--color-text-primary)", resize: "vertical", minHeight: 40 }} />
|
||||||
|
<div style={{ display: "flex", gap: 6 }}>
|
||||||
|
<button type="button" onClick={enviar} disabled={enviando}
|
||||||
|
style={{ fontSize: 12, background: "#8B0000", color: "white", border: "none", borderRadius: "var(--border-radius-md)", padding: "5px 12px", cursor: "pointer" }}>
|
||||||
|
{enviando ? "Enviando..." : "Enviar aviso"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setAbierto(false)}
|
||||||
|
style={{ fontSize: 12, background: "none", border: "none", color: "var(--color-text-tertiary)", cursor: "pointer" }}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,12 @@ export {
|
|||||||
cerrarSesion,
|
cerrarSesion,
|
||||||
suscribirLocales,
|
suscribirLocales,
|
||||||
deleteLocal,
|
deleteLocal,
|
||||||
|
confirmarLocal,
|
||||||
|
reportarLocal,
|
||||||
|
obtenerReportes,
|
||||||
|
descartarReporte,
|
||||||
enviarPropuesta,
|
enviarPropuesta,
|
||||||
|
comprobarDuplicado,
|
||||||
suscribirPendientes,
|
suscribirPendientes,
|
||||||
aprobarPropuesta,
|
aprobarPropuesta,
|
||||||
rechazarPropuesta,
|
rechazarPropuesta,
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const CLAVE = "localesp_favoritos";
|
||||||
|
|
||||||
|
function leerFavoritos() {
|
||||||
|
try {
|
||||||
|
const datos = JSON.parse(localStorage.getItem(CLAVE) || "[]");
|
||||||
|
return Array.isArray(datos) ? datos : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favoritos guardados en el propio dispositivo (localStorage), sin necesidad
|
||||||
|
// de cuenta de usuario. Sincroniza entre pestañas mediante el evento "storage".
|
||||||
|
export default function useFavoritos() {
|
||||||
|
const [favoritos, setFavoritos] = useState(leerFavoritos);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const alCambiar = () => setFavoritos(leerFavoritos());
|
||||||
|
window.addEventListener("storage", alCambiar);
|
||||||
|
return () => window.removeEventListener("storage", alCambiar);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const esFavorito = useCallback((id) => favoritos.includes(id), [favoritos]);
|
||||||
|
|
||||||
|
const alternarFavorito = useCallback((id) => {
|
||||||
|
setFavoritos((actual) => {
|
||||||
|
const nuevo = actual.includes(id) ? actual.filter((x) => x !== id) : [...actual, id];
|
||||||
|
localStorage.setItem(CLAVE, JSON.stringify(nuevo));
|
||||||
|
return nuevo;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { favoritos, esFavorito, alternarFavorito };
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
// Pide la ubicación del navegador solo cuando el usuario la solicita explícitamente
|
||||||
|
// (botón "Cerca de mí"), nunca automáticamente al cargar la página.
|
||||||
|
export default function useUbicacion() {
|
||||||
|
const [ubicacion, setUbicacion] = useState(null); // { lat, lng }
|
||||||
|
const [buscando, setBuscando] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const pedirUbicacion = useCallback(() => {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
setError("Tu navegador no permite compartir ubicación.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBuscando(true);
|
||||||
|
setError("");
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
setUbicacion({ lat: pos.coords.latitude, lng: pos.coords.longitude });
|
||||||
|
setBuscando(false);
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
setError(err.code === 1 ? "Has denegado el acceso a tu ubicación." : "No se pudo obtener tu ubicación.");
|
||||||
|
setBuscando(false);
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: true, timeout: 10000 },
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const limpiarUbicacion = useCallback(() => { setUbicacion(null); setError(""); }, []);
|
||||||
|
|
||||||
|
return { ubicacion, buscando, error, pedirUbicacion, limpiarUbicacion };
|
||||||
|
}
|
||||||
@@ -17,6 +17,36 @@ export function parsearEnlaceGoogleMaps(url) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enlaces para abrir la ubicación de un local directamente en Google Maps o Waze,
|
||||||
|
// usando las coordenadas si existen (más preciso) o si no, el texto de la dirección.
|
||||||
|
export function enlaceGoogleMapsDesdeLocal(local) {
|
||||||
|
if (local?.lat && local?.lng) return `https://www.google.com/maps/search/?api=1&query=${local.lat},${local.lng}`;
|
||||||
|
if (local?.direccion) return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(local.direccion)}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enlaceWazeDesdeLocal(local) {
|
||||||
|
if (local?.lat && local?.lng) return `https://waze.com/ul?ll=${local.lat},${local.lng}&navigate=yes`;
|
||||||
|
if (local?.direccion) return `https://waze.com/ul?q=${encodeURIComponent(local.direccion)}&navigate=yes`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distancia en kilómetros entre dos coordenadas (fórmula de Haversine),
|
||||||
|
// usada para "cerca de mí" y para mostrar la distancia en cada tarjeta.
|
||||||
|
export function distanciaKm(lat1, lng1, lat2, lng2) {
|
||||||
|
const R = 6371;
|
||||||
|
const toRad = (d) => (d * Math.PI) / 180;
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLng = toRad(lng2 - lng1);
|
||||||
|
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||||
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatearDistancia(km) {
|
||||||
|
if (km == null) return "";
|
||||||
|
return km < 1 ? `${Math.round(km * 1000)} m` : `${km.toFixed(1)} km`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function geocodificarDireccion(direccion) {
|
export async function geocodificarDireccion(direccion) {
|
||||||
const query = encodeURIComponent(`${direccion}, España`);
|
const query = encodeURIComponent(`${direccion}, España`);
|
||||||
const url = `https://nominatim.openstreetmap.org/search?q=${query}&format=json&limit=1&countrycodes=es`;
|
const url = `https://nominatim.openstreetmap.org/search?q=${query}&format=json&limit=1&countrycodes=es`;
|
||||||
@@ -27,3 +57,24 @@ export async function geocodificarDireccion(direccion) {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Busca lugares/negocios por nombre (autocompletar). Usa Nominatim (OpenStreetMap),
|
||||||
|
// que no requiere clave de API. Nota: no es el autocompletar "de Google" (eso exigiría
|
||||||
|
// una clave de Google Places con facturación activada) pero da el mismo resultado
|
||||||
|
// práctico: escribes un nombre y aparecen varias opciones para elegir.
|
||||||
|
export async function buscarLugares(texto) {
|
||||||
|
const q = (texto || "").trim();
|
||||||
|
if (q.length < 3) return [];
|
||||||
|
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(`${q}, España`)}&format=json&addressdetails=1&namedetails=1&limit=6&countrycodes=es`;
|
||||||
|
const respuesta = await fetch(url, { headers: { "Accept-Language": "es", "User-Agent": "LocalesEspanoles/1.0" } });
|
||||||
|
const datos = await respuesta.json();
|
||||||
|
if (!datos?.length) return [];
|
||||||
|
return datos.map((d) => ({
|
||||||
|
id: d.place_id,
|
||||||
|
nombre: d.namedetails?.name || d.display_name.split(",")[0],
|
||||||
|
direccion: d.display_name,
|
||||||
|
provincia: d.address?.county || d.address?.province || d.address?.state || d.address?.city || "",
|
||||||
|
lat: parseFloat(d.lat),
|
||||||
|
lng: parseFloat(d.lon),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user