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