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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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