refactor(ui): modulariza App.jsx y unifica branding y caché PWA
- Extrae App.jsx en módulos reutilizables: constants/ (categorias, provincias), utils/geo, styles/shared, hooks/usePwaInstall y components/ (StarRating, LocalCard, CampoUbicacion, BannerMovil, CategoriaBadge) - AdminPanel reutiliza constantes y componentes compartidos y corrige la limpieza de suscripciones de Firebase al desmontar - Añade logos de marca (logo-localesp.png + @2x) y regenera los iconos PWA con tamaños menores - sw.js: sube la caché del shell a v2 e incluye los nuevos iconos y logos
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import { useState } from "react";
|
||||
import usePwaInstall from "../hooks/usePwaInstall.js";
|
||||
|
||||
// Banner 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
|
||||
// se oculta porque ya no aporta nada mostrarlo.
|
||||
export default function BannerMovil() {
|
||||
const [cerrado, setCerrado] = useState(false);
|
||||
const { isInstalled, canPromptInstall, promptInstall, isIos } = usePwaInstall();
|
||||
const [instalando, setInstalando] = useState(false);
|
||||
const [resultado, setResultado] = useState(null); // "accepted" | "dismissed" | null
|
||||
|
||||
if (cerrado || isInstalled) return null;
|
||||
|
||||
const handleInstalar = async () => {
|
||||
setInstalando(true);
|
||||
const outcome = await promptInstall();
|
||||
setResultado(outcome);
|
||||
setInstalando(false);
|
||||
};
|
||||
|
||||
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={() => setCerrado(true)} 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 }}>
|
||||
<img src="/icon-192.png" alt="" width={30} height={30} style={{ borderRadius: 7, flexShrink: 0 }} />
|
||||
<div>
|
||||
<p style={{ margin: 0, fontWeight: 700, fontSize: 14 }}>App para móvil</p>
|
||||
<p style={{ margin: 0, fontSize: 12, color: "#888" }}>Sincronizada en tiempo real</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canPromptInstall && (
|
||||
<button onClick={handleInstalar} disabled={instalando}
|
||||
style={{ display: "block", width: "100%", textAlign: "center", padding: "9px 0", background: "#8B0000", color: "white", border: "none", borderRadius: 8, fontSize: 13, fontWeight: 700, cursor: instalando ? "default" : "pointer", opacity: instalando ? 0.7 : 1 }}>
|
||||
{instalando ? "Abriendo instalación..." : "📲 Instalar app"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!canPromptInstall && isIos && (
|
||||
<p style={{ margin: 0, fontSize: 12, color: "var(--color-text-secondary, #666)", lineHeight: 1.5 }}>
|
||||
Pulsa <i className="ti ti-share-2" aria-hidden="true"></i> <strong>Compartir</strong> y luego <strong>"Añadir a pantalla de inicio"</strong> para instalarla.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!canPromptInstall && !isIos && (
|
||||
<p style={{ margin: 0, fontSize: 12, color: "var(--color-text-secondary, #666)", lineHeight: 1.5 }}>
|
||||
Instálala desde el menú de tu navegador ("Instalar app" o "Añadir a pantalla de inicio").
|
||||
</p>
|
||||
)}
|
||||
|
||||
{resultado === "dismissed" && (
|
||||
<p style={{ margin: "8px 0 0", fontSize: 11, color: "#888" }}>Puedes instalarla más tarde cuando quieras.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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,19 @@
|
||||
import { obtenerCategoria } from "../constants/categorias.js";
|
||||
|
||||
export default function CategoriaBadge({ categoria, subcategoria }) {
|
||||
if (!categoria) return null;
|
||||
const cat = obtenerCategoria(categoria);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>
|
||||
<span style={{ background: cat.bgLight, color: cat.textColor, fontSize: 12, padding: "3px 10px", borderRadius: "var(--border-radius-md)", fontWeight: 500 }}>
|
||||
{cat.emoji} {categoria}
|
||||
</span>
|
||||
{subcategoria && (
|
||||
<span style={{ background: "var(--color-background-secondary)", color: "var(--color-text-secondary)", fontSize: 12, padding: "3px 10px", borderRadius: "var(--border-radius-md)" }}>
|
||||
{subcategoria}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import CategoriaBadge from "./CategoriaBadge.jsx";
|
||||
import StarRating from "./StarRating.jsx";
|
||||
|
||||
function formatearFecha(fecha) {
|
||||
if (!fecha) return null;
|
||||
const date = new Date(fecha);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date.toLocaleDateString("es-ES", { day: "numeric", month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
export default function LocalCard({ local }) {
|
||||
const gmUrl = local.enlaceGoogleMaps || (local.direccion
|
||||
? `https://www.google.com/maps/search/${encodeURIComponent(`${local.nombre} ${local.direccion}`)}`
|
||||
: null);
|
||||
const fechaFormateada = formatearFecha(local.fecha);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ background: "var(--color-background-primary)", border: "0.5px solid var(--color-border-tertiary)", borderRadius: "var(--border-radius-lg)", padding: "1rem 1.25rem", display: "flex", flexDirection: "column", gap: 8, transition: "border-color 0.2s" }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = "var(--color-border-secondary)"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--color-border-tertiary)"; }}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontWeight: 500, fontSize: 16, margin: 0 }}>{local.nombre}</p>
|
||||
{local.provincia && <p style={{ fontSize: 13, color: "var(--color-text-secondary)", margin: "2px 0 0" }}>{local.provincia}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CategoriaBadge categoria={local.categoria} subcategoria={local.subcategoria} />
|
||||
|
||||
{local.direccion && (
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 6 }}>
|
||||
<i className="ti ti-map-pin" aria-hidden="true" style={{ fontSize: 14, color: "var(--color-text-tertiary)", marginTop: 1, flexShrink: 0 }}></i>
|
||||
<span style={{ fontSize: 13, color: "var(--color-text-secondary)" }}>{local.direccion}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gmUrl && (
|
||||
<a href={gmUrl} target="_blank" rel="noopener noreferrer"
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 5, fontSize: 12, color: "#1A73E8", textDecoration: "none", width: "fit-content", background: "#E8F0FE", padding: "4px 10px", borderRadius: "var(--border-radius-md)", fontWeight: 500 }}>
|
||||
<i className="ti ti-brand-google-maps" aria-hidden="true" style={{ fontSize: 14 }}></i>
|
||||
Ver en Google Maps
|
||||
</a>
|
||||
)}
|
||||
|
||||
<StarRating value={local.puntuacion} readOnly />
|
||||
|
||||
{local.descripcion && (
|
||||
<p style={{ fontSize: 13, color: "var(--color-text-secondary)", margin: 0, fontStyle: "italic" }}>"{local.descripcion}"</p>
|
||||
)}
|
||||
|
||||
{fechaFormateada && (
|
||||
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: 0 }}>Publicado {fechaFormateada}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from "react";
|
||||
|
||||
const ESTRELLAS = [1, 2, 3, 4, 5];
|
||||
|
||||
/**
|
||||
* Muestra siempre 5 estrellas (usando el font icon de Tabler ya cargado en
|
||||
* index.html, para no depender de una fuente/librería extra). Si no hay
|
||||
* valoración (0, null, undefined o un valor no numérico) se muestran las
|
||||
* 5 estrellas vacías en lugar de no renderizar nada.
|
||||
*
|
||||
* - Modo lectura (readOnly, por defecto): <div role="img"> con aria-label
|
||||
* describiendo la puntuación, para lectores de pantalla.
|
||||
* - Modo edición (onChange): botones reales con role="radio" para que sea
|
||||
* utilizable con teclado y tenga foco visible.
|
||||
*/
|
||||
export default function StarRating({ value = 0, onChange, readOnly = false, size = 20 }) {
|
||||
const [valorHover, setValorHover] = useState(0);
|
||||
const esInteractivo = !readOnly && typeof onChange === "function";
|
||||
|
||||
const valorSeguro = Math.min(5, Math.max(0, Math.round(Number(value)) || 0));
|
||||
const valorMostrado = esInteractivo && valorHover ? valorHover : valorSeguro;
|
||||
|
||||
const contenedorProps = esInteractivo
|
||||
? { role: "radiogroup", "aria-label": "Selecciona una puntuación de 1 a 5 estrellas" }
|
||||
: { role: "img", "aria-label": `Valoración: ${valorSeguro} de 5 estrellas` };
|
||||
|
||||
return (
|
||||
<div
|
||||
{...contenedorProps}
|
||||
style={{ display: "inline-flex", gap: 2 }}
|
||||
onMouseLeave={() => esInteractivo && setValorHover(0)}
|
||||
>
|
||||
{ESTRELLAS.map((estrella) => {
|
||||
const rellena = estrella <= valorMostrado;
|
||||
const iconStyle = {
|
||||
fontSize: size,
|
||||
color: rellena ? "#D4AF37" : "var(--color-border-secondary)",
|
||||
transition: "color 0.15s",
|
||||
lineHeight: 0,
|
||||
};
|
||||
|
||||
if (!esInteractivo) {
|
||||
return (
|
||||
<i key={estrella} className={`ti ${rellena ? "ti-star-filled" : "ti-star"}`} style={iconStyle} aria-hidden="true" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={estrella}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={valorSeguro === estrella}
|
||||
aria-label={`${estrella} estrella${estrella === 1 ? "" : "s"}`}
|
||||
onClick={() => onChange(estrella)}
|
||||
onMouseEnter={() => setValorHover(estrella)}
|
||||
onFocus={() => setValorHover(estrella)}
|
||||
onBlur={() => setValorHover(0)}
|
||||
style={{ background: "none", border: "none", padding: 2, margin: 0, cursor: "pointer", lineHeight: 0 }}
|
||||
>
|
||||
<i className={`ti ${rellena ? "ti-star-filled" : "ti-star"}`} style={iconStyle} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user