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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user