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