diff --git a/server/db.js b/server/db.js
index 8c09b35..18aca3a 100644
--- a/server/db.js
+++ b/server/db.js
@@ -43,6 +43,15 @@ db.exec(`
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 (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
@@ -69,5 +78,10 @@ for (const table of ["locales", "pendientes"]) {
ensureColumn(table, "enlaceGoogleMaps", "TEXT");
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;
diff --git a/server/index.js b/server/index.js
index 925a233..f0f933e 100644
--- a/server/index.js
+++ b/server/index.js
@@ -22,6 +22,41 @@ app.get("/api/pendientes", (_req, res) => {
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
app.post("/api/propuestas", (req, res) => {
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();
db.prepare(`
- INSERT INTO locales (id, nombre, descripcion, provincia, subcategoria, direccion, enlaceGoogleMaps, puntuacion, categoria, fecha, lat, lng)
- 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);
+ INSERT INTO locales (id, nombre, descripcion, provincia, subcategoria, direccion, enlaceGoogleMaps, puntuacion, categoria, fecha, lat, lng, fechaConfirmacion)
+ 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, fecha);
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) => {
const { id } = req.params;
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 });
});
diff --git a/src/AdminPanel.jsx b/src/AdminPanel.jsx
index ed6dc69..84dfca7 100644
--- a/src/AdminPanel.jsx
+++ b/src/AdminPanel.jsx
@@ -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 (
@@ -92,12 +96,15 @@ function TarjetaPropuesta({ p, onAprobar, onRechazar, busy }) {
📍 {p.provincia}
- {p.direccion &&
🗺 {p.direccion} }
+ {p.direccion && (gmUrl ?
🗺 {p.direccion} :
🗺 {p.direccion} )}
Enviado: {fecha}
{p.descripcion &&
"{p.descripcion}"
}
- {p.enlaceGoogleMaps &&
Ver en Google Maps ↗ }
+
onAprobar(p)} disabled={busy} style={{ ...S.btn("#1E8449"), padding:"8px 16px", fontSize:13 }}>✓ Aprobar
@@ -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 && {obtenerCategoria(l.categoria).emoji} {l.categoria} }
📍 {l.provincia} · {new Date(l.fecha).toLocaleDateString("es-ES",{day:"numeric",month:"short",year:"numeric"})}
- {l.direccion && {l.direccion}
}
+ {l.direccion && (() => {
+ const url = l.enlaceGoogleMaps || enlaceGoogleMapsDesdeLocal(l);
+ return url
+ ? {l.direccion}
+ : {l.direccion}
;
+ })()}
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
@@ -225,6 +243,40 @@ function PanelAdmin({ email }) {
>
)}
+ {/* ── Reportes ── */}
+ {tab === "reportes" && (
+ reportes.length === 0 ? (
+
+
🚩
+
Sin reportes pendientes
+
Cuando alguien reporte un local (cerrado, datos incorrectos...), aparecerá aquí.
+
+ ) : (
+ <>
+ {reportes.length} reporte{reportes.length!==1?"s":""} sin revisar.
+ {reportes.map(r => (
+
+
+
+
+ {r.localNombre || "(local ya eliminado)"}
+ {{ cerrado:"Ha cerrado", datos_incorrectos:"Datos incorrectos", inapropiado:"Contenido inapropiado", otro:"Otro motivo" }[r.motivo] || r.motivo}
+
+
📍 {r.localProvincia} {r.localDireccion ? `· ${r.localDireccion}` : ""}
+ {r.comentario &&
"{r.comentario}"
}
+
Reportado: {new Date(r.fecha).toLocaleDateString("es-ES", { day:"numeric", month:"short", hour:"2-digit", minute:"2-digit" })}
+
+
+ {r.localId && eliminar(r.localId)} style={{ ...S.btn("#C0392B"), padding:"8px 16px", fontSize:13 }}>🗑 Eliminar local }
+ descartar(r.id)} style={{ ...S.btn("#EEE","#666"), padding:"8px 16px", fontSize:13 }}>Descartar aviso
+
+
+
+ ))}
+ >
+ )
+ )}
+
{/* ── Filtro de palabras ── */}
{tab === "filtro" && (
diff --git a/src/App.jsx b/src/App.jsx
index 7862c13..010664c 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -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: `
${cat.emoji} ${local.nombre}${labelDir}
`, 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(`
${local.nombre} ${local.provincia} ${cat.emoji} ${local.categoria} ${local.subcategoria ? `
${local.subcategoria} ` : ""}${local.direccion ? `
📍 ${local.direccion} ` : ""}${gmLink ? `
Ver en Google Maps ↗ ` : ""}
${stars} ${local.descripcion ? `
"${local.descripcion}" ` : ""}
`);
+ const gmLink = local.enlaceGoogleMaps || enlaceGoogleMapsDesdeLocal(local);
+ const wazeLink = enlaceWazeDesdeLocal(local);
+ const direccionHtml = local.direccion
+ ? `
📍 ${gmLink ? `${local.direccion} ` : local.direccion} `
+ : "";
+ const enlacesHtml = `${gmLink ? `
Google Maps ↗ ` : ""}${wazeLink ? `
Waze ↗ ` : ""}`;
+ marker.bindPopup(`
${local.nombre} ${local.provincia} ${cat.emoji} ${local.categoria} ${local.subcategoria ? `${local.subcategoria} ` : ""}${direccionHtml} ${enlacesHtml}${stars} ${local.descripcion ? `"${local.descripcion}" ` : ""}
`);
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 (
@@ -239,10 +303,7 @@ export default function App() {
El administrador revisará tu propuesta antes de publicarla.
-
- Nombre del local *
- setForm((f) => ({ ...f, nombre: e.target.value }))} />
-
+
Provincia *
setForm((f) => ({ ...f, provincia: e.target.value }))}>
@@ -276,6 +337,17 @@ export default function App() {
setForm((f) => ({ ...f, puntuacion: v }))} />
+
+ setAceptaPrivacidad(e.target.checked)} style={{ marginTop: 2 }} />
+
+ He leído y acepto la{" "}
+ setMostrarPrivacidad(true)} style={{ background: "none", border: "none", padding: 0, color: "#8B0000", textDecoration: "underline", cursor: "pointer", fontSize: 12 }}>
+ política de privacidad
+
+ {" "}sobre cómo se usan estos datos.
+
+
+
{/* Error filtro palabras */}
{errorFiltro && (
@@ -283,12 +355,33 @@ export default function App() {
)}
+ {/* Aviso de posible duplicado */}
+ {posibleDuplicado && (
+
+
+ Ya existe un local parecido
+
+
+ {posibleDuplicado.coincidencias.map((c) => (
+ {c.nombre} · {c.provincia}{c.direccion ? ` · ${c.direccion}` : ""} ({c.estado === "pendiente" ? "pendiente de revisión" : "ya publicado"})
+ ))}
+
+
+
+ {saving ? "Enviando..." : "Enviar de todos modos"}
+
+ 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
+
+
+ )}
+
-
- {saving ? "Enviando..." : "📨 Enviar propuesta"}
+ {comprobandoDuplicado ? "Comprobando..." : saving ? "Enviando..." : "📨 Enviar propuesta"}
- { 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
+ { 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
)}
@@ -305,7 +398,7 @@ export default function App() {
{vista === "lista" && (
<>
-
+
setBusqueda(e.target.value)} aria-label="Buscar por nombre, dirección o categoría" />
setFiltroSubcat(e.target.value)} disabled={!filtroCategoria} aria-label="Filtrar por tipo específico">
{filtroCategoria ? "Todos los tipos" : "Elige categoría arriba"}
@@ -319,9 +412,29 @@ export default function App() {
Reciente
★ Mejor
A-Z
+ {ubicacion && 📍 Más cerca }
+
+ {!ubicacion ? (
+
+ {buscandoUbicacion ? "Localizando..." : "Cerca de mí"}
+
+ ) : (
+ { 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" }}>
+ Ubicación activada ✕
+
+ )}
+ {errorUbicacion && {errorUbicacion} }
+ 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" }}>
+ Favoritos {favoritos.length > 0 && `(${favoritos.length})`}
+
+
+
{loading ? (
@@ -330,12 +443,21 @@ export default function App() {
) : localesFiltrados.length === 0 ? (
🏘️
-
{locales.length === 0 ? "Todavía no hay locales publicados" : "No hay locales que coincidan"}
-
Sé el primero en proponer uno — el administrador lo revisará y publicará
+
+ {locales.length === 0 ? "Todavía no hay locales publicados" : soloFavoritos ? "Aún no tienes favoritos guardados" : "No hay locales que coincidan"}
+
+
{soloFavoritos ? "Pulsa el corazón ♥ en un local para guardarlo aquí" : "Sé el primero en proponer uno — el administrador lo revisará y publicará"}
) : (
- {localesFiltrados.map((local) =>
)}
+ {localesFiltrados.map((local) => (
+
+
+
+ ))}
{localesFiltrados.length < locales.length && (
Mostrando {localesFiltrados.length} de {locales.length} locales
@@ -357,11 +479,13 @@ export default function App() {
))}
{locales.length === 0 &&
Todavía no hay locales publicados
}
+ {locales.length > 0 && localesFiltrados.length === 0 &&
Ningún local coincide con el filtro seleccionado
}
- {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}`}
@@ -369,8 +493,14 @@ export default function App() {
Los locales pasan por revisión antes de publicarse · ¿Tienes un negocio? Proponlo arriba
+ {" · "}
+ setMostrarPrivacidad(true)} style={{ background: "none", border: "none", padding: 0, fontSize: 11, color: "var(--color-text-tertiary)", textDecoration: "underline", cursor: "pointer" }}>
+ Política de privacidad
+
+ {mostrarPrivacidad &&
setMostrarPrivacidad(false)} />}
+
);
diff --git a/src/api.js b/src/api.js
index 718d614..1902032 100644
--- a/src/api.js
+++ b/src/api.js
@@ -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`);
diff --git a/src/components/AutocompletarLocal.jsx b/src/components/AutocompletarLocal.jsx
new file mode 100644
index 0000000..418870f
--- /dev/null
+++ b/src/components/AutocompletarLocal.jsx
@@ -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 (
+
+
Nombre del local *
+
handleChange(e.target.value)}
+ onFocus={() => sugerencias.length && setAbierto(true)}
+ role="combobox"
+ aria-expanded={abierto && sugerencias.length > 0}
+ aria-autocomplete="list"
+ />
+ {buscando && (
+
+ )}
+ {abierto && sugerencias.length > 0 && (
+
+ {sugerencias.map((s) => (
+
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",
+ }}>
+ {s.nombre}
+ {s.direccion}
+
+ ))}
+
+ )}
+
+ Escribe el nombre y elige una opción para rellenar dirección y ubicación automáticamente.
+
+
+ );
+}
diff --git a/src/components/BannerMovil.jsx b/src/components/BannerMovil.jsx
index 910dfa8..b8f0b7e 100644
--- a/src/components/BannerMovil.jsx
+++ b/src/components/BannerMovil.jsx
@@ -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 (
-
setCerrado(true)} aria-label="Cerrar aviso de instalación"
+ ✕
diff --git a/src/components/LocalCard.jsx b/src/components/LocalCard.jsx
index cbe3bd5..7b1adcd 100644
--- a/src/components/LocalCard.jsx
+++ b/src/components/LocalCard.jsx
@@ -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 (
{ e.currentTarget.style.borderColor = "var(--color-border-secondary)"; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--color-border-tertiary)"; }}
>
-
+
{local.nombre}
- {local.provincia &&
{local.provincia}
}
+
+ {local.provincia}
+ {distanciaKm != null && · {formatearDistancia(distanciaKm)} }
+
+
+
+
+
+
+ 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 }}>
+
+
@@ -32,16 +95,34 @@ export default function LocalCard({ local }) {
{local.direccion && (
)}
- {gmUrl && (
-
-
- Ver en Google Maps
-
+ {(gmUrl || wazeUrl) && (
+
)}
@@ -50,9 +131,27 @@ export default function LocalCard({ local }) {
"{local.descripcion}"
)}
- {fechaFormateada && (
-
Publicado {fechaFormateada}
+ {sinConfirmarReciente && !confirmado && (
+
+
+ Sin confirmar recientemente
+
+
+ {confirmando ? "..." : "Sigue aquí ✅"}
+
+
)}
+ {confirmado && (
+
✅ Gracias por confirmar que sigue activo.
+ )}
+
+
+ {fechaFormateada && (
+
Publicado {fechaFormateada}
+ )}
+
+
);
}
diff --git a/src/components/ModalPrivacidad.jsx b/src/components/ModalPrivacidad.jsx
new file mode 100644
index 0000000..1d79d8d
--- /dev/null
+++ b/src/components/ModalPrivacidad.jsx
@@ -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 (
+
+
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)" }}
+ >
+
+
Privacidad y condiciones
+
✕
+
+
+
+
Qué datos guardamos. 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.
+
Revisión previa. Toda propuesta pasa por un administrador antes de publicarse, y puede ser rechazada si incumple las normas (contenido inapropiado, información falsa, etc.).
+
Ubicación. 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.
+
Datos públicos de negocios. 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.
+
Favoritos. Tu lista de favoritos se guarda únicamente en tu propio dispositivo (localStorage), no en nuestros servidores.
+
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.
+
+
+
+ );
+}
diff --git a/src/components/ReportarLocal.jsx b/src/components/ReportarLocal.jsx
new file mode 100644
index 0000000..b97ef65
--- /dev/null
+++ b/src/components/ReportarLocal.jsx
@@ -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
✅ Gracias, hemos recibido tu aviso.
;
+ }
+
+ if (!abierto) {
+ return (
+
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" }}>
+ Reportar
+
+ );
+ }
+
+ const enviar = async () => {
+ setEnviando(true);
+ try {
+ await reportarLocal(localId, motivo, comentario);
+ setEnviado(true);
+ } catch (e) {
+ console.error(e);
+ }
+ setEnviando(false);
+ };
+
+ return (
+
+
¿Qué está mal con este local?
+
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) => {m.label} )}
+
+
+ );
+}
diff --git a/src/firebase.js b/src/firebase.js
index d161962..c3ced94 100644
--- a/src/firebase.js
+++ b/src/firebase.js
@@ -6,7 +6,12 @@ export {
cerrarSesion,
suscribirLocales,
deleteLocal,
+ confirmarLocal,
+ reportarLocal,
+ obtenerReportes,
+ descartarReporte,
enviarPropuesta,
+ comprobarDuplicado,
suscribirPendientes,
aprobarPropuesta,
rechazarPropuesta,
diff --git a/src/hooks/useFavoritos.js b/src/hooks/useFavoritos.js
new file mode 100644
index 0000000..e67aea2
--- /dev/null
+++ b/src/hooks/useFavoritos.js
@@ -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 };
+}
diff --git a/src/hooks/useUbicacion.js b/src/hooks/useUbicacion.js
new file mode 100644
index 0000000..3b0c10e
--- /dev/null
+++ b/src/hooks/useUbicacion.js
@@ -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 };
+}
diff --git a/src/utils/geo.js b/src/utils/geo.js
index 8768357..9c08665 100644
--- a/src/utils/geo.js
+++ b/src/utils/geo.js
@@ -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),
+ }));
+}