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
+56 -4
View File
@@ -5,9 +5,11 @@ import {
escucharAuth, loginAdmin, registrarAdmin, cerrarSesion,
suscribirPendientes, aprobarPropuesta, rechazarPropuesta,
suscribirLocales, deleteLocal,
obtenerReportes, descartarReporte,
obtenerPalabrasFiltradas, guardarPalabrasFiltradas,
} from "./firebase.js";
import { obtenerCategoria } from "./constants/categorias.js";
import { enlaceGoogleMapsDesdeLocal, enlaceWazeDesdeLocal } from "./utils/geo.js";
import StarRating from "./components/StarRating.jsx";
const S = {
@@ -81,6 +83,8 @@ function PantallaAuth() {
function TarjetaPropuesta({ p, onAprobar, onRechazar, busy }) {
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 gmUrl = p.enlaceGoogleMaps || enlaceGoogleMapsDesdeLocal(p);
const wazeUrl = enlaceWazeDesdeLocal(p);
return (
<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 }}>
@@ -92,12 +96,15 @@ function TarjetaPropuesta({ p, onAprobar, onRechazar, busy }) {
</div>
<div style={{ display:"flex", flexWrap:"wrap", alignItems:"center", gap:16, fontSize:13, color:"#666" }}>
<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} />
<span style={{ color:"#AAA" }}>Enviado: {fecha}</span>
</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.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 style={{ display:"flex", gap:8, flexShrink:0 }}>
<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 [pendientes, setPendientes] = useState([]);
const [publicados, setPublicados] = useState([]);
const [reportes, setReportes] = useState([]);
const [palabras, setPalabras] = useState([]);
const [nuevaP, setNuevaP] = useState("");
const [cargando, setCargando] = useState(true);
@@ -120,6 +128,8 @@ function PanelAdmin({ email }) {
const [guardando, setGuardando] = useState(false);
const [buscador, setBuscador] = useState("");
const cargarReportes = () => obtenerReportes().then(setReportes).catch(() => {});
useEffect(() => {
setCargando(true);
let cancelado = false;
@@ -129,12 +139,14 @@ function PanelAdmin({ email }) {
suscribirLocales(l => !cancelado && setPublicados(l), () => {})
.then(fn => { if (cancelado) fn?.(); else limpiarPublicados = fn; });
obtenerPalabrasFiltradas().then(p => !cancelado && setPalabras(p));
cargarReportes();
return () => { cancelado = true; limpiarPendientes?.(); limpiarPublicados?.(); };
}, []);
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 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 p = nuevaP.trim().toLowerCase();
@@ -155,6 +167,7 @@ function PanelAdmin({ email }) {
const TABS = [
{ id:"pendientes", l:`Pendientes (${pendientes.length})` },
{ id:"publicados", l:`Publicados (${publicados.length})` },
{ id:"reportes", l:`Reportes${reportes.length ? ` (${reportes.length})` : ""}` },
{ 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>}
<span style={{ fontSize:12, color:"#AAA" }}>📍 {l.provincia} · {new Date(l.fecha).toLocaleDateString("es-ES",{day:"numeric",month:"short",year:"numeric"})}</span>
</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>
<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>
@@ -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 ── */}
{tab === "filtro" && (
<div style={{ maxWidth:620 }}>
+162 -32
View File
@@ -1,12 +1,17 @@
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 { 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 StarRating from "./components/StarRating.jsx";
import LocalCard from "./components/LocalCard.jsx";
import CampoUbicacion from "./components/CampoUbicacion.jsx";
import AutocompletarLocal from "./components/AutocompletarLocal.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 = {
nombre: "", provincia: "", categoria: "", subcategoria: "",
@@ -29,6 +34,14 @@ export default function App() {
const [filtroProvincia, setFiltroProvincia] = useState("");
const [orden, setOrden] = useState("fecha");
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 mapInstanceRef = useRef(null);
const markersRef = useRef([]);
@@ -55,10 +68,46 @@ export default function App() {
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 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 () => {
setErrorFiltro("");
setPosibleDuplicado(null);
// Comprobar filtro de palabras
const textoCompleto = [form.nombre, form.descripcion, form.direccion].join(" ");
if (contienepalabrasProhibidas(textoCompleto, palabrasProhibidas)) {
@@ -73,23 +122,27 @@ export default function App() {
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 {
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();
setMostrarForm(false);
setTimeout(() => setEnviado(false), 5000);
const chequeo = await comprobarDuplicado({ nombre: form.nombre, provincia: form.provincia, lat, lng });
setComprobandoDuplicado(false);
if (chequeo?.duplicado) {
setPosibleDuplicado({ coincidencias: chequeo.coincidencias, lat, lng });
return;
}
} catch (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 || [] : [];
@@ -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))
&& (!filtroCategoria || l.categoria === filtroCategoria)
&& (!filtroSubcat || l.subcategoria === filtroSubcat)
&& (!filtroProvincia || l.provincia === filtroProvincia);
&& (!filtroProvincia || l.provincia === filtroProvincia)
&& (!soloFavoritos || esFavorito(l.id));
})
.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 === "puntuacion") return b.puntuacion - a.puntuacion;
return a.nombre.localeCompare(b.nombre);
@@ -131,7 +190,7 @@ export default function App() {
useEffect(() => {
if (vista === "mapa" && mapInstanceRef.current && window.L) updateMarkers();
}, [locales, vista]);
}, [locales, vista, filtroCategoria, filtroSubcat, filtroProvincia, busqueda, soloFavoritos, favoritos]);
function initMap() {
if (!mapRef.current || mapInstanceRef.current) return;
@@ -148,7 +207,7 @@ export default function App() {
if (!L || !map) return;
markersRef.current.forEach((m) => m.remove());
markersRef.current = [];
locales.forEach((local) => {
localesFiltrados.forEach((local) => {
if (!local.lat || !local.lng) return;
const cat = obtenerCategoria(local.categoria);
const color = COLORES_MAPA[local.categoria] || cat.color;
@@ -158,8 +217,13 @@ export default function App() {
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] }),
}).addTo(map);
const gmLink = local.enlaceGoogleMaps || (local.direccion ? `https://www.google.com/maps/search/${encodeURIComponent(`${local.nombre} ${local.direccion}`)}` : null);
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 gmLink = local.enlaceGoogleMaps || enlaceGoogleMapsDesdeLocal(local);
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);
});
}
@@ -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 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 (
<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>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
<div>
<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>
<AutocompletarLocal form={form} setForm={setForm} />
<div>
<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 }))}>
@@ -276,6 +337,17 @@ export default function App() {
<StarRating value={form.puntuacion} onChange={(v) => setForm((f) => ({ ...f, puntuacion: v }))} />
</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 */}
{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 }}>
@@ -283,12 +355,33 @@ export default function App() {
</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" }}>
<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" }}>
{saving ? "Enviando..." : "📨 Enviar propuesta"}
{comprobandoDuplicado ? "Comprobando..." : saving ? "Enviando..." : "📨 Enviar propuesta"}
</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>
)}
@@ -305,7 +398,7 @@ export default function App() {
{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" />
<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>
@@ -319,9 +412,29 @@ export default function App() {
<option value="fecha">Reciente</option>
<option value="puntuacion"> Mejor</option>
<option value="nombre">A-Z</option>
{ubicacion && <option value="distancia">📍 Más cerca</option>}
</select>
</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 ? (
<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>
@@ -330,12 +443,21 @@ export default function App() {
) : localesFiltrados.length === 0 ? (
<div style={{ textAlign: "center", padding: "3rem", color: "var(--color-text-tertiary)" }}>
<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: "6px 0 0", fontSize: 13 }}> el primero en proponer uno el administrador lo revisará y publicará</p>
<p style={{ margin: 0, fontWeight: 500 }}>
{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 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 && (
<p style={{ textAlign: "center", fontSize: 12, color: "var(--color-text-tertiary)", margin: "4px 0" }}>
Mostrando {localesFiltrados.length} de {locales.length} locales
@@ -357,11 +479,13 @@ export default function App() {
))}
</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 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)" }}>
<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>
</div>
</div>
@@ -369,8 +493,14 @@ export default function App() {
<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
{" · "}
<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>
{mostrarPrivacidad && <ModalPrivacidad onCerrar={() => setMostrarPrivacidad(false)} />}
<BannerMovil />
</div>
);
+33
View File
@@ -54,6 +54,15 @@ export async function enviarPropuesta(local) {
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) {
const response = await fetch(`${API_URL}/aprobar/${p.id}`, {
method: "POST",
@@ -77,6 +86,30 @@ export async function deleteLocal(id) {
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() {
try {
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";
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 +
// manifest.webmanifest ya registrados en main.jsx/index.html: si el
// navegador soporta la instalación nativa (evento beforeinstallprompt),
// 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
// 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() {
const [cerrado, setCerrado] = useState(false);
const [cerrado, setCerrado] = useState(estaCerradoRecientemente);
const [visitasSuficientes, setVisitasSuficientes] = useState(false);
const { isInstalled, canPromptInstall, promptInstall, isIos } = usePwaInstall();
const [instalando, setInstalando] = useState(false);
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 () => {
setInstalando(true);
@@ -24,7 +64,7 @@ export default function BannerMovil() {
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)" }}>
<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>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 10 }}>
+114 -15
View File
@@ -1,5 +1,12 @@
import { useState } from "react";
import CategoriaBadge from "./CategoriaBadge.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) {
if (!fecha) return null;
@@ -8,22 +15,78 @@ function formatearFecha(fecha) {
return date.toLocaleDateString("es-ES", { day: "numeric", month: "long", year: "numeric" });
}
export default function LocalCard({ local }) {
const gmUrl = local.enlaceGoogleMaps || (local.direccion
? `https://www.google.com/maps/search/${encodeURIComponent(`${local.nombre} ${local.direccion}`)}`
: null);
function mesesDesde(fecha) {
if (!fecha) return null;
const date = new Date(fecha);
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 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 (
<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" }}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = "var(--color-border-secondary)"; }}
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 }}>
<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>
@@ -32,16 +95,34 @@ export default function LocalCard({ local }) {
{local.direccion && (
<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>
<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>
)}
{gmUrl && (
<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 }}>
<i className="ti ti-brand-google-maps" aria-hidden="true" style={{ fontSize: 14 }}></i>
Ver en Google Maps
</a>
{(gmUrl || wazeUrl) && (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{gmUrl && (
<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 }}>
<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 />
@@ -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>
)}
{fechaFormateada && (
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: 0 }}>Publicado {fechaFormateada}</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 && (
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: 0 }}>Publicado {fechaFormateada}</p>
)}
<ReportarLocal localId={local.id} />
</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,
suscribirLocales,
deleteLocal,
confirmarLocal,
reportarLocal,
obtenerReportes,
descartarReporte,
enviarPropuesta,
comprobarDuplicado,
suscribirPendientes,
aprobarPropuesta,
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;
}
// 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) {
const query = encodeURIComponent(`${direccion}, España`);
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;
}
// 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),
}));
}