- 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
161 lines
6.8 KiB
React
161 lines
6.8 KiB
React
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>
|
|
);
|
|
}
|