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
+46 -6
View File
@@ -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>
);
}
+60
View File
@@ -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>
);
}
-95
View File
@@ -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>
);
}
+169
View File
@@ -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>
);
}
+25
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+119
View File
@@ -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>
);
}
+29
View File
@@ -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>
);
}
+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>
);
}