feat: directorio más fiable y más fácil de usar
deploy-branch / deploy (push) Successful in 2m34s
deploy-branch / teardown (push) Skipped

El objetivo de estos cambios es, por un lado, mantener los datos
actualizados y, por otro, mejorar la experiencia para quienes lo usan:

- Fiabilidad: los locales se pueden confirmar ("Sigue aquí") y
  reportar (cerrado, datos incorrectos...), y se avisa de duplicados
  antes de publicar, para que el contenido no se quede obsoleto ni
  repetido.

- Usabilidad: autocompletado al proponer, favoritos, "cerca de mí",
  compartir fichas, enlaces a Waze y una política de privacidad
  visible, para que sea más sencillo aportar y encontrar locales.
This commit is contained in:
2026-08-10 22:23:35 +02:00
parent 257fb9c622
commit b36867225c
14 changed files with 826 additions and 58 deletions
+14
View File
@@ -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
View File
@@ -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
View File
@@ -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 }}>
+164 -34
View File
@@ -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>
)} )}
<div style={{ display: "flex", gap: 8, marginTop: "1rem" }}> {/* Aviso de posible duplicado */}
<button onClick={enviarLocal} disabled={!formValido || saving} {posibleDuplicado && (
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" }}> <div role="alert" style={{ marginTop: 12, background: "#FDF2E9", border: "1px solid #F5CBA7", borderRadius: "var(--border-radius-md)", padding: "10px 14px", fontSize: 13, color: "#784212" }}>
{saving ? "Enviando..." : "📨 Enviar propuesta"} <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>
<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={() => 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" }}>
<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" }}>
{comprobandoDuplicado ? "Comprobando..." : saving ? "Enviando..." : "📨 Enviar propuesta"}
</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 }}> 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
View File
@@ -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`);
+103
View File
@@ -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>
);
}
+44 -4
View File
@@ -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 }}>
+106 -7
View File
@@ -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,17 +95,35 @@ 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>
{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> <span style={{ fontSize: 13, color: "var(--color-text-secondary)" }}>{local.direccion}</span>
)}
</div> </div>
)} )}
{(gmUrl || wazeUrl) && (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{gmUrl && ( {gmUrl && (
<a href={gmUrl} target="_blank" rel="noopener noreferrer" <a href={gmUrl} target="_blank" rel="noopener noreferrer"
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 }}> 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 }}>
<i className="ti ti-brand-google-maps" aria-hidden="true" style={{ fontSize: 14 }}></i> <i className="ti ti-brand-google-maps" aria-hidden="true" style={{ fontSize: 14 }}></i>
Ver en Google Maps Google Maps
</a> </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>
)} )}
{sinConfirmarReciente && !confirmado && (
<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 && ( {fechaFormateada && (
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: 0 }}>Publicado {fechaFormateada}</p> <p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: 0 }}>Publicado {fechaFormateada}</p>
)} )}
<ReportarLocal localId={local.id} />
</div>
</div> </div>
); );
} }
+31
View File
@@ -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>
);
}
+63
View File
@@ -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>
);
}
+5
View File
@@ -6,7 +6,12 @@ export {
cerrarSesion, cerrarSesion,
suscribirLocales, suscribirLocales,
deleteLocal, deleteLocal,
confirmarLocal,
reportarLocal,
obtenerReportes,
descartarReporte,
enviarPropuesta, enviarPropuesta,
comprobarDuplicado,
suscribirPendientes, suscribirPendientes,
aprobarPropuesta, aprobarPropuesta,
rechazarPropuesta, rechazarPropuesta,
+36
View File
@@ -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 };
}
+33
View File
@@ -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 };
}
+51
View File
@@ -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),
}));
}