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 ↗} +
+ {gmUrl && Ver en Google Maps ↗} + {wazeUrl && Ver en Waze ↗} +
@@ -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}

; + })()} @@ -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 && } + +
+
+
+ ))} + + ) + )} + {/* ── 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.

-
- - setForm((f) => ({ ...f, nombre: e.target.value }))} /> -
+
setAceptaPrivacidad(e.target.checked)} style={{ marginTop: 2 }} /> + + He leído y acepto la{" "} + + {" "}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"})
  • + ))} +
+
+ + +
+
+ )} +
- - +
)} @@ -305,7 +398,7 @@ export default function App() { {vista === "lista" && ( <> -
+
setBusqueda(e.target.value)} aria-label="Buscar por nombre, dirección o categoría" />
+
+ {!ubicacion ? ( + + ) : ( + + )} + {errorUbicacion && {errorUbicacion}} + +
+ {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 + {" · "} +

+ {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 ( +
+ + 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) => ( + + ))} +
+ )} +

+ 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 (
-
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)}} +

+
+
+ +
@@ -32,16 +95,34 @@ export default function LocalCard({ local }) { {local.direccion && (
- {local.direccion} + {gmUrl ? ( + + {local.direccion} + + ) : ( + {local.direccion} + )}
)} - {gmUrl && ( - - - Ver en Google Maps - + {(gmUrl || wazeUrl) && ( +
+ {gmUrl && ( + + + Google Maps + + )} + {wazeUrl && ( + + + Waze + + )} +
)} @@ -50,9 +131,27 @@ export default function LocalCard({ local }) {

"{local.descripcion}"

)} - {fechaFormateada && ( -

Publicado {fechaFormateada}

+ {sinConfirmarReciente && !confirmado && ( +
+ + Sin confirmar recientemente + + +
)} + {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 ( + + ); + } + + 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?

+ +