feat(propuesta): pasarela paso a paso con autodetección de ubicación y FAB
- Campo universal de ubicación (Google Maps/Waze/OSM/coordenadas/dirección) sobre proveedores extensibles (src/utils/ubicacion) con resolver e inferencia inversa Nominatim (nombre/provincia pre-rellenados, alias cooficiales) - FAB flotante abajo a la derecha que abre la pasarela en un modal accesible (Escape, foco contenido, responsive); sin descripción ni puntuación; avisos de duplicado y filtro de palabras dentro del modal - Aviso PWA reposicionado: tarjeta abajo a la izquierda en móvil, botón compacto arriba a la izquierda en PC (no tapa el FAB) - Suite de pruebas: 88 unitarias/componente (vitest + RTL, offline) y 10 de integración (supertest + SQLite :memory: y Nominatim real opt-in) - CI: workflow de Gitea Actions con npm test + build en push/PR e integración manual
This commit is contained in:
+43
-106
@@ -4,26 +4,25 @@ import { CATEGORIAS, COLORES_MAPA, NOMBRES_CATEGORIAS, obtenerCategoria } from "
|
||||
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 BotonFlotante from "./components/BotonFlotante.jsx";
|
||||
import ModalPasarela from "./components/ModalPasarela.jsx";
|
||||
import useFavoritos from "./hooks/useFavoritos.js";
|
||||
import useUbicacion from "./hooks/useUbicacion.js";
|
||||
|
||||
const FORM_VACIO = {
|
||||
nombre: "", provincia: "", categoria: "", subcategoria: "",
|
||||
direccion: "", enlaceGoogleMaps: "", lat: null, lng: null,
|
||||
puntuacion: 0, descripcion: "",
|
||||
entradaUbicacion: "", proveedorUbicacion: "", // estado interno del paso de ubicación, no se envía
|
||||
};
|
||||
|
||||
// ── App principal ─────────────────────────────────────────────────────────────
|
||||
export default function App() {
|
||||
const [locales, setLocales] = useState([]);
|
||||
const [vista, setVista] = useState("lista");
|
||||
const [mostrarForm, setMostrarForm] = useState(false);
|
||||
const [pasarelaAbierta, setPasarelaAbierta] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [enviado, setEnviado] = useState(false); // confirmación de propuesta enviada
|
||||
@@ -83,6 +82,21 @@ export default function App() {
|
||||
|
||||
const resetForm = () => setForm(FORM_VACIO);
|
||||
|
||||
const abrirPasarela = () => {
|
||||
setPasarelaAbierta(true);
|
||||
setErrorFiltro("");
|
||||
setPosibleDuplicado(null);
|
||||
};
|
||||
|
||||
// Cerrar la pasarela descarta el borrador en curso
|
||||
const cerrarPasarela = () => {
|
||||
setPasarelaAbierta(false);
|
||||
resetForm();
|
||||
setAceptaPrivacidad(false);
|
||||
setErrorFiltro("");
|
||||
setPosibleDuplicado(null);
|
||||
};
|
||||
|
||||
const guardarPropuesta = async (lat, lng) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -90,13 +104,12 @@ export default function App() {
|
||||
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);
|
||||
setPasarelaAbierta(false);
|
||||
setPosibleDuplicado(null);
|
||||
setTimeout(() => setEnviado(false), 5000);
|
||||
} catch (e) {
|
||||
@@ -109,9 +122,9 @@ export default function App() {
|
||||
setErrorFiltro("");
|
||||
setPosibleDuplicado(null);
|
||||
// Comprobar filtro de palabras
|
||||
const textoCompleto = [form.nombre, form.descripcion, form.direccion].join(" ");
|
||||
const textoCompleto = [form.nombre, form.direccion].join(" ");
|
||||
if (contienepalabrasProhibidas(textoCompleto, palabrasProhibidas)) {
|
||||
setErrorFiltro("Tu propuesta contiene palabras no permitidas. Por favor, revisa el nombre, dirección o descripción.");
|
||||
setErrorFiltro("Tu propuesta contiene palabras no permitidas. Por favor, revisa el nombre o la dirección.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -240,9 +253,6 @@ export default function App() {
|
||||
const provincias = [...new Set(locales.map((l) => l.provincia))].sort();
|
||||
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 && aceptaPrivacidad;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720, margin: "0 auto", padding: "1.5rem 1rem", fontFamily: "var(--font-sans)" }}>
|
||||
{/* Header */}
|
||||
@@ -260,10 +270,6 @@ export default function App() {
|
||||
</h1>
|
||||
<p style={{ margin: "6px 0 0", fontSize: 13, color: "var(--color-text-secondary)" }}>Directorio colaborativo de negocios por toda España</p>
|
||||
</div>
|
||||
<button onClick={() => { setMostrarForm(!mostrarForm); setErrorFiltro(""); }}
|
||||
style={{ background: "#8B0000", color: "white", border: "none", borderRadius: "var(--border-radius-md)", padding: "8px 16px", fontSize: 14, fontWeight: 500, cursor: "pointer", display: "flex", alignItems: "center", gap: 6, whiteSpace: "nowrap" }}>
|
||||
<i className="ti ti-plus" aria-hidden="true"></i> Proponer local
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Confirmación enviada */}
|
||||
@@ -304,95 +310,6 @@ export default function App() {
|
||||
{filtroCategoria && <button onClick={() => { setFiltroCategoria(""); setFiltroSubcat(""); }} style={{ background: "none", border: "0.5px solid var(--color-border-secondary)", borderRadius: 20, padding: "5px 10px", fontSize: 12, cursor: "pointer", color: "var(--color-text-tertiary)" }}>✕ limpiar</button>}
|
||||
</div>
|
||||
|
||||
{/* Formulario de propuesta */}
|
||||
{mostrarForm && (
|
||||
<div style={{ background: "var(--color-background-primary)", border: "0.5px solid var(--color-border-secondary)", borderRadius: "var(--border-radius-lg)", padding: "1.25rem", marginBottom: "1.5rem" }}>
|
||||
<p style={{ margin: "0 0 4px", fontWeight: 500, fontSize: 16 }}>Proponer un local</p>
|
||||
<p style={{ margin: "0 0 1rem", fontSize: 13, color: "var(--color-text-secondary)" }}>El administrador revisará tu propuesta antes de publicarla.</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
||||
<AutocompletarLocal form={form} setForm={setForm} />
|
||||
<div>
|
||||
<label htmlFor="campo-provincia" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Provincia *</label>
|
||||
<select id="campo-provincia" style={inputStyle} value={form.provincia} onChange={(e) => setForm((f) => ({ ...f, provincia: e.target.value }))}>
|
||||
<option value="">Selecciona provincia...</option>
|
||||
{PROVINCIAS.map((p) => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="campo-categoria" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Categoría *</label>
|
||||
<select id="campo-categoria" style={inputStyle} value={form.categoria} onChange={(e) => setForm((f) => ({ ...f, categoria: e.target.value, subcategoria: "" }))}>
|
||||
<option value="">Selecciona categoría...</option>
|
||||
{NOMBRES_CATEGORIAS.map((c) => <option key={c} value={c}>{CATEGORIAS[c].emoji} {c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="campo-subcategoria" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Tipo específico</label>
|
||||
<select id="campo-subcategoria" style={{ ...inputStyle, opacity: form.categoria ? 1 : 0.5 }} value={form.subcategoria} onChange={(e) => setForm((f) => ({ ...f, subcategoria: e.target.value }))} disabled={!form.categoria}>
|
||||
<option value="">{form.categoria ? "Selecciona tipo..." : "Elige categoría primero"}</option>
|
||||
{form.categoria && CATEGORIAS[form.categoria]?.subcategorias.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<CampoUbicacion form={form} setForm={setForm} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<label htmlFor="campo-descripcion" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Descripción / comentario</label>
|
||||
<textarea id="campo-descripcion" style={{ ...inputStyle, resize: "vertical", minHeight: 60 }} placeholder="¿Qué lo hace especial? Horarios, servicios..." value={form.descripcion} onChange={(e) => setForm((f) => ({ ...f, descripcion: e.target.value }))} />
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 6 }}>Puntuación *</span>
|
||||
<StarRating value={form.puntuacion} onChange={(v) => setForm((f) => ({ ...f, puntuacion: v }))} />
|
||||
</div>
|
||||
|
||||
<label style={{ display: "flex", alignItems: "flex-start", gap: 8, marginTop: 12, fontSize: 12, color: "var(--color-text-secondary)", cursor: "pointer" }}>
|
||||
<input type="checkbox" checked={aceptaPrivacidad} onChange={(e) => setAceptaPrivacidad(e.target.checked)} style={{ marginTop: 2 }} />
|
||||
<span>
|
||||
He leído y acepto la{" "}
|
||||
<button type="button" onClick={() => setMostrarPrivacidad(true)} style={{ background: "none", border: "none", padding: 0, color: "#8B0000", textDecoration: "underline", cursor: "pointer", fontSize: 12 }}>
|
||||
política de privacidad
|
||||
</button>
|
||||
{" "}sobre cómo se usan estos datos.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Error filtro palabras */}
|
||||
{errorFiltro && (
|
||||
<div role="alert" style={{ marginTop: 12, background: "#FDEDEC", border: "1px solid #F5B7B1", borderRadius: "var(--border-radius-md)", padding: "10px 14px", fontSize: 13, color: "#922B21", display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<i className="ti ti-ban" aria-hidden="true"></i> {errorFiltro}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aviso de posible duplicado */}
|
||||
{posibleDuplicado && (
|
||||
<div role="alert" style={{ marginTop: 12, background: "#FDF2E9", border: "1px solid #F5CBA7", borderRadius: "var(--border-radius-md)", padding: "10px 14px", fontSize: 13, color: "#784212" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: 500 }}>
|
||||
<i className="ti ti-alert-triangle" aria-hidden="true"></i> Ya existe un local parecido
|
||||
</div>
|
||||
<ul style={{ margin: "6px 0", paddingLeft: 20 }}>
|
||||
{posibleDuplicado.coincidencias.map((c) => (
|
||||
<li key={c.id}>{c.nombre} · {c.provincia}{c.direccion ? ` · ${c.direccion}` : ""} ({c.estado === "pendiente" ? "pendiente de revisión" : "ya publicado"})</li>
|
||||
))}
|
||||
</ul>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 4 }}>
|
||||
<button onClick={confirmarPeseADuplicado} disabled={saving}
|
||||
style={{ background: "#8B0000", color: "white", border: "none", borderRadius: "var(--border-radius-md)", padding: "6px 14px", fontSize: 12, fontWeight: 500, cursor: "pointer" }}>
|
||||
{saving ? "Enviando..." : "Enviar de todos modos"}
|
||||
</button>
|
||||
<button onClick={() => setPosibleDuplicado(null)} style={{ background: "none", border: "0.5px solid var(--color-border-secondary)", borderRadius: "var(--border-radius-md)", padding: "6px 14px", fontSize: 12, cursor: "pointer", color: "var(--color-text-secondary)" }}>Revisar datos</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: 8, marginTop: "1rem" }}>
|
||||
<button onClick={enviarLocal} disabled={!formValido || saving || comprobandoDuplicado}
|
||||
style={{ background: formValido ? "#8B0000" : "var(--color-background-secondary)", color: formValido ? "white" : "var(--color-text-tertiary)", border: "none", borderRadius: "var(--border-radius-md)", padding: "8px 20px", fontSize: 14, fontWeight: 500, cursor: formValido ? "pointer" : "not-allowed" }}>
|
||||
{comprobandoDuplicado ? "Comprobando..." : saving ? "Enviando..." : "📨 Enviar propuesta"}
|
||||
</button>
|
||||
<button onClick={() => { setMostrarForm(false); setErrorFiltro(""); setPosibleDuplicado(null); }} style={{ background: "none", border: "0.5px solid var(--color-border-secondary)", borderRadius: "var(--border-radius-md)", padding: "8px 16px", fontSize: 14, cursor: "pointer", color: "var(--color-text-secondary)" }}>Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div role="tablist" aria-label="Vista de locales" style={{ display: "flex", gap: 6, marginBottom: "1rem", borderBottom: "0.5px solid var(--color-border-tertiary)", paddingBottom: 8 }}>
|
||||
@@ -500,13 +417,33 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", textAlign: "center", marginTop: "1.5rem" }}>
|
||||
Los locales pasan por revisión antes de publicarse · ¿Tienes un negocio? Proponlo arriba
|
||||
Los locales pasan por revisión antes de publicarse · ¿Tienes un negocio? Proponlo con el botón flotante ➕
|
||||
{" · "}
|
||||
<button type="button" onClick={() => setMostrarPrivacidad(true)} style={{ background: "none", border: "none", padding: 0, fontSize: 11, color: "var(--color-text-tertiary)", textDecoration: "underline", cursor: "pointer" }}>
|
||||
Política de privacidad
|
||||
</button>
|
||||
</p>
|
||||
|
||||
{/* Botón flotante y pasarela de propuesta */}
|
||||
<BotonFlotante onClick={abrirPasarela} />
|
||||
{pasarelaAbierta && (
|
||||
<ModalPasarela
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
onCerrar={cerrarPasarela}
|
||||
onEnviar={enviarLocal}
|
||||
onConfirmarDuplicado={confirmarPeseADuplicado}
|
||||
onDescartarDuplicado={() => setPosibleDuplicado(null)}
|
||||
aceptaPrivacidad={aceptaPrivacidad}
|
||||
setAceptaPrivacidad={setAceptaPrivacidad}
|
||||
onAbrirPrivacidad={() => setMostrarPrivacidad(true)}
|
||||
errorFiltro={errorFiltro}
|
||||
posibleDuplicado={posibleDuplicado}
|
||||
comprobandoDuplicado={comprobandoDuplicado}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mostrarPrivacidad && <ModalPrivacidad onCerrar={() => setMostrarPrivacidad(false)} />}
|
||||
|
||||
<BannerMovil />
|
||||
|
||||
+11
-2
@@ -141,8 +141,17 @@ let currentUser = null;
|
||||
|
||||
export function escucharAuth(cb) {
|
||||
// Simple localStorage-based auth
|
||||
const user = localStorage.getItem("user");
|
||||
currentUser = user ? JSON.parse(user) : null;
|
||||
// Robusto ante valores corruptos/legados: si el JSON no parsea, se limpia y
|
||||
// se trata como sesión cerrada (antes esto rompía /admin en blanco).
|
||||
let user = null;
|
||||
try {
|
||||
const raw = localStorage.getItem("user");
|
||||
user = raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
try { localStorage.removeItem("user"); } catch { /* almacenamiento no disponible */ }
|
||||
user = null;
|
||||
}
|
||||
currentUser = user;
|
||||
cb(currentUser);
|
||||
|
||||
// Listen for storage changes
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import usePwaInstall from "../hooks/usePwaInstall.js";
|
||||
import useEsMovil from "../hooks/useEsMovil.js";
|
||||
|
||||
const CLAVE_VISITAS = "localesp_visitas";
|
||||
const CLAVE_CERRADO = "localesp_banner_cerrado_hasta";
|
||||
@@ -25,22 +26,31 @@ function estaCerradoRecientemente() {
|
||||
}
|
||||
}
|
||||
|
||||
// Banner flotante para instalar/abrir la PWA. Usa el service worker +
|
||||
// Aviso 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
|
||||
// modo standalone (o el navegador confirma que está instalada), el aviso
|
||||
// se oculta porque ya no aporta nada mostrarlo.
|
||||
//
|
||||
// Colocación (no debe tapar el FAB de "proponer local", abajo a la derecha):
|
||||
// - Móvil: tarjeta completa abajo a la IZQUIERDA, respetando la barra
|
||||
// inferior de la PWA (env(safe-area-inset-bottom)).
|
||||
// - PC: botón compacto arriba a la izquierda; al pulsarlo instala
|
||||
// directamente o, si el navegador no soporta el diálogo nativo,
|
||||
// despliega la tarjeta con las instrucciones.
|
||||
//
|
||||
// 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
|
||||
// usuario (contador en localStorage) y, si se cierra con ✕, no vuelve a
|
||||
// aparecer durante 14 días.
|
||||
export default function BannerMovil() {
|
||||
const [cerrado, setCerrado] = useState(estaCerradoRecientemente);
|
||||
const [visitasSuficientes, setVisitasSuficientes] = useState(false);
|
||||
const [desplegada, setDesplegada] = useState(false); // tarjeta expandida bajo el botón en PC
|
||||
const { isInstalled, canPromptInstall, promptInstall, isIos } = usePwaInstall();
|
||||
const [instalando, setInstalando] = useState(false);
|
||||
const [resultado, setResultado] = useState(null); // "accepted" | "dismissed" | null
|
||||
const esMovil = useEsMovil();
|
||||
|
||||
useEffect(() => {
|
||||
setVisitasSuficientes(registrarVisitaYContar() >= VISITAS_MINIMAS);
|
||||
@@ -60,11 +70,14 @@ export default function BannerMovil() {
|
||||
const outcome = await promptInstall();
|
||||
setResultado(outcome);
|
||||
setInstalando(false);
|
||||
if (outcome === "dismissed") setDesplegada(true); // se muestra el mensaje "más tarde"
|
||||
};
|
||||
|
||||
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={handleCerrar} aria-label="Cerrar aviso de instalación"
|
||||
// Tarjeta completa (instrucciones / instalación). `onCerrarTarjeta`
|
||||
// difiere: en móvil aplica la regla de 14 días; en PC solo la recoge.
|
||||
const tarjeta = (onCerrarTarjeta, estiloExtra = {}) => (
|
||||
<div style={{ position: "relative", background: "white", border: "1px solid #E5DDD5", borderRadius: 14, padding: "14px 18px", boxShadow: "0 6px 24px rgba(0,0,0,0.10)", width: 280, boxSizing: "border-box", fontFamily: "var(--font-sans)", ...estiloExtra }}>
|
||||
<button onClick={onCerrarTarjeta} 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 }}>
|
||||
@@ -99,4 +112,31 @@ export default function BannerMovil() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Móvil: tarjeta abajo a la izquierda (el FAB está abajo a la derecha) ──
|
||||
if (esMovil) {
|
||||
return (
|
||||
<div style={{ position: "fixed", left: 16, bottom: 0, zIndex: 999, paddingBottom: "env(safe-area-inset-bottom)" }}>
|
||||
{tarjeta(handleCerrar, { marginTop: 16 })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── PC: botón compacto arriba a la izquierda ──
|
||||
return (
|
||||
<div style={{ position: "fixed", top: 20, left: 20, zIndex: 999 }}>
|
||||
<div style={{ display: "flex", alignItems: "stretch", gap: 6 }}>
|
||||
<button
|
||||
onClick={canPromptInstall ? handleInstalar : () => setDesplegada((v) => !v)}
|
||||
disabled={instalando}
|
||||
aria-expanded={desplegada}
|
||||
style={{ display: "flex", alignItems: "center", gap: 7, background: "#8B0000", color: "white", border: "none", borderRadius: 999, padding: "9px 16px", fontSize: 13, fontWeight: 700, cursor: instalando ? "default" : "pointer", fontFamily: "var(--font-sans)", boxShadow: "0 4px 14px rgba(139,0,0,0.30)", opacity: instalando ? 0.7 : 1 }}>
|
||||
{instalando ? "Abriendo instalación..." : "📲 Instalar app"}
|
||||
</button>
|
||||
<button onClick={handleCerrar} aria-label="Ocultar aviso de instalación (no volverá a mostrarse en 14 días)" title="No mostrar más durante 14 días"
|
||||
style={{ background: "white", border: "1px solid #E5DDD5", borderRadius: 999, width: 30, cursor: "pointer", color: "#999", fontSize: 13, boxShadow: "0 4px 14px rgba(0,0,0,0.08)" }}>✕</button>
|
||||
</div>
|
||||
{desplegada && tarjeta(() => setDesplegada(false), { marginTop: 8 })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState } from "react";
|
||||
|
||||
const TEXTO_TOOLTIP = "Añade un nuevo local al directorio";
|
||||
|
||||
// Botón de acción flotante (FAB) para proponer un local: fijo en la esquina
|
||||
// inferior derecha, visible en PC y móvil, respetando la barra de la PWA con
|
||||
// env(safe-area-inset-bottom). Su z-index queda por debajo del modal de la
|
||||
// pasarela (1000) y del toast (2000). Muestra un tooltip propio al hacer
|
||||
// hover o recibir foco (posicionado a la izquierda) y expone aria-label
|
||||
// para accesibilidad sin ratón.
|
||||
export default function BotonFlotante({ onClick }) {
|
||||
const [tooltipVisible, setTooltipVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
right: 16,
|
||||
bottom: 0,
|
||||
zIndex: 900,
|
||||
paddingBottom: "env(safe-area-inset-bottom)",
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "relative", marginBottom: 16 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={TEXTO_TOOLTIP}
|
||||
onMouseEnter={() => setTooltipVisible(true)}
|
||||
onMouseLeave={() => setTooltipVisible(false)}
|
||||
onFocus={() => setTooltipVisible(true)}
|
||||
onBlur={() => setTooltipVisible(false)}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
width: 54, height: 54, borderRadius: "50%",
|
||||
background: "#8B0000", color: "white", border: "none",
|
||||
boxShadow: "0 4px 14px rgba(139,0,0,0.4)",
|
||||
fontSize: 24, cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<i className="ti ti-plus" aria-hidden="true"></i>
|
||||
</button>
|
||||
{tooltipVisible && (
|
||||
<span
|
||||
role="tooltip"
|
||||
style={{
|
||||
position: "absolute", right: "calc(100% + 10px)", top: "50%", transform: "translateY(-50%)",
|
||||
background: "var(--color-text-primary)", color: "var(--color-background-primary)",
|
||||
padding: "6px 10px", borderRadius: "var(--border-radius-md)",
|
||||
fontSize: 12, fontWeight: 500, whiteSpace: "nowrap", pointerEvents: "none",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.25)",
|
||||
}}
|
||||
>
|
||||
{TEXTO_TOOLTIP}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { geocodificarDireccion, parsearEnlaceGoogleMaps } from "../utils/geo.js";
|
||||
|
||||
const inputStyle = {
|
||||
width: "100%", boxSizing: "border-box", padding: "8px 12px",
|
||||
borderRadius: "var(--border-radius-md)", border: "0.5px solid var(--color-border-secondary)",
|
||||
background: "var(--color-background-primary)", color: "var(--color-text-primary)", fontSize: 14, outline: "none",
|
||||
};
|
||||
|
||||
export default function CampoUbicacion({ form, setForm }) {
|
||||
const [modoInput, setModoInput] = useState("direccion");
|
||||
const [geocodificando, setGeocodificando] = useState(false);
|
||||
const [resultadoGeo, setResultadoGeo] = useState(null);
|
||||
const debounceRef = useRef(null);
|
||||
|
||||
const handleDireccionChange = (val) => {
|
||||
setForm((f) => ({ ...f, direccion: val, lat: null, lng: null, enlaceGoogleMaps: "" }));
|
||||
setResultadoGeo(null);
|
||||
clearTimeout(debounceRef.current);
|
||||
if (val.trim().length < 8) return;
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setGeocodificando(true);
|
||||
try {
|
||||
const geo = await geocodificarDireccion(val);
|
||||
if (geo) {
|
||||
setForm((f) => ({ ...f, lat: geo.lat, lng: geo.lng }));
|
||||
setResultadoGeo({ ok: true, texto: geo.displayName });
|
||||
} else {
|
||||
setResultadoGeo({ ok: false, texto: "No se encontró la dirección. Se usará el centro de la provincia." });
|
||||
}
|
||||
} catch {
|
||||
setResultadoGeo({ ok: false, texto: "Error al geocodificar. Se usará el centro de la provincia." });
|
||||
}
|
||||
setGeocodificando(false);
|
||||
}, 900);
|
||||
};
|
||||
|
||||
const handleEnlaceChange = (val) => {
|
||||
setForm((f) => ({ ...f, enlaceGoogleMaps: val, lat: null, lng: null, direccion: "" }));
|
||||
setResultadoGeo(null);
|
||||
if (!val.trim()) return;
|
||||
const coords = parsearEnlaceGoogleMaps(val);
|
||||
if (coords) {
|
||||
setForm((f) => ({ ...f, lat: coords.lat, lng: coords.lng }));
|
||||
setResultadoGeo({ ok: true, texto: `Coordenadas detectadas: ${coords.lat.toFixed(5)}, ${coords.lng.toFixed(5)}` });
|
||||
} else {
|
||||
setResultadoGeo({ ok: false, texto: "No se pudieron extraer coordenadas. Copia la URL completa desde Google Maps." });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ gridColumn: "1 / -1", marginTop: 4 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 6 }}>
|
||||
<label style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
|
||||
Ubicación <span style={{ color: "#8B0000" }}>*</span>
|
||||
</label>
|
||||
<div style={{ display: "flex", border: "0.5px solid var(--color-border-secondary)", borderRadius: "var(--border-radius-md)", overflow: "hidden" }}>
|
||||
{[{ id: "direccion", label: "📍 Dirección" }, { id: "enlace", label: "🔗 Google Maps" }].map((opt) => (
|
||||
<button key={opt.id} type="button"
|
||||
onClick={() => { setModoInput(opt.id); setResultadoGeo(null); setForm((f) => ({ ...f, lat: null, lng: null, direccion: "", enlaceGoogleMaps: "" })); }}
|
||||
style={{ background: modoInput === opt.id ? "var(--color-background-secondary)" : "none", border: "none", padding: "4px 10px", fontSize: 11, cursor: "pointer", fontWeight: modoInput === opt.id ? 500 : 400, color: modoInput === opt.id ? "var(--color-text-primary)" : "var(--color-text-secondary)" }}>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modoInput === "direccion" ? (
|
||||
<input style={inputStyle} value={form.direccion || ""} placeholder="Ej: Calle Mayor 5, Alcalá de Henares, Madrid"
|
||||
onChange={(e) => handleDireccionChange(e.target.value)} />
|
||||
) : (
|
||||
<input style={inputStyle} value={form.enlaceGoogleMaps || ""} placeholder="Pega aquí el enlace de Google Maps..."
|
||||
onChange={(e) => handleEnlaceChange(e.target.value)} />
|
||||
)}
|
||||
|
||||
{geocodificando && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 6, fontSize: 12, color: "var(--color-text-secondary)" }}>
|
||||
<i className="ti ti-loader" aria-hidden="true" style={{ fontSize: 14 }}></i> Buscando dirección...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resultadoGeo && !geocodificando && (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "flex-start", gap: 6, marginTop: 6, fontSize: 12,
|
||||
color: resultadoGeo.ok ? "#1E8449" : "#784212",
|
||||
background: resultadoGeo.ok ? "#EAFAF1" : "#FDF2E9",
|
||||
padding: "6px 10px", borderRadius: "var(--border-radius-md)",
|
||||
}}>
|
||||
<i className={`ti ${resultadoGeo.ok ? "ti-circle-check" : "ti-alert-triangle"}`} aria-hidden="true" style={{ fontSize: 14, flexShrink: 0, marginTop: 1 }}></i>
|
||||
<span style={{ lineHeight: 1.4 }}>{resultadoGeo.texto}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import useEsMovil from "../hooks/useEsMovil.js";
|
||||
import PasoUbicacion from "./PasoUbicacion.jsx";
|
||||
import PasoNombre from "./PasoNombre.jsx";
|
||||
import PasoProvincia from "./PasoProvincia.jsx";
|
||||
import PasoCategoria from "./PasoCategoria.jsx";
|
||||
import PasoSubcategoria from "./PasoSubcategoria.jsx";
|
||||
import PasoResumen from "./PasoResumen.jsx";
|
||||
|
||||
// Definición declarativa de las pantallas de la pasarela: orden, título y
|
||||
// validez de cada paso (habilita o deshabilita "Siguiente"). El paso final
|
||||
// gestiona su propio botón de envío en PasoResumen.
|
||||
const PASOS = [
|
||||
{ id: "ubicacion", titulo: "Ubicación", Componente: PasoUbicacion, puedeContinuar: (f) => f.lat != null && f.lng != null },
|
||||
{ id: "nombre", titulo: "Nombre", Componente: PasoNombre, puedeContinuar: (f) => (f.nombre || "").trim().length >= 2 },
|
||||
{ id: "provincia", titulo: "Provincia", Componente: PasoProvincia, puedeContinuar: (f) => !!f.provincia },
|
||||
{ id: "categoria", titulo: "Categoría", Componente: PasoCategoria, puedeContinuar: (f) => !!f.categoria },
|
||||
{ id: "subcategoria", titulo: "Tipo específico", Componente: PasoSubcategoria, puedeContinuar: () => true },
|
||||
{ id: "resumen", titulo: "Revisión y envío", Componente: PasoResumen, puedeContinuar: () => true },
|
||||
];
|
||||
|
||||
// Modal de la pasarela para proponer un local: una pantalla por campo con
|
||||
// navegación atrás/siguiente e indicador de progreso. Sigue el patrón de
|
||||
// ModalPrivacidad (overlay + role="dialog" + cierre por fondo/✕/Escape) y
|
||||
// añade contención del foco de teclado. Responsive: en móvil el panel ocupa
|
||||
// todo el ancho y casi todo el alto; en escritorio queda centrado (~480px).
|
||||
export default function ModalPasarela({
|
||||
form, setForm,
|
||||
onCerrar, onEnviar, onConfirmarDuplicado, onDescartarDuplicado,
|
||||
aceptaPrivacidad, setAceptaPrivacidad, onAbrirPrivacidad,
|
||||
errorFiltro, posibleDuplicado, comprobandoDuplicado, saving,
|
||||
}) {
|
||||
const [paso, setPaso] = useState(0);
|
||||
const esMovil = useEsMovil();
|
||||
const panelRef = useRef(null);
|
||||
const cuerpoRef = useRef(null);
|
||||
|
||||
const pasoActual = PASOS[paso];
|
||||
const esUltimo = paso === PASOS.length - 1;
|
||||
const puedeContinuar = pasoActual.puedeContinuar(form);
|
||||
const enviando = comprobandoDuplicado || saving;
|
||||
|
||||
// Foco inicial en el primer control de la pantalla activa (no del panel
|
||||
// completo: el ✕ de la cabecera precede al cuerpo en el DOM). La
|
||||
// contención de Tab evita que el foco se escape del modal.
|
||||
useEffect(() => {
|
||||
const cuerpo = cuerpoRef.current;
|
||||
if (!cuerpo) return;
|
||||
const primerControl = cuerpo.querySelector("input, select, textarea, button:not([disabled])");
|
||||
primerControl?.focus();
|
||||
}, [paso]);
|
||||
|
||||
const alTeclear = (e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onCerrar();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab") return;
|
||||
const focos = panelRef.current?.querySelectorAll(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
if (!focos?.length) return;
|
||||
const primero = focos[0];
|
||||
const ultimo = focos[focos.length - 1];
|
||||
if (e.shiftKey && document.activeElement === primero) {
|
||||
e.preventDefault();
|
||||
ultimo.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === ultimo) {
|
||||
e.preventDefault();
|
||||
primero.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const { Componente } = pasoActual;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Proponer local: ${pasoActual.titulo}`}
|
||||
style={{
|
||||
position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", zIndex: 1000,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
padding: esMovil ? 8 : 16,
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onCerrar(); }}
|
||||
onKeyDown={alTeclear}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: "var(--color-background-primary)",
|
||||
borderRadius: "var(--border-radius-lg)",
|
||||
width: "100%",
|
||||
maxWidth: esMovil ? "none" : 480,
|
||||
height: esMovil ? "92dvh" : undefined,
|
||||
maxHeight: "92vh",
|
||||
display: "flex", flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{/* Cabecera: título, progreso y cierre */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10, padding: "14px 16px 10px", borderBottom: "0.5px solid var(--color-border-tertiary)" }}>
|
||||
<div>
|
||||
<p style={{ margin: 0, fontWeight: 600, fontSize: 16 }}>Proponer un local</p>
|
||||
<p style={{ margin: "2px 0 0", fontSize: 12, color: "var(--color-text-secondary)" }}>
|
||||
{pasoActual.titulo} · Paso {paso + 1} de {PASOS.length}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ display: "flex", gap: 4 }} aria-hidden="true">
|
||||
{PASOS.map((p, i) => (
|
||||
<span key={p.id} style={{ width: 16, height: 4, borderRadius: 2, background: i <= paso ? "#8B0000" : "var(--color-border-tertiary)", transition: "background 0.15s" }} />
|
||||
))}
|
||||
</div>
|
||||
<button onClick={onCerrar} aria-label="Cerrar" disabled={enviando} style={{ background: "none", border: "none", cursor: enviando ? "not-allowed" : "pointer", fontSize: 18, color: "var(--color-text-tertiary)" }}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cuerpo: la pantalla activa (con scroll si no cabe) */}
|
||||
<div ref={cuerpoRef} style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: "14px 16px" }}>
|
||||
<Componente
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
{...(pasoActual.id === "resumen" ? {
|
||||
aceptaPrivacidad, setAceptaPrivacidad, onAbrirPrivacidad,
|
||||
onEnviar, onConfirmarDuplicado, onDescartarDuplicado,
|
||||
errorFiltro, posibleDuplicado, comprobandoDuplicado, saving,
|
||||
} : {})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pie: navegación atrás/siguiente */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, padding: "12px 16px", borderTop: "0.5px solid var(--color-border-tertiary)" }}>
|
||||
<button
|
||||
onClick={paso === 0 ? onCerrar : () => setPaso((p) => p - 1)}
|
||||
disabled={enviando}
|
||||
style={{
|
||||
background: "none", border: "0.5px solid var(--color-border-secondary)",
|
||||
borderRadius: "var(--border-radius-md)", padding: "8px 16px",
|
||||
fontSize: 14, cursor: enviando ? "not-allowed" : "pointer",
|
||||
color: "var(--color-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{paso === 0 ? "Cancelar" : "← Atrás"}
|
||||
</button>
|
||||
{!esUltimo && (
|
||||
<button
|
||||
onClick={() => setPaso((p) => p + 1)}
|
||||
disabled={!puedeContinuar}
|
||||
style={{
|
||||
background: puedeContinuar ? "#8B0000" : "var(--color-background-secondary)",
|
||||
color: puedeContinuar ? "white" : "var(--color-text-tertiary)",
|
||||
border: "none", borderRadius: "var(--border-radius-md)",
|
||||
padding: "8px 20px", fontSize: 14, fontWeight: 500,
|
||||
cursor: puedeContinuar ? "pointer" : "not-allowed",
|
||||
}}
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { inputStyle } from "../styles/shared.js";
|
||||
import { CATEGORIAS, NOMBRES_CATEGORIAS } from "../constants/categorias.js";
|
||||
|
||||
// Paso 4 de la pasarela: categoría principal del negocio.
|
||||
export default function PasoCategoria({ form, setForm }) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="paso-categoria" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>
|
||||
Categoría <span style={{ color: "#8B0000" }}>*</span>
|
||||
</label>
|
||||
<select
|
||||
id="paso-categoria"
|
||||
style={inputStyle}
|
||||
value={form.categoria}
|
||||
onChange={(e) => setForm((f) => ({ ...f, categoria: e.target.value, subcategoria: "" }))}
|
||||
>
|
||||
<option value="">Selecciona categoría...</option>
|
||||
{NOMBRES_CATEGORIAS.map((c) => <option key={c} value={c}>{CATEGORIAS[c].emoji} {c}</option>)}
|
||||
</select>
|
||||
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: "6px 0 0" }}>
|
||||
Elige el grupo general al que pertenece el negocio.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { inputStyle } from "../styles/shared.js";
|
||||
|
||||
// Paso 2 de la pasarela: nombre del local, pre-rellenado con lo inferido
|
||||
// desde la ubicación (editable). Si la inferencia falló, llega vacío.
|
||||
export default function PasoNombre({ form, setForm }) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="paso-nombre" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>
|
||||
Nombre del local <span style={{ color: "#8B0000" }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="paso-nombre"
|
||||
style={inputStyle}
|
||||
autoComplete="off"
|
||||
placeholder="Bar El Olivo, Clínica San José..."
|
||||
value={form.nombre}
|
||||
onChange={(e) => setForm((f) => ({ ...f, nombre: e.target.value }))}
|
||||
/>
|
||||
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: "6px 0 0" }}>
|
||||
{form.nombre
|
||||
? "Si el nombre se rellenó automáticamente desde la ubicación, comprueba que es correcto antes de continuar."
|
||||
: "El nombre con el que la gente conoce el negocio."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { inputStyle } from "../styles/shared.js";
|
||||
import { PROVINCIAS } from "../constants/provincias.js";
|
||||
|
||||
// Paso 3 de la pasarela: provincia, pre-rellenada con lo inferido desde la
|
||||
// ubicación (si la inferencia falló o no casó con el listado, llega vacía).
|
||||
export default function PasoProvincia({ form, setForm }) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="paso-provincia" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>
|
||||
Provincia <span style={{ color: "#8B0000" }}>*</span>
|
||||
</label>
|
||||
<select
|
||||
id="paso-provincia"
|
||||
style={inputStyle}
|
||||
value={form.provincia}
|
||||
onChange={(e) => setForm((f) => ({ ...f, provincia: e.target.value }))}
|
||||
>
|
||||
<option value="">Selecciona provincia...</option>
|
||||
{PROVINCIAS.map((p) => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: "6px 0 0" }}>
|
||||
Si se rellenó automáticamente desde la ubicación, verifica que es correcta.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { CATEGORIAS } from "../constants/categorias.js";
|
||||
import { ETIQUETAS_PROVEEDOR } from "../utils/ubicacion/index.js";
|
||||
|
||||
const fila = { display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 10, padding: "6px 0", borderBottom: "0.5px solid var(--color-border-tertiary)", fontSize: 13 };
|
||||
const clave = { color: "var(--color-text-secondary)", flexShrink: 0 };
|
||||
const valor = { textAlign: "right", fontWeight: 500, wordBreak: "break-word" };
|
||||
|
||||
// Paso 6 (final) de la pasarela: resumen legible de todo lo introducido,
|
||||
// aceptación obligatoria de la política de privacidad y envío de la
|
||||
// propuesta. Los avisos de palabra prohibida y de posible duplicado se
|
||||
// muestran dentro del propio modal.
|
||||
export default function PasoResumen({
|
||||
form, aceptaPrivacidad, setAceptaPrivacidad, onAbrirPrivacidad,
|
||||
onEnviar, onConfirmarDuplicado, onDescartarDuplicado,
|
||||
errorFiltro, posibleDuplicado, comprobandoDuplicado, saving,
|
||||
}) {
|
||||
const categoria = form.categoria ? CATEGORIAS[form.categoria] : null;
|
||||
const enviando = comprobandoDuplicado || saving;
|
||||
const etiquetaProveedor = form.proveedorUbicacion ? ETIQUETAS_PROVEEDOR[form.proveedorUbicacion] || form.proveedorUbicacion : "";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p style={{ margin: "0 0 8px", fontSize: 13, color: "var(--color-text-secondary)" }}>
|
||||
Comprueba los datos antes de enviar. El administrador revisará la propuesta antes de publicarla.
|
||||
</p>
|
||||
|
||||
<div style={{ background: "var(--color-background-secondary)", borderRadius: "var(--border-radius-md)", padding: "4px 12px", marginBottom: 12 }}>
|
||||
<div style={fila}>
|
||||
<span style={clave}>📍 Ubicación</span>
|
||||
<span style={valor}>
|
||||
{etiquetaProveedor && <>{etiquetaProveedor}<br /></>}
|
||||
{form.lat != null && form.lng != null && <span style={{ fontWeight: 400 }}>{form.lat.toFixed(4)}, {form.lng.toFixed(4)}</span>}
|
||||
{form.direccion && <span style={{ fontWeight: 400, display: "block" }}>{form.direccion}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div style={fila}>
|
||||
<span style={clave}>🏷️ Nombre</span>
|
||||
<span style={valor}>{form.nombre}</span>
|
||||
</div>
|
||||
<div style={fila}>
|
||||
<span style={clave}>🗺️ Provincia</span>
|
||||
<span style={valor}>{form.provincia}</span>
|
||||
</div>
|
||||
<div style={fila}>
|
||||
<span style={clave}>📂 Categoría</span>
|
||||
<span style={valor}>{categoria ? `${categoria.emoji} ${form.categoria}` : ""}</span>
|
||||
</div>
|
||||
<div style={{ ...fila, borderBottom: "none" }}>
|
||||
<span style={clave}>🔖 Tipo específico</span>
|
||||
<span style={{ ...valor, fontWeight: form.subcategoria ? 500 : 400, color: form.subcategoria ? "inherit" : "var(--color-text-tertiary)" }}>
|
||||
{form.subcategoria || "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label style={{ display: "flex", alignItems: "flex-start", gap: 8, marginBottom: 12, fontSize: 12, color: "var(--color-text-secondary)", cursor: "pointer" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aceptaPrivacidad}
|
||||
onChange={(e) => setAceptaPrivacidad(e.target.checked)}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<span>
|
||||
He leído y acepto la{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.preventDefault(); onAbrirPrivacidad(); }}
|
||||
style={{ background: "none", border: "none", padding: 0, color: "#8B0000", textDecoration: "underline", cursor: "pointer", fontSize: 12 }}
|
||||
>
|
||||
política de privacidad
|
||||
</button>
|
||||
{" "}sobre cómo se usan estos datos. <span style={{ color: "#8B0000" }}>*</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Aviso de palabra prohibida */}
|
||||
{errorFiltro && (
|
||||
<div role="alert" style={{ marginBottom: 12, background: "#FDEDEC", border: "1px solid #F5B7B1", borderRadius: "var(--border-radius-md)", padding: "10px 14px", fontSize: 13, color: "#922B21", display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<i className="ti ti-ban" aria-hidden="true"></i> {errorFiltro}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aviso de posible duplicado */}
|
||||
{posibleDuplicado && (
|
||||
<div role="alert" style={{ marginBottom: 12, background: "#FDF2E9", border: "1px solid #F5CBA7", borderRadius: "var(--border-radius-md)", padding: "10px 14px", fontSize: 13, color: "#784212" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: 500 }}>
|
||||
<i className="ti ti-alert-triangle" aria-hidden="true"></i> Ya existe un local parecido
|
||||
</div>
|
||||
<ul style={{ margin: "6px 0", paddingLeft: 20 }}>
|
||||
{posibleDuplicado.coincidencias.map((c) => (
|
||||
<li key={c.id}>{c.nombre} · {c.provincia}{c.direccion ? ` · ${c.direccion}` : ""} ({c.estado === "pendiente" ? "pendiente de revisión" : "ya publicado"})</li>
|
||||
))}
|
||||
</ul>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 4 }}>
|
||||
<button onClick={onConfirmarDuplicado} disabled={saving}
|
||||
style={{ background: "#8B0000", color: "white", border: "none", borderRadius: "var(--border-radius-md)", padding: "6px 14px", fontSize: 12, fontWeight: 500, cursor: "pointer" }}>
|
||||
{saving ? "Enviando..." : "Enviar de todos modos"}
|
||||
</button>
|
||||
<button onClick={onDescartarDuplicado} style={{ background: "none", border: "0.5px solid var(--color-border-secondary)", borderRadius: "var(--border-radius-md)", padding: "6px 14px", fontSize: 12, cursor: "pointer", color: "var(--color-text-secondary)" }}>Revisar datos</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onEnviar}
|
||||
disabled={!aceptaPrivacidad || enviando}
|
||||
style={{
|
||||
background: aceptaPrivacidad ? "#8B0000" : "var(--color-background-secondary)",
|
||||
color: aceptaPrivacidad ? "white" : "var(--color-text-tertiary)",
|
||||
border: "none", borderRadius: "var(--border-radius-md)",
|
||||
padding: "10px 20px", fontSize: 14, fontWeight: 500, width: "100%",
|
||||
cursor: aceptaPrivacidad && !enviando ? "pointer" : "not-allowed",
|
||||
}}
|
||||
>
|
||||
{comprobandoDuplicado ? "Comprobando..." : saving ? "Enviando..." : "📨 Enviar propuesta"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { inputStyle } from "../styles/shared.js";
|
||||
import { CATEGORIAS } from "../constants/categorias.js";
|
||||
|
||||
// Paso 5 de la pasarela: tipo específico (subcategoría), opcional. Solo está
|
||||
// accesible tras elegir categoría (el paso anterior lo exige), y se puede
|
||||
// continuar sin seleccionar ninguna.
|
||||
export default function PasoSubcategoria({ form, setForm }) {
|
||||
const subcategorias = form.categoria ? CATEGORIAS[form.categoria]?.subcategorias || [] : [];
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="paso-subcategoria" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>
|
||||
Tipo específico
|
||||
</label>
|
||||
<select
|
||||
id="paso-subcategoria"
|
||||
style={{ ...inputStyle, opacity: form.categoria ? 1 : 0.5 }}
|
||||
value={form.subcategoria}
|
||||
onChange={(e) => setForm((f) => ({ ...f, subcategoria: e.target.value }))}
|
||||
disabled={!form.categoria}
|
||||
>
|
||||
<option value="">Sin tipo específico</option>
|
||||
{subcategorias.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: "6px 0 0" }}>
|
||||
Paso opcional: puedes continuar sin elegir un tipo específico.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ETIQUETAS_PROVEEDOR, inferirDesdeCoords, resolverUbicacion } from "../utils/ubicacion/index.js";
|
||||
import { inputStyle } from "../styles/shared.js";
|
||||
|
||||
const DEBOUNCE_MS = 600;
|
||||
// Proveedores cuya entrada es un enlace (se guarda como enlace del local)
|
||||
const PROVEEDORES_ENLACE = new Set(["google-maps", "waze", "osm"]);
|
||||
|
||||
// Paso 1 de la pasarela: un único campo universal de ubicación que acepta
|
||||
// enlaces de Google Maps / Waze / OpenStreetMap, coordenadas sueltas o una
|
||||
// dirección en texto. Con debounce resuelve la entrada con el primer
|
||||
// proveedor que la reconozca y, si solo hay coordenadas, lanza la
|
||||
// geocodificación inversa para pre-rellenar nombre/provincia/dirección.
|
||||
export default function PasoUbicacion({ form, setForm }) {
|
||||
const [texto, setTexto] = useState(form.entradaUbicacion || "");
|
||||
// fase: inactivo | resolviendo | resuelto | noreconocido
|
||||
const [estado, setEstado] = useState(() =>
|
||||
form.lat != null && form.lng != null
|
||||
? { fase: "resuelto", resolucion: { proveedor: form.proveedorUbicacion, lat: form.lat, lng: form.lng }, aviso: "" }
|
||||
: { fase: "inactivo", resolucion: null, aviso: "" },
|
||||
);
|
||||
const debounceRef = useRef(null);
|
||||
// Identificador de resolución en curso: descarta respuestas asíncronas
|
||||
// obsoletas si el usuario sigue escribiendo.
|
||||
const idResolucionRef = useRef(0);
|
||||
|
||||
useEffect(() => () => clearTimeout(debounceRef.current), []);
|
||||
|
||||
const resolver = async (entrada, id) => {
|
||||
if (id !== idResolucionRef.current) return;
|
||||
setEstado({ fase: "resolviendo", resolucion: null, aviso: "" });
|
||||
let resultado = null;
|
||||
try {
|
||||
resultado = await resolverUbicacion(entrada);
|
||||
} catch {
|
||||
resultado = null; // fallo controlado: la pasarela nunca se rompe
|
||||
}
|
||||
if (id !== idResolucionRef.current) return;
|
||||
|
||||
if (!resultado) {
|
||||
setEstado({
|
||||
fase: "noreconocido",
|
||||
resolucion: null,
|
||||
aviso: "No se pudo interpretar la ubicación. Si pegaste un enlace acortado, copia la URL completa; si escribiste una dirección, sé más específico.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const esEnlace = PROVEEDORES_ENLACE.has(resultado.proveedor);
|
||||
setEstado({ fase: "resuelto", resolucion: resultado, aviso: "" });
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
entradaUbicacion: entrada,
|
||||
proveedorUbicacion: resultado.proveedor,
|
||||
lat: resultado.lat,
|
||||
lng: resultado.lng,
|
||||
enlaceGoogleMaps: esEnlace ? entrada : "",
|
||||
direccion: resultado.direccion || "",
|
||||
nombre: resultado.nombre || "",
|
||||
provincia: resultado.provincia || "",
|
||||
}));
|
||||
|
||||
// Inferencia por geocodificación inversa solo si el resultado no trajo ya
|
||||
// nombre y provincia (una sola llamada a Nominatim por resolución).
|
||||
if (!resultado.nombre || !resultado.provincia) {
|
||||
const inferido = await inferirDesdeCoords(resultado.lat, resultado.lng);
|
||||
if (id !== idResolucionRef.current) return;
|
||||
if (inferido) {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
nombre: inferido.nombre || f.nombre,
|
||||
provincia: inferido.provincia || f.provincia,
|
||||
direccion: f.direccion || inferido.direccion || "",
|
||||
}));
|
||||
} else {
|
||||
setEstado((e) => ({
|
||||
...e,
|
||||
aviso: "No se pudieron inferir el nombre y la provincia automáticamente (posible fallo de red). Podrás rellenarlos a mano en los pasos siguientes.",
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCambio = (val) => {
|
||||
setTexto(val);
|
||||
clearTimeout(debounceRef.current);
|
||||
const id = ++idResolucionRef.current;
|
||||
const limpio = val.trim();
|
||||
// En cuanto cambia el texto, la resolución anterior deja de ser válida:
|
||||
// se limpia el chip y las coordenadas del form hasta la nueva resolución
|
||||
setEstado({ fase: "inactivo", resolucion: null, aviso: "" });
|
||||
setForm((f) => ({ ...f, entradaUbicacion: val, proveedorUbicacion: "", lat: null, lng: null }));
|
||||
if (!limpio) return;
|
||||
debounceRef.current = setTimeout(() => resolver(limpio, id), DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const { fase, resolucion, aviso } = estado;
|
||||
const etiqueta = resolucion ? ETIQUETAS_PROVEEDOR[resolucion.proveedor] || resolucion.proveedor : "";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="paso-ubicacion" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>
|
||||
Ubicación del local <span style={{ color: "#8B0000" }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="paso-ubicacion"
|
||||
style={inputStyle}
|
||||
autoComplete="off"
|
||||
placeholder="Enlace de Google Maps / Waze / OpenStreetMap, coordenadas o dirección"
|
||||
value={texto}
|
||||
onChange={(e) => handleCambio(e.target.value)}
|
||||
aria-describedby="paso-ubicacion-ayuda"
|
||||
/>
|
||||
<p id="paso-ubicacion-ayuda" style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: "6px 0 0" }}>
|
||||
Pega el enlace del local, escribe sus coordenadas (p. ej. 41.3851, 2.1734) o su dirección.
|
||||
</p>
|
||||
|
||||
{fase === "resolviendo" && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 10, fontSize: 12, color: "var(--color-text-secondary)" }}>
|
||||
<i className="ti ti-loader" aria-hidden="true" style={{ fontSize: 14 }}></i> Resolviendo ubicación...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fase === "resuelto" && resolucion && (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "flex-start", gap: 6, marginTop: 10,
|
||||
background: "#EAFAF1", color: "#1E8449",
|
||||
padding: "6px 10px", borderRadius: "var(--border-radius-md)", fontSize: 12,
|
||||
}}>
|
||||
<i className="ti ti-circle-check" aria-hidden="true" style={{ fontSize: 14, flexShrink: 0, marginTop: 1 }}></i>
|
||||
<span style={{ lineHeight: 1.4 }}>
|
||||
<strong>{etiqueta} detectado</strong>{" · "}{resolucion.lat.toFixed(4)}, {resolucion.lng.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fase === "noreconocido" && (
|
||||
<div role="alert" style={{
|
||||
display: "flex", alignItems: "flex-start", gap: 6, marginTop: 10,
|
||||
background: "#FDF2E9", color: "#784212",
|
||||
padding: "6px 10px", borderRadius: "var(--border-radius-md)", fontSize: 12,
|
||||
}}>
|
||||
<i className="ti ti-alert-triangle" aria-hidden="true" style={{ fontSize: 14, flexShrink: 0, marginTop: 1 }}></i>
|
||||
<span style={{ lineHeight: 1.4 }}>{aviso}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fase === "resuelto" && aviso && (
|
||||
<div role="status" style={{
|
||||
display: "flex", alignItems: "flex-start", gap: 6, marginTop: 6,
|
||||
background: "#FDF2E9", color: "#784212",
|
||||
padding: "6px 10px", borderRadius: "var(--border-radius-md)", fontSize: 12,
|
||||
}}>
|
||||
<i className="ti ti-wifi-off" aria-hidden="true" style={{ fontSize: 14, flexShrink: 0, marginTop: 1 }}></i>
|
||||
<span style={{ lineHeight: 1.4 }}>{aviso}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Detecta si el viewport es estrecho (móvil) consultando matchMedia, para
|
||||
// adaptar la presentación de la pasarela (panel a pantalla casi completa).
|
||||
export default function useEsMovil(query = "(max-width: 600px)") {
|
||||
const [esMovil, setEsMovil] = useState(
|
||||
() => typeof window !== "undefined" && window.matchMedia && window.matchMedia(query).matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.matchMedia) return;
|
||||
const mq = window.matchMedia(query);
|
||||
const escuchar = (e) => setEsMovil(e.matches);
|
||||
setEsMovil(mq.matches);
|
||||
if (mq.addEventListener) {
|
||||
mq.addEventListener("change", escuchar);
|
||||
return () => mq.removeEventListener("change", escuchar);
|
||||
}
|
||||
// Safari antiguo
|
||||
mq.addListener(escuchar);
|
||||
return () => mq.removeListener(escuchar);
|
||||
}, [query]);
|
||||
|
||||
return esMovil;
|
||||
}
|
||||
+4
-28
@@ -1,21 +1,7 @@
|
||||
// Utilidades de geolocalización: extraer coordenadas de un enlace de Google Maps
|
||||
// o geocodificar una dirección de texto mediante Nominatim (OpenStreetMap).
|
||||
|
||||
export function parsearEnlaceGoogleMaps(url) {
|
||||
try {
|
||||
let coincidencia = url.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (coincidencia) return { lat: parseFloat(coincidencia[1]), lng: parseFloat(coincidencia[2]) };
|
||||
|
||||
coincidencia = url.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/);
|
||||
if (coincidencia) return { lat: parseFloat(coincidencia[1]), lng: parseFloat(coincidencia[2]) };
|
||||
|
||||
coincidencia = url.match(/[?&]q=(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (coincidencia) return { lat: parseFloat(coincidencia[1]), lng: parseFloat(coincidencia[2]) };
|
||||
} catch {
|
||||
// URL malformada: se ignora y se devuelve null más abajo
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Utilidades de geolocalización usadas por las tarjetas y el mapa: enlaces
|
||||
// para abrir un local en Google Maps o Waze, distancias y búsqueda de lugares.
|
||||
// La extracción de coordenadas de enlaces y la geocodificación por texto viven
|
||||
// ahora en los proveedores de src/utils/ubicacion/.
|
||||
|
||||
// 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.
|
||||
@@ -47,16 +33,6 @@ export function formatearDistancia(km) {
|
||||
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`;
|
||||
const respuesta = await fetch(url, { headers: { "Accept-Language": "es", "User-Agent": "LocalesEspanoles/1.0" } });
|
||||
const datos = await respuesta.json();
|
||||
if (datos?.length) {
|
||||
return { lat: parseFloat(datos[0].lat), lng: parseFloat(datos[0].lon), displayName: datos[0].display_name };
|
||||
}
|
||||
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
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Clase base de los proveedores de ubicación: define el contrato común que
|
||||
// implementan todas las integraciones con backends de mapas (Google Maps,
|
||||
// Waze, OpenStreetMap, coordenadas sueltas y Nominatim como fallback de
|
||||
// texto). JS no tiene interfaces nativas, así que el contrato se documenta
|
||||
// aquí y se comprueba al registrar cada instancia (ver index.js).
|
||||
//
|
||||
// Contrato de cada proveedor:
|
||||
// - id: identificador estable del proveedor. Se copia al resultado para
|
||||
// que la UI pueda etiquetar el origen de la ubicación.
|
||||
// - detecta(entrada) → boolean: ¿esta entrada parece pertenecer a este
|
||||
// proveedor? Debe ser barato y síncrono (normalmente un regex).
|
||||
// - extrae(entrada) → Promise<Resultado|null>: extrae la información
|
||||
// disponible y devuelve { proveedor, lat, lng, ...extras } o null
|
||||
// si no se pudo extraer nada. Los errores de red se degradan a un
|
||||
// fallo controlado (null), nunca se lanzan al consumidor.
|
||||
//
|
||||
// El resultado es deliberadamente extensible: los consumidores leen solo los
|
||||
// campos que conocen (hoy lat/lng/nombre/provincia/direccion), de modo que
|
||||
// añadir campos nuevos en el futuro (teléfono, horarios…) no rompe nada.
|
||||
import { PROVINCIAS } from "../../constants/provincias.js";
|
||||
|
||||
export default class ProveedorUbicacion {
|
||||
// Identificador del proveedor; cada subclase debe sobrescribirlo.
|
||||
id = "proveedor";
|
||||
|
||||
// Nombres cooficiales (normalizados y sin artículo) → provincia canónica.
|
||||
// Nominatim puede devolver "Bizkaia", "Girona" o "Ourense" según el idioma
|
||||
// negociado; sin este mapa la provincia quedaría vacía innecesariamente.
|
||||
static ALIAS_PROVINCIAS = {
|
||||
bizkaia: "Vizcaya",
|
||||
gipuzkoa: "Guipúzcoa",
|
||||
araba: "Álava",
|
||||
girona: "Gerona",
|
||||
lleida: "Lérida",
|
||||
ourense: "Orense",
|
||||
coruna: "La Coruña",
|
||||
"illes balears": "Islas Baleares",
|
||||
asturies: "Asturias",
|
||||
};
|
||||
|
||||
// ¿Reconoce esta entrada? La base no reconoce nada.
|
||||
detecta(entrada) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extrae la ubicación de la entrada. Método "abstracto": la base lanza para
|
||||
// que una subclase registrada sin implementarlo falle rápido.
|
||||
async extrae(entrada) {
|
||||
throw new Error("sin implementar");
|
||||
}
|
||||
|
||||
// ── Helpers compartidos por todos los proveedores ────────────────────────
|
||||
|
||||
// Normaliza un texto: minúsculas, sin tildes y sin espacios extremos. Se usa
|
||||
// para comparar provincias y etiquetas sin depender de acentos.
|
||||
static normalizar(texto) {
|
||||
return (texto || "")
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
normalizar(texto) {
|
||||
return ProveedorUbicacion.normalizar(texto);
|
||||
}
|
||||
|
||||
// Empareja un texto libre contra el listado canónico de PROVINCIAS,
|
||||
// insensible a acentos y mayúsculas y tolerante con artículos ("Comunidad
|
||||
// de Madrid" → "Madrid", "A Coruña" → "La Coruña"). Devuelve "" si no casa
|
||||
// con ninguna, para que la UI fuerce la selección manual (mismo
|
||||
// comportamiento que tenía AutocompletarLocal).
|
||||
emparejarProvincia(texto) {
|
||||
const t = this.normalizar(texto);
|
||||
if (!t) return "";
|
||||
const sinArticulo = (s) => s.replace(/^(el|la|los|las|a|o)\s+/, "");
|
||||
const tSin = sinArticulo(t);
|
||||
const directa = PROVINCIAS.find((p) => {
|
||||
const np = ProveedorUbicacion.normalizar(p);
|
||||
const npSin = sinArticulo(np);
|
||||
return t === np || t.includes(np) || tSin === npSin || tSin.includes(npSin);
|
||||
});
|
||||
if (directa) return directa;
|
||||
// Alias de nombres cooficiales: Nominatim puede responder en euskera,
|
||||
// catalán o gallego según el idioma negociado (D12 del diseño).
|
||||
return ProveedorUbicacion.ALIAS_PROVINCIAS[tSin] || "";
|
||||
}
|
||||
|
||||
// fetch a Nominatim con las cabeceras adecuadas y manejo de errores: si la
|
||||
// red o el servicio fallan devuelve null (fallo controlado) en vez de
|
||||
// lanzar, para que la pasarela nunca se rompa por un error de proveedor.
|
||||
async fetchNominatim(url) {
|
||||
try {
|
||||
const respuesta = await fetch(url, {
|
||||
headers: { "Accept-Language": "es", "User-Agent": "LocalesEspanoles/1.0" },
|
||||
});
|
||||
if (!respuesta.ok) return null;
|
||||
return await respuesta.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Punto de entrada del sistema de proveedores de ubicación.
|
||||
//
|
||||
// Registro abierto/cerrado: añadir un backend nuevo consiste en crear una
|
||||
// clase que implemente el contrato de ProveedorUbicacion (id/detecta/extrae)
|
||||
// y sumar una instancia a PROVEEDORES. Ni resolverUbicacion, ni los demás
|
||||
// proveedores, ni la pasarela necesitan cambiar.
|
||||
import ProveedorCoordenadas from "./proveedorCoordenadas.js";
|
||||
import ProveedorGoogleMaps from "./proveedorGoogleMaps.js";
|
||||
import ProveedorWaze from "./proveedorWaze.js";
|
||||
import ProveedorOSM from "./proveedorOSM.js";
|
||||
import ProveedorNominatim from "./proveedorNominatim.js";
|
||||
|
||||
// Comprueba que una instancia cumple el contrato antes de registrarla:
|
||||
// fallo rápido si un proveedor nuevo olvida implementar la interfaz.
|
||||
function registrar(proveedor) {
|
||||
if (!proveedor?.id || typeof proveedor.detecta !== "function" || typeof proveedor.extrae !== "function") {
|
||||
throw new Error(`Proveedor inválido: falta id, detecta() o extrae() en ${proveedor?.constructor?.name || "proveedor"}`);
|
||||
}
|
||||
return proveedor;
|
||||
}
|
||||
|
||||
// Proveedores ordenados por prioridad: lo más específico primero y el texto
|
||||
// de dirección (Nominatim) siempre al final como fallback.
|
||||
export const PROVEEDORES = [
|
||||
registrar(new ProveedorCoordenadas()),
|
||||
registrar(new ProveedorGoogleMaps()),
|
||||
registrar(new ProveedorWaze()),
|
||||
registrar(new ProveedorOSM()),
|
||||
registrar(new ProveedorNominatim()),
|
||||
];
|
||||
|
||||
// Etiquetas legibles por id de proveedor, para los chips de la UI.
|
||||
export const ETIQUETAS_PROVEEDOR = {
|
||||
coordenadas: "Coordenadas",
|
||||
"google-maps": "Google Maps",
|
||||
waze: "Waze",
|
||||
osm: "OpenStreetMap",
|
||||
nominatim: "Dirección (OpenStreetMap)",
|
||||
};
|
||||
|
||||
// Resuelve una entrada libre usando el primer proveedor que la reconozca y
|
||||
// logre extraer datos. Devuelve { proveedor, lat, lng, ...extras } o null si
|
||||
// ningún proveedor la entiende. Los errores de cada proveedor se degradan
|
||||
// (se registran en consola y se continúa con el siguiente), nunca se lanzan
|
||||
// al consumidor.
|
||||
export async function resolverUbicacion(entrada) {
|
||||
const texto = (entrada || "").trim();
|
||||
if (!texto) return null;
|
||||
for (const proveedor of PROVEEDORES) {
|
||||
try {
|
||||
if (!proveedor.detecta(texto)) continue;
|
||||
const resultado = await proveedor.extrae(texto);
|
||||
if (resultado && Number.isFinite(resultado.lat) && Number.isFinite(resultado.lng)) {
|
||||
return resultado;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[ubicacion] el proveedor ${proveedor.id} falló:`, e);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Geocodificación inversa: de coordenadas a { nombre, provincia, direccion }
|
||||
// con la provincia normalizada al listado canónico. Devuelve null si falla;
|
||||
// la UI avisa y permite continuar sin inferencia.
|
||||
export async function inferirDesdeCoords(lat, lng) {
|
||||
const nominatim = PROVEEDORES.find((p) => p.id === "nominatim");
|
||||
if (!nominatim) return null;
|
||||
try {
|
||||
return await nominatim.reverse(lat, lng);
|
||||
} catch (e) {
|
||||
console.error("[ubicacion] la inferencia falló:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Proveedor de coordenadas geográficas sueltas: "41.3851, 2.1734".
|
||||
// Acepta signo, espacios alrededor del separador y coma o punto como
|
||||
// separador decimal (incluido el par con decimales españoles "41,38, 2,17").
|
||||
import ProveedorUbicacion from "./ProveedorUbicacion.js";
|
||||
|
||||
// Par separado por coma o punto y coma: "41.3851, 2.1734", "-3,70;40,41"
|
||||
const PATRON_PAR_COMA = /^\s*(-?\d{1,3}(?:[.,]\d+))\s*[,;]\s*(-?\d{1,3}(?:[.,]\d+))\s*$/;
|
||||
// Par separado por espacio con decimales obligatorios (evita falsos
|
||||
// positivos con texto): "41,3851 2,1734"
|
||||
const PATRON_PAR_ESPACIO = /^\s*(-?\d{1,3}[.,]\d{3,})\s+(-?\d{1,3}[.,]\d{3,})\s*$/;
|
||||
|
||||
const aNumero = (s) => parseFloat(String(s).replace(",", "."));
|
||||
|
||||
export default class ProveedorCoordenadas extends ProveedorUbicacion {
|
||||
id = "coordenadas";
|
||||
|
||||
detecta(entrada) {
|
||||
const t = entrada || "";
|
||||
return PATRON_PAR_COMA.test(t) || PATRON_PAR_ESPACIO.test(t);
|
||||
}
|
||||
|
||||
async extrae(entrada) {
|
||||
const texto = (entrada || "").trim();
|
||||
const par = texto.match(PATRON_PAR_COMA) || texto.match(PATRON_PAR_ESPACIO);
|
||||
if (!par) return null;
|
||||
const lat = aNumero(par[1]);
|
||||
const lng = aNumero(par[2]);
|
||||
// Sanidad: rangos válidos de coordenadas terrestres
|
||||
if (Math.abs(lat) > 90 || Math.abs(lng) > 180) return null;
|
||||
return { proveedor: this.id, lat, lng };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Proveedor de enlaces de Google Maps. Migra la lógica de
|
||||
// parsearEnlaceGoogleMaps (src/utils/geo.js) y reconoce los tres formatos con
|
||||
// coordenadas en la URL:
|
||||
// - @lat,lng → https://maps.google.com/@41.38,-2.17,15z
|
||||
// - !3d…!4d… → https://google.com/maps/place/…/!3d41.38!4d-2.17
|
||||
// - ?q=lat,lng → https://google.com/maps?q=41.38,-2.17
|
||||
// Los enlaces acortados (maps.app.goo.gl) se detectan pero sin coordenadas en
|
||||
// la URL devuelven null: la UI pide el enlace completo (unshorten pendiente).
|
||||
import ProveedorUbicacion from "./ProveedorUbicacion.js";
|
||||
|
||||
const PATRONES_COORDS = [
|
||||
/@(-?\d+\.\d+),(-?\d+\.\d+)/,
|
||||
/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/,
|
||||
/[?&]q=(-?\d+\.\d+),(-?\d+\.\d+)/,
|
||||
];
|
||||
|
||||
export default class ProveedorGoogleMaps extends ProveedorUbicacion {
|
||||
id = "google-maps";
|
||||
|
||||
detecta(entrada) {
|
||||
return /maps\.google\.|google\.[a-z.]+\/maps|maps\.app\.goo\.gl|goo\.gl\/maps/i.test(entrada || "");
|
||||
}
|
||||
|
||||
async extrae(entrada) {
|
||||
const texto = entrada || "";
|
||||
for (const patron of PATRONES_COORDS) {
|
||||
const m = texto.match(patron);
|
||||
if (m) {
|
||||
const lat = parseFloat(m[1]);
|
||||
const lng = parseFloat(m[2]);
|
||||
if (Math.abs(lat) <= 90 && Math.abs(lng) <= 180) {
|
||||
return { proveedor: this.id, lat, lng };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Proveedor de geocodificación por texto (Nominatim / OpenStreetMap). Actúa
|
||||
// como fallback de último recurso: cuando la entrada no es un enlace
|
||||
// reconocible ni un par de coordenadas, se trata como dirección y se busca
|
||||
// su ubicación (/search, migra geocodificarDireccion de src/utils/geo.js).
|
||||
// Además expone reverse(lat, lng) (/reverse) para la geocodificación
|
||||
// inversa con la que la pasarela infiere nombre, provincia y dirección.
|
||||
import ProveedorUbicacion from "./ProveedorUbicacion.js";
|
||||
|
||||
const BASE = "https://nominatim.openstreetmap.org";
|
||||
|
||||
export default class ProveedorNominatim extends ProveedorUbicacion {
|
||||
id = "nominatim";
|
||||
|
||||
// Fallback de texto: acepta cualquier entrada con letras que no parezca una
|
||||
// URL (un enlace que ningún proveedor supo extraer no debe buscarse como
|
||||
// si fuera una dirección).
|
||||
detecta(entrada) {
|
||||
const t = (entrada || "").trim();
|
||||
if (t.length < 3) return false;
|
||||
if (/https?:\/\/|\bwww\.|\b[a-z0-9-]+\.(com|es|org|net|gl|io|app|edu|gob)\b/i.test(t)) return false;
|
||||
return /[a-záéíóúüñ]/i.test(t);
|
||||
}
|
||||
|
||||
// Búsqueda por texto (dirección o nombre de lugar). Devuelve el resultado
|
||||
// con coordenadas y, si Nominatim las facilita, nombre/provincia/dirección.
|
||||
async extrae(entrada) {
|
||||
const q = (entrada || "").trim();
|
||||
if (!q) return null;
|
||||
const params = new URLSearchParams({
|
||||
q: `${q}, España`,
|
||||
format: "json",
|
||||
addressdetails: "1",
|
||||
namedetails: "1",
|
||||
limit: "1",
|
||||
countrycodes: "es",
|
||||
});
|
||||
const datos = await this.fetchNominatim(`${BASE}/search?${params}`);
|
||||
if (!datos?.length) return null;
|
||||
return this.desdeResultado(datos[0]);
|
||||
}
|
||||
|
||||
// Geocodificación inversa: de coordenadas a { nombre, provincia, direccion }.
|
||||
// Devuelve null si el servicio no responde (la UI avisa y sigue sin
|
||||
// inferencia, sin bloquear la pasarela).
|
||||
async reverse(lat, lng) {
|
||||
const params = new URLSearchParams({
|
||||
lat: String(lat),
|
||||
lon: String(lng),
|
||||
format: "jsonv2",
|
||||
addressdetails: "1",
|
||||
namedetails: "1",
|
||||
"accept-language": "es",
|
||||
});
|
||||
const dato = await this.fetchNominatim(`${BASE}/reverse?${params}`);
|
||||
if (!dato || dato.error) return null;
|
||||
const r = this.desdeResultado(dato);
|
||||
return { nombre: r.nombre, provincia: r.provincia, direccion: r.direccion };
|
||||
}
|
||||
|
||||
// Convierte una respuesta de Nominatim (search o reverse) en el resultado
|
||||
// extensible del proveedor. La provincia se normaliza al listado canónico
|
||||
// de PROVINCIAS: si no casa con ninguna, queda vacía para que el usuario la
|
||||
// seleccione manualmente.
|
||||
desdeResultado(d) {
|
||||
const addr = d.address || {};
|
||||
const candidatos = [addr.county, addr.province, addr.state_district, addr.state, addr.city, addr.municipality, addr.city_district].filter(Boolean);
|
||||
let provincia = "";
|
||||
for (const candidato of candidatos) {
|
||||
provincia = this.emparejarProvincia(candidato);
|
||||
if (provincia) break;
|
||||
}
|
||||
return {
|
||||
proveedor: this.id,
|
||||
lat: parseFloat(d.lat),
|
||||
lng: parseFloat(d.lon),
|
||||
nombre: d.namedetails?.name || "",
|
||||
provincia,
|
||||
direccion: d.display_name || "",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Proveedor de enlaces de openstreetmap.org. Extrae las coordenadas de los
|
||||
// parámetros mlat/mlon (enlace de marcador) o del fragmento
|
||||
// #map=zoom/lat/lng (posición actual del visor), p. ej.:
|
||||
// https://www.openstreetmap.org/?mlat=41.38&mlon=2.17#map=15/41.38/2.17
|
||||
import ProveedorUbicacion from "./ProveedorUbicacion.js";
|
||||
|
||||
const PATRON_MAPA = /#map=\d+(?:\.\d+)?\/(-?\d{1,3}\.\d+)\/(-?\d{1,3}\.\d+)/;
|
||||
|
||||
function comoUrl(entrada) {
|
||||
const texto = (entrada || "").trim();
|
||||
if (!texto) return null;
|
||||
try {
|
||||
return new URL(/:\/\//i.test(texto) ? texto : `https://${texto}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default class ProveedorOSM extends ProveedorUbicacion {
|
||||
id = "osm";
|
||||
|
||||
detecta(entrada) {
|
||||
return /openstreetmap\.org/i.test(entrada || "");
|
||||
}
|
||||
|
||||
async extrae(entrada) {
|
||||
const url = comoUrl(entrada);
|
||||
if (!url) return null;
|
||||
|
||||
const mlat = parseFloat(url.searchParams.get("mlat"));
|
||||
const mlon = parseFloat(url.searchParams.get("mlon"));
|
||||
if (Number.isFinite(mlat) && Number.isFinite(mlon)) {
|
||||
return { proveedor: this.id, lat: mlat, lng: mlon };
|
||||
}
|
||||
|
||||
const mapa = (url.hash || "").match(PATRON_MAPA);
|
||||
if (mapa) {
|
||||
return { proveedor: this.id, lat: parseFloat(mapa[1]), lng: parseFloat(mapa[2]) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Proveedor de enlaces de Waze. Extrae las coordenadas del parámetro ll=
|
||||
// (https://waze.com/ul?ll=41.38,2.17&navigate=yes). Si el enlace solo trae
|
||||
// una búsqueda de texto (q=...), delega la resolución en la búsqueda por
|
||||
// texto de Nominatim marcando el resultado como proveedor "waze".
|
||||
import ProveedorUbicacion from "./ProveedorUbicacion.js";
|
||||
import ProveedorNominatim from "./proveedorNominatim.js";
|
||||
|
||||
const PATRON_LL = /^(-?\d{1,3}(?:\.\d+)?),\s*(-?\d{1,3}(?:\.\d+)?)$/;
|
||||
|
||||
function comoUrl(entrada) {
|
||||
const texto = (entrada || "").trim();
|
||||
if (!texto) return null;
|
||||
try {
|
||||
return new URL(/:\/\//i.test(texto) ? texto : `https://${texto}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default class ProveedorWaze extends ProveedorUbicacion {
|
||||
id = "waze";
|
||||
|
||||
// Instancia propia del resolvedor de texto (Waze no lo referencia desde el
|
||||
// registro para evitar acoplamientos con index.js).
|
||||
#nominatim = new ProveedorNominatim();
|
||||
|
||||
detecta(entrada) {
|
||||
return /waze\.(com|to)/i.test(entrada || "");
|
||||
}
|
||||
|
||||
async extrae(entrada) {
|
||||
const url = comoUrl(entrada);
|
||||
if (!url) return null;
|
||||
|
||||
const ll = url.searchParams.get("ll");
|
||||
if (ll) {
|
||||
const m = ll.match(PATRON_LL);
|
||||
if (m) {
|
||||
const lat = parseFloat(m[1]);
|
||||
const lng = parseFloat(m[2]);
|
||||
if (Math.abs(lat) <= 90 && Math.abs(lng) <= 180) {
|
||||
return { proveedor: this.id, lat, lng };
|
||||
}
|
||||
}
|
||||
return null; // ll= presente pero malformado
|
||||
}
|
||||
|
||||
// Solo búsqueda de texto: delegar en Nominatim manteniendo el proveedor
|
||||
const q = url.searchParams.get("q");
|
||||
if (q) {
|
||||
const resultado = await this.#nominatim.extrae(q);
|
||||
if (resultado) return { ...resultado, proveedor: this.id };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user