feat(propuesta): pasarela paso a paso con autodetección de ubicación y FAB
deploy-branch / deploy (push) Failing after 6s
deploy-branch / teardown (push) Skipped

- 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:
2026-08-16 01:35:02 +02:00
parent d48b74cbd4
commit 003b1e209f
53 changed files with 4896 additions and 253 deletions
+160
View File
@@ -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>
);
}