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
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 204 KiB After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 50 KiB |
@@ -1,5 +1,15 @@
|
||||
const CACHE_NAME = "localesp-shell-v1";
|
||||
const SHELL_ASSETS = ["/", "/manifest.webmanifest", "/icon-192.png", "/icon-512.png"];
|
||||
const CACHE_NAME = "localesp-shell-v2";
|
||||
const SHELL_ASSETS = [
|
||||
"/",
|
||||
"/manifest.webmanifest",
|
||||
"/icon-192.png",
|
||||
"/icon-512.png",
|
||||
"/icon-512-maskable.png",
|
||||
"/apple-touch-icon.png",
|
||||
"/favicon-32.png",
|
||||
"/logo-localesp.png",
|
||||
"/logo-localesp@2x.png",
|
||||
];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
|
||||
@@ -7,14 +7,8 @@ import {
|
||||
suscribirLocales, deleteLocal,
|
||||
obtenerPalabrasFiltradas, guardarPalabrasFiltradas,
|
||||
} from "./firebase.js";
|
||||
|
||||
const CATEGORIAS = {
|
||||
"Restauración": { emoji:"🍽️", color:"#8B0000", bg:"#FFF0F0" },
|
||||
"Pequeño comercio": { emoji:"🛍️", color:"#1A5276", bg:"#EBF5FB" },
|
||||
"Peluquería y estética": { emoji:"✂️", color:"#76448A", bg:"#F5EEF8" },
|
||||
"Servicios del hogar": { emoji:"🔧", color:"#1E8449", bg:"#EAFAF1" },
|
||||
"Otros": { emoji:"📌", color:"#784212", bg:"#FDF2E9" },
|
||||
};
|
||||
import { obtenerCategoria } from "./constants/categorias.js";
|
||||
import StarRating from "./components/StarRating.jsx";
|
||||
|
||||
const S = {
|
||||
page: { minHeight:"100vh", background:"#F2EDE8", fontFamily:"-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" },
|
||||
@@ -85,21 +79,21 @@ function PantallaAuth() {
|
||||
|
||||
// ── Tarjeta propuesta pendiente ───────────────────────────────────────────────
|
||||
function TarjetaPropuesta({ p, onAprobar, onRechazar, busy }) {
|
||||
const cat = CATEGORIAS[p.categoria] || {};
|
||||
const cat = obtenerCategoria(p.categoria);
|
||||
const fecha = new Date(p.fechaPropuesta).toLocaleDateString("es-ES", { day:"numeric", month:"short", hour:"2-digit", minute:"2-digit" });
|
||||
return (
|
||||
<div style={{ ...S.card, borderLeft:`4px solid ${cat.color||"#CCC"}`, marginBottom:10 }}>
|
||||
<div style={{ ...S.card, borderLeft:`4px solid ${cat.color}`, marginBottom:10 }}>
|
||||
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"flex-start", flexWrap:"wrap", gap:12 }}>
|
||||
<div style={{ flex:1 }}>
|
||||
<div style={{ display:"flex", alignItems:"center", gap:8, flexWrap:"wrap", marginBottom:6 }}>
|
||||
<span style={{ fontWeight:700, fontSize:17 }}>{p.nombre}</span>
|
||||
{cat.emoji && <span style={S.badge(cat.color, cat.bg)}>{cat.emoji} {p.categoria}</span>}
|
||||
{p.categoria && <span style={S.badge(cat.textColor, cat.bgLight)}>{cat.emoji} {p.categoria}</span>}
|
||||
{p.subcategoria && <span style={{ fontSize:12, color:"#888", background:"#F5F5F5", padding:"2px 8px", borderRadius:20 }}>{p.subcategoria}</span>}
|
||||
</div>
|
||||
<div style={{ display:"flex", flexWrap:"wrap", gap:16, fontSize:13, color:"#666" }}>
|
||||
<div style={{ display:"flex", flexWrap:"wrap", alignItems:"center", gap:16, fontSize:13, color:"#666" }}>
|
||||
<span>📍 {p.provincia}</span>
|
||||
{p.direccion && <span>🗺 {p.direccion}</span>}
|
||||
<span style={{ color:"#D4AF37" }}>{"★".repeat(p.puntuacion||0)}{"☆".repeat(5-(p.puntuacion||0))}</span>
|
||||
<StarRating value={p.puntuacion} readOnly size={14} />
|
||||
<span style={{ color:"#AAA" }}>Enviado: {fecha}</span>
|
||||
</div>
|
||||
{p.descripcion && <p style={{ margin:"8px 0 0", fontSize:13, color:"#666", fontStyle:"italic", background:"#FAFAFA", padding:"8px 12px", borderRadius:8 }}>"{p.descripcion}"</p>}
|
||||
@@ -128,10 +122,14 @@ function PanelAdmin({ email }) {
|
||||
|
||||
useEffect(() => {
|
||||
setCargando(true);
|
||||
const u1 = suscribirPendientes(l => { setPendientes(l); setCargando(false); }, () => setCargando(false));
|
||||
const u2 = suscribirLocales(l => setPublicados(l), () => {});
|
||||
obtenerPalabrasFiltradas().then(p => setPalabras(p));
|
||||
return () => { u1(); u2(); };
|
||||
let cancelado = false;
|
||||
let limpiarPendientes, limpiarPublicados;
|
||||
suscribirPendientes(l => { if (!cancelado) { setPendientes(l); setCargando(false); } }, () => !cancelado && setCargando(false))
|
||||
.then(fn => { if (cancelado) fn?.(); else limpiarPendientes = fn; });
|
||||
suscribirLocales(l => !cancelado && setPublicados(l), () => {})
|
||||
.then(fn => { if (cancelado) fn?.(); else limpiarPublicados = fn; });
|
||||
obtenerPalabrasFiltradas().then(p => !cancelado && setPalabras(p));
|
||||
return () => { cancelado = true; limpiarPendientes?.(); limpiarPublicados?.(); };
|
||||
}, []);
|
||||
|
||||
const aprobar = async (p) => { setBusy(true); try { await aprobarPropuesta(p); } catch(e) { alert("Error: "+e.message); } setBusy(false); };
|
||||
@@ -214,7 +212,7 @@ function PanelAdmin({ email }) {
|
||||
<div style={{ flex:1 }}>
|
||||
<div style={{ display:"flex", alignItems:"center", gap:8, flexWrap:"wrap" }}>
|
||||
<span style={{ fontWeight:600, fontSize:15 }}>{l.nombre}</span>
|
||||
{CATEGORIAS[l.categoria] && <span style={S.badge(CATEGORIAS[l.categoria].color, CATEGORIAS[l.categoria].bg)}>{CATEGORIAS[l.categoria].emoji} {l.categoria}</span>}
|
||||
{l.categoria && <span style={S.badge(obtenerCategoria(l.categoria).textColor, obtenerCategoria(l.categoria).bgLight)}>{obtenerCategoria(l.categoria).emoji} {l.categoria}</span>}
|
||||
<span style={{ fontSize:12, color:"#AAA" }}>📍 {l.provincia} · {new Date(l.fecha).toLocaleDateString("es-ES",{day:"numeric",month:"short",year:"numeric"})}</span>
|
||||
</div>
|
||||
{l.direccion && <p style={{ margin:"3px 0 0", fontSize:13, color:"#888" }}>{l.direccion}</p>}
|
||||
|
||||
@@ -1,266 +1,18 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { suscribirLocales, enviarPropuesta, obtenerPalabrasFiltradas, contienepalabrasProhibidas } from "./firebase.js";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { contienepalabrasProhibidas, enviarPropuesta, obtenerPalabrasFiltradas, suscribirLocales } from "./firebase.js";
|
||||
import { CATEGORIAS, COLORES_MAPA, NOMBRES_CATEGORIAS, obtenerCategoria } from "./constants/categorias.js";
|
||||
import { COORDS_PROVINCIAS, PROVINCIAS, CENTRO_ESPANA } from "./constants/provincias.js";
|
||||
import { inputStyle } from "./styles/shared.js";
|
||||
import StarRating from "./components/StarRating.jsx";
|
||||
import LocalCard from "./components/LocalCard.jsx";
|
||||
import CampoUbicacion from "./components/CampoUbicacion.jsx";
|
||||
import BannerMovil from "./components/BannerMovil.jsx";
|
||||
|
||||
const CATEGORIAS = {
|
||||
"Restauración": {
|
||||
emoji: "🍽️", color: "#8B0000", bgLight: "#FFF0F0", textColor: "#8B0000",
|
||||
subcategorias: ["Tapas y raciones","Paella y arroces","Pintxos","Asador / Carne a la brasa","Mariscos y pescados","Bocadillos y montaditos","Menú del día","Cocina vasca","Cocina catalana","Cocina andaluza","Cocina gallega","Cocina madrileña","Cocina mediterránea","Pizzería","Hamburguesería","Comida rápida","Cocina internacional","Cafetería / Desayunos","Heladería","Pastelería"]
|
||||
},
|
||||
"Pequeño comercio": {
|
||||
emoji: "🛍️", color: "#1A5276", bgLight: "#EBF5FB", textColor: "#1A5276",
|
||||
subcategorias: ["Alimentación / Ultramarinos","Frutería","Carnicería","Pescadería","Panadería","Farmacia","Papelería / Librería","Floristería","Joyería / Relojería","Zapatería","Ropa y moda","Juguetería","Ferretería","Bazar / Todo a 100","Estanco","Quiosco","Óptica","Ortopedia","Tienda de mascotas","Electrodomésticos"]
|
||||
},
|
||||
"Peluquería y estética": {
|
||||
emoji: "✂️", color: "#76448A", bgLight: "#F5EEF8", textColor: "#76448A",
|
||||
subcategorias: ["Peluquería señora","Peluquería caballero","Peluquería unisex","Barbería","Centro de estética","Uñas / Manicura","Depilación","Masajes y spa","Tatuajes y piercings","Centro de bronceado","Micropigmentación"]
|
||||
},
|
||||
"Servicios del hogar": {
|
||||
emoji: "🔧", color: "#1E8449", bgLight: "#EAFAF1", textColor: "#1E8449",
|
||||
subcategorias: ["Cerrajería","Fontanería / Plomería","Electricidad","Reformas y construcción","Pintura","Carpintería","Cristalería","Climatización / Aire acondicionado","Mudanzas","Limpieza","Jardinería","Instalación solar","Alarmas y seguridad","Reparación electrodomésticos"]
|
||||
},
|
||||
"Otros": {
|
||||
emoji: "📌", color: "#784212", bgLight: "#FDF2E9", textColor: "#784212",
|
||||
subcategorias: ["Taller mecánico","Lavado de coches","Academia / Clases","Gestoría / Asesoría","Inmobiliaria","Agencia de viajes","Fotografía","Informática / Reparación móviles","Copistería / Imprenta","Veterinaria","Gimnasio / Fitness","Centro médico / Clínica","Fisioterapia","Psicología","Lavandería","Tintorería","Otro"]
|
||||
}
|
||||
const FORM_VACIO = {
|
||||
nombre: "", provincia: "", categoria: "", subcategoria: "",
|
||||
direccion: "", enlaceGoogleMaps: "", lat: null, lng: null,
|
||||
puntuacion: 0, descripcion: "",
|
||||
};
|
||||
const NOMBRES_CATEGORIAS = Object.keys(CATEGORIAS);
|
||||
|
||||
const PROVINCIAS = [
|
||||
"Álava","Albacete","Alicante","Almería","Asturias","Ávila","Badajoz","Barcelona","Burgos","Cáceres",
|
||||
"Cádiz","Cantabria","Castellón","Ciudad Real","Córdoba","Cuenca","Gerona","Granada","Guadalajara","Guipúzcoa",
|
||||
"Huelva","Huesca","Islas Baleares","Jaén","La Coruña","La Rioja","Las Palmas","León","Lérida","Lugo",
|
||||
"Madrid","Málaga","Murcia","Navarra","Orense","Palencia","Pontevedra","Salamanca","Santa Cruz de Tenerife","Segovia",
|
||||
"Sevilla","Soria","Tarragona","Teruel","Toledo","Valencia","Valladolid","Vizcaya","Zamora","Zaragoza"
|
||||
];
|
||||
|
||||
const COORDS_PROVINCIAS = {
|
||||
"Álava":[42.8467,-2.6726],"Albacete":[38.9942,-1.8585],"Alicante":[38.3452,-0.4815],
|
||||
"Almería":[36.834,-2.4637],"Asturias":[43.3614,-5.8593],"Ávila":[40.6566,-4.6814],
|
||||
"Badajoz":[38.8794,-6.9707],"Barcelona":[41.3851,2.1734],"Burgos":[42.344,-3.6969],
|
||||
"Cáceres":[39.4753,-6.3724],"Cádiz":[36.5271,-6.2886],"Cantabria":[43.4623,-3.8099],
|
||||
"Castellón":[39.9864,-0.0513],"Ciudad Real":[38.9848,-3.9274],"Córdoba":[37.8882,-4.7794],
|
||||
"Cuenca":[40.0704,-2.1374],"Gerona":[41.9794,2.8214],"Granada":[37.1773,-3.5986],
|
||||
"Guadalajara":[40.6328,-3.1614],"Guipúzcoa":[43.3183,-1.9812],"Huelva":[37.2614,-6.9447],
|
||||
"Huesca":[42.1401,-0.4089],"Islas Baleares":[39.5696,2.6502],"Jaén":[37.7796,-3.7849],
|
||||
"La Coruña":[43.3623,-8.4115],"La Rioja":[42.4627,-2.4449],"Las Palmas":[28.1235,-15.4366],
|
||||
"León":[42.5987,-5.5671],"Lérida":[41.6178,0.62],"Lugo":[43.0097,-7.5567],
|
||||
"Madrid":[40.4168,-3.7038],"Málaga":[36.7213,-4.4214],"Murcia":[37.9922,-1.1307],
|
||||
"Navarra":[42.8169,-1.6432],"Orense":[42.3362,-7.8639],"Palencia":[42.0097,-4.5288],
|
||||
"Pontevedra":[42.4333,-8.65],"Salamanca":[40.9701,-5.6635],"Santa Cruz de Tenerife":[28.4636,-16.2518],
|
||||
"Segovia":[40.9429,-4.1088],"Sevilla":[37.3891,-5.9845],"Soria":[41.764,-2.464],
|
||||
"Tarragona":[41.1189,1.2445],"Teruel":[40.344,-1.1065],"Toledo":[39.8628,-4.0273],
|
||||
"Valencia":[39.4699,-0.3763],"Valladolid":[41.6523,-4.7245],"Vizcaya":[43.263,-2.935],
|
||||
"Zamora":[41.5034,-5.7446],"Zaragoza":[41.6488,-0.8891],
|
||||
};
|
||||
|
||||
const COLORES_MAPA = {
|
||||
"Restauración":"#8B0000","Pequeño comercio":"#1A5276",
|
||||
"Peluquería y estética":"#76448A","Servicios del hogar":"#1E8449","Otros":"#784212",
|
||||
};
|
||||
|
||||
function parsearEnlaceGoogleMaps(url) {
|
||||
try {
|
||||
let m = url.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (m) return { lat: parseFloat(m[1]), lng: parseFloat(m[2]) };
|
||||
m = url.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/);
|
||||
if (m) return { lat: parseFloat(m[1]), lng: parseFloat(m[2]) };
|
||||
m = url.match(/[?&]q=(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (m) return { lat: parseFloat(m[1]), lng: parseFloat(m[2]) };
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function geocodificarDireccion(direccion) {
|
||||
const query = encodeURIComponent(direccion + ", España");
|
||||
const url = `https://nominatim.openstreetmap.org/search?q=${query}&format=json&limit=1&countrycodes=es`;
|
||||
const res = await fetch(url, { headers: { "Accept-Language": "es", "User-Agent": "LocalesEspanoles/1.0" } });
|
||||
const data = await res.json();
|
||||
if (data?.length) return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon), displayName: data[0].display_name };
|
||||
return null;
|
||||
}
|
||||
|
||||
function StarRating({ value, onChange, readOnly = false }) {
|
||||
const [hover, setHover] = useState(0);
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 2 }}>
|
||||
{[1,2,3,4,5].map(star => (
|
||||
<span key={star}
|
||||
onClick={() => !readOnly && onChange && onChange(star)}
|
||||
onMouseEnter={() => !readOnly && setHover(star)}
|
||||
onMouseLeave={() => !readOnly && setHover(0)}
|
||||
style={{ fontSize: 22, cursor: readOnly ? "default" : "pointer", color: star <= (hover || value) ? "#D4AF37" : "#ccc", transition: "color 0.15s", userSelect: "none" }}
|
||||
>★</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoriaBadge({ categoria, subcategoria }) {
|
||||
const cat = CATEGORIAS[categoria];
|
||||
if (!cat) return null;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function LocalCard({ local }) {
|
||||
const gmUrl = local.enlaceGoogleMaps || (local.direccion
|
||||
? `https://www.google.com/maps/search/${encodeURIComponent(local.nombre + " " + local.direccion)}`
|
||||
: null);
|
||||
|
||||
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>
|
||||
<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>
|
||||
)}
|
||||
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: 0 }}>
|
||||
Publicado {new Date(local.fecha).toLocaleDateString("es-ES", { day: "numeric", month: "long", year: "numeric" })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CampoUbicacion({ form, setForm }) {
|
||||
const [modoInput, setModoInput] = useState("direccion");
|
||||
const [geocodificando, setGeocodificando] = useState(false);
|
||||
const [resultadoGeo, setResultadoGeo] = useState(null);
|
||||
const debounceRef = useRef(null);
|
||||
|
||||
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"
|
||||
};
|
||||
|
||||
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} 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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Banner descarga app móvil ─────────────────────────────────────────────────
|
||||
function BannerMovil() {
|
||||
const [visible, setVisible] = useState(true);
|
||||
if (!visible) return null;
|
||||
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={() => setVisible(false)} 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 }}>
|
||||
<span style={{ fontSize: 30 }}>📱</span>
|
||||
<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>
|
||||
<a href="/movil" target="_blank" rel="noopener noreferrer"
|
||||
style={{ display: "block", textAlign: "center", padding: "9px 0", background: "#8B0000", color: "white", borderRadius: 8, fontSize: 13, fontWeight: 700, textDecoration: "none" }}>
|
||||
📲 Abrir app móvil
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── App principal ─────────────────────────────────────────────────────────────
|
||||
export default function App() {
|
||||
@@ -281,28 +33,29 @@ export default function App() {
|
||||
const mapInstanceRef = useRef(null);
|
||||
const markersRef = useRef([]);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
nombre: "", provincia: "", categoria: "", subcategoria: "",
|
||||
direccion: "", enlaceGoogleMaps: "", lat: null, lng: null,
|
||||
puntuacion: 0, descripcion: ""
|
||||
});
|
||||
const [form, setForm] = useState(FORM_VACIO);
|
||||
|
||||
// Carga locales aprobados en tiempo real
|
||||
// Carga locales aprobados en tiempo real.
|
||||
// suscribirLocales es async (hace fetch antes de devolver la función de
|
||||
// limpieza), así que su valor de retorno inmediato es una Promise, no la
|
||||
// función en sí: hay que esperarla antes de poder cancelar el intervalo.
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const unsub = suscribirLocales(
|
||||
lista => { setLocales(lista); setLoading(false); },
|
||||
() => { setLocales([]); setLoading(false); }
|
||||
);
|
||||
return () => unsub();
|
||||
let cancelado = false;
|
||||
let limpiar;
|
||||
suscribirLocales(
|
||||
(lista) => { if (!cancelado) { setLocales(lista); setLoading(false); } },
|
||||
() => { if (!cancelado) { setLocales([]); setLoading(false); } },
|
||||
).then((fn) => { if (cancelado) fn?.(); else limpiar = fn; });
|
||||
return () => { cancelado = true; limpiar?.(); };
|
||||
}, []);
|
||||
|
||||
// Carga filtro de palabras desde Firestore
|
||||
// Carga filtro de palabras desde el backend
|
||||
useEffect(() => {
|
||||
obtenerPalabrasFiltradas().then(p => setPalabrasProhibidas(p));
|
||||
obtenerPalabrasFiltradas().then((p) => setPalabrasProhibidas(p));
|
||||
}, []);
|
||||
|
||||
const resetForm = () => setForm({ nombre: "", provincia: "", categoria: "", subcategoria: "", direccion: "", enlaceGoogleMaps: "", lat: null, lng: null, puntuacion: 0, descripcion: "" });
|
||||
const resetForm = () => setForm(FORM_VACIO);
|
||||
|
||||
const enviarLocal = async () => {
|
||||
setErrorFiltro("");
|
||||
@@ -313,11 +66,11 @@ export default function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
let lat = form.lat, lng = form.lng;
|
||||
let { lat, lng } = form;
|
||||
if (!lat || !lng) {
|
||||
const fb = COORDS_PROVINCIAS[form.provincia] || [40.4, -3.7];
|
||||
lat = fb[0] + (Math.random() - 0.5) * 0.04;
|
||||
lng = fb[1] + (Math.random() - 0.5) * 0.04;
|
||||
const fallback = COORDS_PROVINCIAS[form.provincia] || CENTRO_ESPANA;
|
||||
lat = fallback[0] + (Math.random() - 0.5) * 0.04;
|
||||
lng = fallback[1] + (Math.random() - 0.5) * 0.04;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
@@ -333,16 +86,18 @@ export default function App() {
|
||||
resetForm();
|
||||
setMostrarForm(false);
|
||||
setTimeout(() => setEnviado(false), 5000);
|
||||
} catch(e) { console.error(e); }
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const subcatsFiltro = filtroCategoria ? CATEGORIAS[filtroCategoria]?.subcategorias || [] : [];
|
||||
|
||||
const localesFiltrados = locales
|
||||
.filter(l => {
|
||||
.filter((l) => {
|
||||
const q = busqueda.toLowerCase();
|
||||
return (!q || l.nombre.toLowerCase().includes(q) || l.provincia.toLowerCase().includes(q) || (l.categoria||"").toLowerCase().includes(q) || (l.subcategoria||"").toLowerCase().includes(q) || (l.direccion||"").toLowerCase().includes(q))
|
||||
return (!q || l.nombre.toLowerCase().includes(q) || l.provincia.toLowerCase().includes(q) || (l.categoria || "").toLowerCase().includes(q) || (l.subcategoria || "").toLowerCase().includes(q) || (l.direccion || "").toLowerCase().includes(q))
|
||||
&& (!filtroCategoria || l.categoria === filtroCategoria)
|
||||
&& (!filtroSubcat || l.subcategoria === filtroSubcat)
|
||||
&& (!filtroProvincia || l.provincia === filtroProvincia);
|
||||
@@ -359,9 +114,17 @@ export default function App() {
|
||||
const timer = setTimeout(() => {
|
||||
if (!mapRef.current) return;
|
||||
if (!window.L) {
|
||||
const link = document.createElement("link"); link.rel = "stylesheet"; link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"; document.head.appendChild(link);
|
||||
const script = document.createElement("script"); script.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"; script.onload = () => initMap(); document.head.appendChild(script);
|
||||
} else { initMap(); }
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
|
||||
document.head.appendChild(link);
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js";
|
||||
script.onload = () => initMap();
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
initMap();
|
||||
}
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [vista]);
|
||||
@@ -383,44 +146,47 @@ export default function App() {
|
||||
const L = window.L;
|
||||
const map = mapObj || mapInstanceRef.current;
|
||||
if (!L || !map) return;
|
||||
markersRef.current.forEach(m => m.remove());
|
||||
markersRef.current.forEach((m) => m.remove());
|
||||
markersRef.current = [];
|
||||
locales.forEach(local => {
|
||||
locales.forEach((local) => {
|
||||
if (!local.lat || !local.lng) return;
|
||||
const color = COLORES_MAPA[local.categoria] || "#555";
|
||||
const cat = CATEGORIAS[local.categoria];
|
||||
const emoji = cat?.emoji || "📌";
|
||||
const stars = "★".repeat(local.puntuacion) + "☆".repeat(5 - local.puntuacion);
|
||||
const cat = obtenerCategoria(local.categoria);
|
||||
const color = COLORES_MAPA[local.categoria] || cat.color;
|
||||
const puntuacion = Math.min(5, Math.max(0, Math.round(Number(local.puntuacion) || 0)));
|
||||
const stars = "★".repeat(puntuacion) + "☆".repeat(5 - puntuacion);
|
||||
const labelDir = local.direccion ? ` · ${local.direccion.split(",")[0]}` : "";
|
||||
const marker = L.marker([local.lat, local.lng], {
|
||||
icon: L.divIcon({ className: "", html: `<div style="background:${color};color:white;border-radius:8px 8px 8px 0;padding:5px 10px;font-size:12px;font-weight:600;white-space:nowrap;box-shadow:0 2px 8px rgba(0,0,0,0.3);border:2px solid white;max-width:220px;overflow:hidden;text-overflow:ellipsis;">${emoji} ${local.nombre}${labelDir}</div>`, iconAnchor: [0, 32] })
|
||||
icon: L.divIcon({ className: "", html: `<div style="background:${color};color:white;border-radius:8px 8px 8px 0;padding:5px 10px;font-size:12px;font-weight:600;white-space:nowrap;box-shadow:0 2px 8px rgba(0,0,0,0.3);border:2px solid white;max-width:220px;overflow:hidden;text-overflow:ellipsis;">${cat.emoji} ${local.nombre}${labelDir}</div>`, iconAnchor: [0, 32] }),
|
||||
}).addTo(map);
|
||||
const gmLink = local.enlaceGoogleMaps || (local.direccion ? `https://www.google.com/maps/search/${encodeURIComponent(local.nombre + " " + local.direccion)}` : null);
|
||||
marker.bindPopup(`<div style="font-family:sans-serif;min-width:200px;max-width:260px"><strong style="font-size:15px">${local.nombre}</strong><br><span style="color:#666;font-size:12px">${local.provincia}</span><br><span style="background:${cat?.bgLight||"#eee"};color:${color};font-size:12px;padding:2px 8px;border-radius:4px;display:inline-block;margin:4px 0;font-weight:500">${emoji} ${local.categoria}</span>${local.subcategoria?`<span style="font-size:11px;color:#666;margin-left:4px">${local.subcategoria}</span>`:""}${local.direccion?`<br><span style="font-size:12px;color:#444;margin-top:4px;display:block">📍 ${local.direccion}</span>`:""}${gmLink?`<br><a href="${gmLink}" target="_blank" style="display:inline-block;margin-top:6px;font-size:12px;color:#1A73E8;font-weight:500;text-decoration:none;background:#E8F0FE;padding:3px 8px;border-radius:4px">Ver en Google Maps ↗</a>`:"" }<br><span style="color:#D4AF37;font-size:16px;display:block;margin-top:4px">${stars}</span>${local.descripcion?`<em style="font-size:12px;color:#555">"${local.descripcion}"</em>`:""}</div>`);
|
||||
const gmLink = local.enlaceGoogleMaps || (local.direccion ? `https://www.google.com/maps/search/${encodeURIComponent(`${local.nombre} ${local.direccion}`)}` : null);
|
||||
marker.bindPopup(`<div style="font-family:sans-serif;min-width:200px;max-width:260px"><strong style="font-size:15px">${local.nombre}</strong><br><span style="color:#666;font-size:12px">${local.provincia}</span><br><span style="background:${cat.bgLight};color:${color};font-size:12px;padding:2px 8px;border-radius:4px;display:inline-block;margin:4px 0;font-weight:500">${cat.emoji} ${local.categoria}</span>${local.subcategoria ? `<span style="font-size:11px;color:#666;margin-left:4px">${local.subcategoria}</span>` : ""}${local.direccion ? `<br><span style="font-size:12px;color:#444;margin-top:4px;display:block">📍 ${local.direccion}</span>` : ""}${gmLink ? `<br><a href="${gmLink}" target="_blank" style="display:inline-block;margin-top:6px;font-size:12px;color:#1A73E8;font-weight:500;text-decoration:none;background:#E8F0FE;padding:3px 8px;border-radius:4px">Ver en Google Maps ↗</a>` : ""}<br><span style="color:#D4AF37;font-size:16px;display:block;margin-top:4px">${stars}</span>${local.descripcion ? `<em style="font-size:12px;color:#555">"${local.descripcion}"</em>` : ""}</div>`);
|
||||
markersRef.current.push(marker);
|
||||
});
|
||||
}
|
||||
|
||||
const conteosCat = NOMBRES_CATEGORIAS.reduce((acc, c) => { acc[c] = locales.filter(l => l.categoria === c).length; return acc; }, {});
|
||||
const provincias = [...new Set(locales.map(l => l.provincia))].sort();
|
||||
const conteosCat = NOMBRES_CATEGORIAS.reduce((acc, c) => { acc[c] = locales.filter((l) => l.categoria === c).length; return acc; }, {});
|
||||
const provincias = [...new Set(locales.map((l) => l.provincia))].sort();
|
||||
const mediaGlobal = locales.length ? (locales.reduce((s, l) => s + l.puntuacion, 0) / locales.length).toFixed(1) : "—";
|
||||
|
||||
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" };
|
||||
const tieneUbicacion = !!(form.direccion || form.enlaceGoogleMaps);
|
||||
const formValido = form.nombre && form.provincia && form.categoria && form.puntuacion > 0 && tieneUbicacion;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720, margin: "0 auto", padding: "1.5rem 1rem", fontFamily: "var(--font-sans)" }}>
|
||||
<h2 className="sr-only">Directorio de locales españoles</h2>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: "1.5rem", gap: 12, flexWrap: "wrap" }}>
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{ fontSize: 28 }}>🇪🇸</span>
|
||||
<h1 style={{ margin: 0, fontSize: 22, fontWeight: 500 }}>Locales Españoles</h1>
|
||||
</div>
|
||||
<p style={{ margin: "4px 0 0", fontSize: 13, color: "var(--color-text-secondary)" }}>Directorio colaborativo de negocios por toda España</p>
|
||||
<h1 style={{ margin: 0, lineHeight: 0 }}>
|
||||
<img
|
||||
src="/logo-localesp.png"
|
||||
srcSet="/logo-localesp.png 1x, /logo-localesp@2x.png 2x"
|
||||
alt="LocalESP · Locales Españoles"
|
||||
width={220}
|
||||
height={62}
|
||||
style={{ display: "block", width: "clamp(140px, 35vw, 200px)", height: "auto" }}
|
||||
/>
|
||||
</h1>
|
||||
<p style={{ margin: "6px 0 0", fontSize: 13, color: "var(--color-text-secondary)" }}>Directorio colaborativo de negocios por toda España</p>
|
||||
</div>
|
||||
<button onClick={() => { setMostrarForm(!mostrarForm); setErrorFiltro(""); }}
|
||||
style={{ background: "#8B0000", color: "white", border: "none", borderRadius: "var(--border-radius-md)", padding: "8px 16px", fontSize: 14, fontWeight: 500, cursor: "pointer", display: "flex", alignItems: "center", gap: 6, whiteSpace: "nowrap" }}>
|
||||
@@ -430,8 +196,8 @@ export default function App() {
|
||||
|
||||
{/* Confirmación enviada */}
|
||||
{enviado && (
|
||||
<div style={{ background: "#EAFAF1", border: "1px solid #A9DFBF", borderRadius: "var(--border-radius-md)", padding: "12px 16px", marginBottom: "1rem", display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{ fontSize: 20 }}>✅</span>
|
||||
<div role="status" style={{ background: "#EAFAF1", border: "1px solid #A9DFBF", borderRadius: "var(--border-radius-md)", padding: "12px 16px", marginBottom: "1rem", display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{ fontSize: 20 }} aria-hidden="true">✅</span>
|
||||
<div>
|
||||
<p style={{ margin: 0, fontWeight: 600, color: "#1E8449", fontSize: 14 }}>¡Propuesta enviada!</p>
|
||||
<p style={{ margin: 0, fontSize: 13, color: "#1E8449" }}>El administrador revisará tu propuesta y la publicará si es correcta.</p>
|
||||
@@ -441,7 +207,7 @@ export default function App() {
|
||||
|
||||
{/* Stats */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10, marginBottom: "1.25rem" }}>
|
||||
{[{ label: "Locales", value: locales.length }, { label: "Provincias", value: provincias.length }, { label: "Media ★", value: mediaGlobal }].map(s => (
|
||||
{[{ label: "Locales", value: locales.length }, { label: "Provincias", value: provincias.length }, { label: "Media ★", value: mediaGlobal }].map((s) => (
|
||||
<div key={s.label} style={{ background: "var(--color-background-secondary)", borderRadius: "var(--border-radius-md)", padding: "0.75rem 1rem", textAlign: "center" }}>
|
||||
<p style={{ margin: 0, fontSize: 13, color: "var(--color-text-secondary)" }}>{s.label}</p>
|
||||
<p style={{ margin: "4px 0 0", fontSize: 24, fontWeight: 500 }}>{s.value}</p>
|
||||
@@ -451,12 +217,13 @@ export default function App() {
|
||||
|
||||
{/* Chips categorías */}
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginBottom: "1.25rem" }}>
|
||||
{NOMBRES_CATEGORIAS.map(cat => {
|
||||
{NOMBRES_CATEGORIAS.map((cat) => {
|
||||
const c = CATEGORIAS[cat];
|
||||
const activo = filtroCategoria === cat;
|
||||
return (
|
||||
<button key={cat} onClick={() => { setFiltroCategoria(activo ? "" : cat); setFiltroSubcat(""); }}
|
||||
style={{ background: activo ? c.bgLight : "var(--color-background-secondary)", color: activo ? c.textColor : "var(--color-text-secondary)", border: "0.5px solid " + (activo ? c.textColor : "var(--color-border-tertiary)"), borderRadius: 20, padding: "5px 12px", fontSize: 12, fontWeight: activo ? 500 : 400, cursor: "pointer", display: "flex", alignItems: "center", gap: 5, transition: "all 0.15s" }}>
|
||||
style={{ background: activo ? c.bgLight : "var(--color-background-secondary)", color: activo ? c.textColor : "var(--color-text-secondary)", border: `0.5px solid ${activo ? c.textColor : "var(--color-border-tertiary)"}`, borderRadius: 20, padding: "5px 12px", fontSize: 12, fontWeight: activo ? 500 : 400, cursor: "pointer", display: "flex", alignItems: "center", gap: 5, transition: "all 0.15s" }}
|
||||
aria-pressed={activo}>
|
||||
{c.emoji} {cat}
|
||||
{conteosCat[cat] > 0 && <span style={{ background: activo ? c.textColor : "var(--color-border-secondary)", color: activo ? "white" : "var(--color-text-secondary)", borderRadius: 10, padding: "0 6px", fontSize: 11 }}>{conteosCat[cat]}</span>}
|
||||
</button>
|
||||
@@ -473,45 +240,45 @@ export default function App() {
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Nombre del local *</label>
|
||||
<input style={inputStyle} placeholder="Bar El Olivo, Clínica San José..." value={form.nombre} onChange={e => setForm(f => ({ ...f, nombre: e.target.value }))} />
|
||||
<label htmlFor="campo-nombre" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Nombre del local *</label>
|
||||
<input id="campo-nombre" style={inputStyle} placeholder="Bar El Olivo, Clínica San José..." value={form.nombre} onChange={(e) => setForm((f) => ({ ...f, nombre: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Provincia *</label>
|
||||
<select style={inputStyle} value={form.provincia} onChange={e => setForm(f => ({ ...f, provincia: e.target.value }))}>
|
||||
<label htmlFor="campo-provincia" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Provincia *</label>
|
||||
<select id="campo-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>)}
|
||||
{PROVINCIAS.map((p) => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Categoría *</label>
|
||||
<select style={inputStyle} value={form.categoria} onChange={e => setForm(f => ({ ...f, categoria: e.target.value, subcategoria: "" }))}>
|
||||
<label htmlFor="campo-categoria" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Categoría *</label>
|
||||
<select id="campo-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>)}
|
||||
{NOMBRES_CATEGORIAS.map((c) => <option key={c} value={c}>{CATEGORIAS[c].emoji} {c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Tipo específico</label>
|
||||
<select style={{ ...inputStyle, opacity: form.categoria ? 1 : 0.5 }} value={form.subcategoria} onChange={e => setForm(f => ({ ...f, subcategoria: e.target.value }))} disabled={!form.categoria}>
|
||||
<label htmlFor="campo-subcategoria" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Tipo específico</label>
|
||||
<select id="campo-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="">{form.categoria ? "Selecciona tipo..." : "Elige categoría primero"}</option>
|
||||
{form.categoria && CATEGORIAS[form.categoria]?.subcategorias.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
{form.categoria && CATEGORIAS[form.categoria]?.subcategorias.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<CampoUbicacion form={form} setForm={setForm} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<label style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Descripción / comentario</label>
|
||||
<textarea style={{ ...inputStyle, resize: "vertical", minHeight: 60 }} placeholder="¿Qué lo hace especial? Horarios, servicios..." value={form.descripcion} onChange={e => setForm(f => ({ ...f, descripcion: e.target.value }))} />
|
||||
<label htmlFor="campo-descripcion" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Descripción / comentario</label>
|
||||
<textarea id="campo-descripcion" style={{ ...inputStyle, resize: "vertical", minHeight: 60 }} placeholder="¿Qué lo hace especial? Horarios, servicios..." value={form.descripcion} onChange={(e) => setForm((f) => ({ ...f, descripcion: e.target.value }))} />
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<label style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 6 }}>Puntuación *</label>
|
||||
<StarRating value={form.puntuacion} onChange={v => setForm(f => ({ ...f, puntuacion: v }))} />
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 6 }}>Puntuación *</span>
|
||||
<StarRating value={form.puntuacion} onChange={(v) => setForm((f) => ({ ...f, puntuacion: v }))} />
|
||||
</div>
|
||||
|
||||
{/* Error filtro palabras */}
|
||||
{errorFiltro && (
|
||||
<div style={{ marginTop: 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 }}>
|
||||
<div role="alert" style={{ marginTop: 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>
|
||||
)}
|
||||
@@ -527,9 +294,10 @@ export default function App() {
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: "flex", gap: 6, marginBottom: "1rem", borderBottom: "0.5px solid var(--color-border-tertiary)", paddingBottom: 8 }}>
|
||||
{[{ id: "lista", label: "Lista", icon: "ti-list" }, { id: "mapa", label: "Mapa", icon: "ti-map-2" }].map(v => (
|
||||
<button key={v.id} onClick={() => setVista(v.id)} style={{ background: vista === v.id ? "#8B0000" : "none", color: vista === v.id ? "white" : "var(--color-text-secondary)", border: "0.5px solid " + (vista === v.id ? "#8B0000" : "var(--color-border-tertiary)"), borderRadius: "var(--border-radius-md)", padding: "6px 14px", fontSize: 13, fontWeight: 500, cursor: "pointer", display: "flex", alignItems: "center", gap: 5 }}>
|
||||
<div role="tablist" aria-label="Vista de locales" style={{ display: "flex", gap: 6, marginBottom: "1rem", borderBottom: "0.5px solid var(--color-border-tertiary)", paddingBottom: 8 }}>
|
||||
{[{ id: "lista", label: "Lista", icon: "ti-list" }, { id: "mapa", label: "Mapa", icon: "ti-map-2" }].map((v) => (
|
||||
<button key={v.id} role="tab" aria-selected={vista === v.id} onClick={() => setVista(v.id)}
|
||||
style={{ background: vista === v.id ? "#8B0000" : "none", color: vista === v.id ? "white" : "var(--color-text-secondary)", border: `0.5px solid ${vista === v.id ? "#8B0000" : "var(--color-border-tertiary)"}`, borderRadius: "var(--border-radius-md)", padding: "6px 14px", fontSize: 13, fontWeight: 500, cursor: "pointer", display: "flex", alignItems: "center", gap: 5 }}>
|
||||
<i className={`ti ${v.icon}`} aria-hidden="true"></i> {v.label}
|
||||
</button>
|
||||
))}
|
||||
@@ -538,16 +306,16 @@ export default function App() {
|
||||
{vista === "lista" && (
|
||||
<>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr auto", gap: 8, marginBottom: "1rem" }}>
|
||||
<input style={inputStyle} placeholder="🔍 Buscar por nombre, dirección..." value={busqueda} onChange={e => setBusqueda(e.target.value)} />
|
||||
<select style={{ ...inputStyle, opacity: filtroCategoria ? 1 : 0.6 }} value={filtroSubcat} onChange={e => setFiltroSubcat(e.target.value)} disabled={!filtroCategoria}>
|
||||
<input style={inputStyle} placeholder="🔍 Buscar por nombre, dirección..." value={busqueda} onChange={(e) => setBusqueda(e.target.value)} aria-label="Buscar por nombre, dirección o categoría" />
|
||||
<select style={{ ...inputStyle, opacity: filtroCategoria ? 1 : 0.6 }} value={filtroSubcat} onChange={(e) => setFiltroSubcat(e.target.value)} disabled={!filtroCategoria} aria-label="Filtrar por tipo específico">
|
||||
<option value="">{filtroCategoria ? "Todos los tipos" : "Elige categoría arriba"}</option>
|
||||
{subcatsFiltro.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
{subcatsFiltro.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<select style={inputStyle} value={filtroProvincia} onChange={e => setFiltroProvincia(e.target.value)}>
|
||||
<select style={inputStyle} value={filtroProvincia} onChange={(e) => setFiltroProvincia(e.target.value)} aria-label="Filtrar por provincia">
|
||||
<option value="">Todas las provincias</option>
|
||||
{PROVINCIAS.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
{PROVINCIAS.map((p) => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
<select style={{ ...inputStyle, width: "auto" }} value={orden} onChange={e => setOrden(e.target.value)}>
|
||||
<select style={{ ...inputStyle, width: "auto" }} value={orden} onChange={(e) => setOrden(e.target.value)} aria-label="Ordenar locales">
|
||||
<option value="fecha">Reciente</option>
|
||||
<option value="puntuacion">★ Mejor</option>
|
||||
<option value="nombre">A-Z</option>
|
||||
@@ -556,18 +324,18 @@ export default function App() {
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: "center", padding: "3rem", color: "var(--color-text-tertiary)", fontSize: 14 }}>
|
||||
<i className="ti ti-loader" style={{ fontSize: 24, display: "block", marginBottom: 8 }}></i>
|
||||
<i className="ti ti-loader" style={{ fontSize: 24, display: "block", marginBottom: 8 }} aria-hidden="true"></i>
|
||||
Cargando locales...
|
||||
</div>
|
||||
) : localesFiltrados.length === 0 ? (
|
||||
<div style={{ textAlign: "center", padding: "3rem", color: "var(--color-text-tertiary)" }}>
|
||||
<div style={{ fontSize: 40, marginBottom: 12 }}>🏘️</div>
|
||||
<div style={{ fontSize: 40, marginBottom: 12 }} aria-hidden="true">🏘️</div>
|
||||
<p style={{ margin: 0, fontWeight: 500 }}>{locales.length === 0 ? "Todavía no hay locales publicados" : "No hay locales que coincidan"}</p>
|
||||
<p style={{ margin: "6px 0 0", fontSize: 13 }}>Sé el primero en proponer uno — el administrador lo revisará y publicará</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "grid", gap: 10 }}>
|
||||
{localesFiltrados.map(local => <LocalCard key={local.id} local={local} />)}
|
||||
{localesFiltrados.map((local) => <LocalCard key={local.id} local={local} />)}
|
||||
{localesFiltrados.length < locales.length && (
|
||||
<p style={{ textAlign: "center", fontSize: 12, color: "var(--color-text-tertiary)", margin: "4px 0" }}>
|
||||
Mostrando {localesFiltrados.length} de {locales.length} locales
|
||||
@@ -581,15 +349,15 @@ export default function App() {
|
||||
{vista === "mapa" && (
|
||||
<div style={{ borderRadius: "var(--border-radius-lg)", overflow: "hidden", border: "0.5px solid var(--color-border-tertiary)" }}>
|
||||
<div style={{ padding: "8px 12px", background: "var(--color-background-secondary)", borderBottom: "0.5px solid var(--color-border-tertiary)", display: "flex", flexWrap: "wrap", gap: 10 }}>
|
||||
{NOMBRES_CATEGORIAS.map(cat => (
|
||||
{NOMBRES_CATEGORIAS.map((cat) => (
|
||||
<span key={cat} style={{ fontSize: 11, display: "flex", alignItems: "center", gap: 4, color: "var(--color-text-secondary)" }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: 2, background: COLORES_MAPA[cat], display: "inline-block", flexShrink: 0 }}></span>
|
||||
<span style={{ width: 10, height: 10, borderRadius: 2, background: COLORES_MAPA[cat], display: "inline-block", flexShrink: 0 }} aria-hidden="true"></span>
|
||||
{CATEGORIAS[cat].emoji} {cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{locales.length === 0 && <div style={{ textAlign: "center", padding: "1rem", background: "var(--color-background-secondary)", fontSize: 13, color: "var(--color-text-secondary)" }}>Todavía no hay locales publicados</div>}
|
||||
<div ref={mapRef} style={{ height: 440, width: "100%" }}></div>
|
||||
<div ref={mapRef} style={{ height: 440, width: "100%" }} role="img" aria-label="Mapa de España con los locales publicados"></div>
|
||||
<div style={{ padding: "8px 12px", background: "var(--color-background-secondary)", borderTop: "0.5px solid var(--color-border-tertiary)", display: "flex", gap: 12, alignItems: "center" }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
|
||||
<i className="ti ti-map-pin" aria-hidden="true" style={{ marginRight: 4 }}></i>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Categorías de negocio compartidas entre la web pública y el panel de administración.
|
||||
// Centralizar esta lista evita que App.jsx y AdminPanel.jsx queden desincronizados.
|
||||
|
||||
export const CATEGORIAS = {
|
||||
"Restauración": {
|
||||
emoji: "🍽️",
|
||||
color: "#8B0000",
|
||||
bgLight: "#FFF0F0",
|
||||
textColor: "#8B0000",
|
||||
subcategorias: [
|
||||
"Tapas y raciones", "Paella y arroces", "Pintxos", "Asador / Carne a la brasa",
|
||||
"Mariscos y pescados", "Bocadillos y montaditos", "Menú del día", "Cocina vasca",
|
||||
"Cocina catalana", "Cocina andaluza", "Cocina gallega", "Cocina madrileña",
|
||||
"Cocina mediterránea", "Pizzería", "Hamburguesería", "Comida rápida",
|
||||
"Cocina internacional", "Cafetería / Desayunos", "Heladería", "Pastelería",
|
||||
],
|
||||
},
|
||||
"Pequeño comercio": {
|
||||
emoji: "🛍️",
|
||||
color: "#1A5276",
|
||||
bgLight: "#EBF5FB",
|
||||
textColor: "#1A5276",
|
||||
subcategorias: [
|
||||
"Alimentación / Ultramarinos", "Frutería", "Carnicería", "Pescadería", "Panadería",
|
||||
"Farmacia", "Papelería / Librería", "Floristería", "Joyería / Relojería", "Zapatería",
|
||||
"Ropa y moda", "Juguetería", "Ferretería", "Bazar / Todo a 100", "Estanco", "Quiosco",
|
||||
"Óptica", "Ortopedia", "Tienda de mascotas", "Electrodomésticos",
|
||||
],
|
||||
},
|
||||
"Peluquería y estética": {
|
||||
emoji: "✂️",
|
||||
color: "#76448A",
|
||||
bgLight: "#F5EEF8",
|
||||
textColor: "#76448A",
|
||||
subcategorias: [
|
||||
"Peluquería señora", "Peluquería caballero", "Peluquería unisex", "Barbería",
|
||||
"Centro de estética", "Uñas / Manicura", "Depilación", "Masajes y spa",
|
||||
"Tatuajes y piercings", "Centro de bronceado", "Micropigmentación",
|
||||
],
|
||||
},
|
||||
"Servicios del hogar": {
|
||||
emoji: "🔧",
|
||||
color: "#1E8449",
|
||||
bgLight: "#EAFAF1",
|
||||
textColor: "#1E8449",
|
||||
subcategorias: [
|
||||
"Cerrajería", "Fontanería / Plomería", "Electricidad", "Reformas y construcción",
|
||||
"Pintura", "Carpintería", "Cristalería", "Climatización / Aire acondicionado",
|
||||
"Mudanzas", "Limpieza", "Jardinería", "Instalación solar", "Alarmas y seguridad",
|
||||
"Reparación electrodomésticos",
|
||||
],
|
||||
},
|
||||
"Otros": {
|
||||
emoji: "📌",
|
||||
color: "#784212",
|
||||
bgLight: "#FDF2E9",
|
||||
textColor: "#784212",
|
||||
subcategorias: [
|
||||
"Taller mecánico", "Lavado de coches", "Academia / Clases", "Gestoría / Asesoría",
|
||||
"Inmobiliaria", "Agencia de viajes", "Fotografía", "Informática / Reparación móviles",
|
||||
"Copistería / Imprenta", "Veterinaria", "Gimnasio / Fitness", "Centro médico / Clínica",
|
||||
"Fisioterapia", "Psicología", "Lavandería", "Tintorería", "Otro",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const NOMBRES_CATEGORIAS = Object.keys(CATEGORIAS);
|
||||
|
||||
export const COLORES_MAPA = NOMBRES_CATEGORIAS.reduce((acc, nombre) => {
|
||||
acc[nombre] = CATEGORIAS[nombre].color;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Categoría comodín usada cuando un local trae una categoría desconocida o vacía
|
||||
// (por ejemplo, datos antiguos que ya no coinciden con el catálogo actual).
|
||||
export const CATEGORIA_DESCONOCIDA = {
|
||||
emoji: "📍",
|
||||
color: "#6e6e73",
|
||||
bgLight: "var(--color-background-secondary)",
|
||||
textColor: "var(--color-text-secondary)",
|
||||
subcategorias: [],
|
||||
};
|
||||
|
||||
export function obtenerCategoria(nombre) {
|
||||
return CATEGORIAS[nombre] || CATEGORIA_DESCONOCIDA;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Provincias españolas y sus coordenadas aproximadas (capital de provincia),
|
||||
// usadas como centro de referencia en el mapa cuando un local no trae lat/lng propios.
|
||||
|
||||
export const PROVINCIAS = [
|
||||
"Álava", "Albacete", "Alicante", "Almería", "Asturias", "Ávila", "Badajoz", "Barcelona",
|
||||
"Burgos", "Cáceres", "Cádiz", "Cantabria", "Castellón", "Ciudad Real", "Córdoba", "Cuenca",
|
||||
"Gerona", "Granada", "Guadalajara", "Guipúzcoa", "Huelva", "Huesca", "Islas Baleares", "Jaén",
|
||||
"La Coruña", "La Rioja", "Las Palmas", "León", "Lérida", "Lugo", "Madrid", "Málaga", "Murcia",
|
||||
"Navarra", "Orense", "Palencia", "Pontevedra", "Salamanca", "Santa Cruz de Tenerife", "Segovia",
|
||||
"Sevilla", "Soria", "Tarragona", "Teruel", "Toledo", "Valencia", "Valladolid", "Vizcaya",
|
||||
"Zamora", "Zaragoza",
|
||||
];
|
||||
|
||||
export const COORDS_PROVINCIAS = {
|
||||
"Álava": [42.8467, -2.6726], "Albacete": [38.9942, -1.8585], "Alicante": [38.3452, -0.4815],
|
||||
"Almería": [36.834, -2.4637], "Asturias": [43.3614, -5.8593], "Ávila": [40.6566, -4.6814],
|
||||
"Badajoz": [38.8794, -6.9707], "Barcelona": [41.3851, 2.1734], "Burgos": [42.344, -3.6969],
|
||||
"Cáceres": [39.4753, -6.3724], "Cádiz": [36.5271, -6.2886], "Cantabria": [43.4623, -3.8099],
|
||||
"Castellón": [39.9864, -0.0513], "Ciudad Real": [38.9848, -3.9274], "Córdoba": [37.8882, -4.7794],
|
||||
"Cuenca": [40.0704, -2.1374], "Gerona": [41.9794, 2.8214], "Granada": [37.1773, -3.5986],
|
||||
"Guadalajara": [40.6328, -3.1614], "Guipúzcoa": [43.3183, -1.9812], "Huelva": [37.2614, -6.9447],
|
||||
"Huesca": [42.1401, -0.4089], "Islas Baleares": [39.5696, 2.6502], "Jaén": [37.7796, -3.7849],
|
||||
"La Coruña": [43.3623, -8.4115], "La Rioja": [42.4627, -2.4449], "Las Palmas": [28.1235, -15.4366],
|
||||
"León": [42.5987, -5.5671], "Lérida": [41.6178, 0.62], "Lugo": [43.0097, -7.5567],
|
||||
"Madrid": [40.4168, -3.7038], "Málaga": [36.7213, -4.4214], "Murcia": [37.9922, -1.1307],
|
||||
"Navarra": [42.8169, -1.6432], "Orense": [42.3362, -7.8639], "Palencia": [42.0097, -4.5288],
|
||||
"Pontevedra": [42.4333, -8.65], "Salamanca": [40.9701, -5.6635], "Santa Cruz de Tenerife": [28.4636, -16.2518],
|
||||
"Segovia": [40.9429, -4.1088], "Sevilla": [37.3891, -5.9845], "Soria": [41.764, -2.464],
|
||||
"Tarragona": [41.1189, 1.2445], "Teruel": [40.344, -1.1065], "Toledo": [39.8628, -4.0273],
|
||||
"Valencia": [39.4699, -0.3763], "Valladolid": [41.6523, -4.7245], "Vizcaya": [43.263, -2.935],
|
||||
"Zamora": [41.5034, -5.7446], "Zaragoza": [41.6488, -0.8891],
|
||||
};
|
||||
|
||||
export const CENTRO_ESPANA = [40.4, -3.7];
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
// Detecta si la web se está ejecutando ya como app instalada (modo standalone),
|
||||
// tanto en Chrome/Android (display-mode: standalone) como en Safari/iOS
|
||||
// (navigator.standalone) o dentro de una TWA de Android (referrer android-app://).
|
||||
function calcularSiEsStandalone() {
|
||||
if (typeof window === "undefined") return false;
|
||||
const enModoStandalone = window.matchMedia?.("(display-mode: standalone)")?.matches ?? false;
|
||||
const enIosStandalone = window.navigator?.standalone === true;
|
||||
const enTwaAndroid = document.referrer?.startsWith("android-app://") ?? false;
|
||||
return enModoStandalone || enIosStandalone || enTwaAndroid;
|
||||
}
|
||||
|
||||
function detectarIos() {
|
||||
if (typeof window === "undefined") return false;
|
||||
return /iphone|ipad|ipod/i.test(window.navigator.userAgent) && !window.MSStream;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook que centraliza el estado de instalación de la PWA:
|
||||
* - isInstalled: la app ya se está ejecutando en modo standalone (o el
|
||||
* navegador confirma que está instalada mediante getInstalledRelatedApps).
|
||||
* - canPromptInstall: el navegador ha ofrecido el evento nativo `beforeinstallprompt`,
|
||||
* así que se puede lanzar el diálogo de instalación con promptInstall().
|
||||
* - isIos: Safari/iOS no soporta beforeinstallprompt, así que ahí solo cabe
|
||||
* mostrar instrucciones manuales ("Compartir → Añadir a pantalla de inicio").
|
||||
*/
|
||||
export default function usePwaInstall() {
|
||||
const [installPromptEvent, setInstallPromptEvent] = useState(null);
|
||||
const [isInstalled, setIsInstalled] = useState(calcularSiEsStandalone);
|
||||
const [isIos] = useState(detectarIos);
|
||||
|
||||
useEffect(() => {
|
||||
const alCapturarPrompt = (evento) => {
|
||||
evento.preventDefault();
|
||||
setInstallPromptEvent(evento);
|
||||
};
|
||||
const alInstalar = () => {
|
||||
setInstallPromptEvent(null);
|
||||
setIsInstalled(true);
|
||||
};
|
||||
|
||||
window.addEventListener("beforeinstallprompt", alCapturarPrompt);
|
||||
window.addEventListener("appinstalled", alInstalar);
|
||||
|
||||
const consultaDisplayMode = window.matchMedia?.("(display-mode: standalone)");
|
||||
const alCambiarDisplayMode = () => setIsInstalled(calcularSiEsStandalone());
|
||||
consultaDisplayMode?.addEventListener?.("change", alCambiarDisplayMode);
|
||||
|
||||
// Progressive enhancement: en navegadores compatibles (Chrome/Android),
|
||||
// confirma si la PWA ya está instalada aunque se esté viendo en una pestaña normal.
|
||||
navigator.getInstalledRelatedApps?.()
|
||||
.then((apps) => { if (apps.length > 0) setIsInstalled(true); })
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("beforeinstallprompt", alCapturarPrompt);
|
||||
window.removeEventListener("appinstalled", alInstalar);
|
||||
consultaDisplayMode?.removeEventListener?.("change", alCambiarDisplayMode);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const promptInstall = useCallback(async () => {
|
||||
if (!installPromptEvent) return "unavailable";
|
||||
installPromptEvent.prompt();
|
||||
const { outcome } = await installPromptEvent.userChoice;
|
||||
setInstallPromptEvent(null);
|
||||
return outcome; // "accepted" | "dismissed"
|
||||
}, [installPromptEvent]);
|
||||
|
||||
return { isInstalled, canPromptInstall: Boolean(installPromptEvent), promptInstall, isIos };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Estilos inline reutilizados en varios componentes de la web pública.
|
||||
// Se mantiene el enfoque de estilos inline del proyecto original (sin CSS
|
||||
// modules/Tailwind), pero centralizado aquí para no repetir el mismo objeto
|
||||
// en cada archivo.
|
||||
|
||||
export 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",
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
// Utilidades de geolocalización: extraer coordenadas de un enlace de Google Maps
|
||||
// o geocodificar una dirección de texto mediante Nominatim (OpenStreetMap).
|
||||
|
||||
export function parsearEnlaceGoogleMaps(url) {
|
||||
try {
|
||||
let coincidencia = url.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (coincidencia) return { lat: parseFloat(coincidencia[1]), lng: parseFloat(coincidencia[2]) };
|
||||
|
||||
coincidencia = url.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/);
|
||||
if (coincidencia) return { lat: parseFloat(coincidencia[1]), lng: parseFloat(coincidencia[2]) };
|
||||
|
||||
coincidencia = url.match(/[?&]q=(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (coincidencia) return { lat: parseFloat(coincidencia[1]), lng: parseFloat(coincidencia[2]) };
|
||||
} catch {
|
||||
// URL malformada: se ignora y se devuelve null más abajo
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function geocodificarDireccion(direccion) {
|
||||
const query = encodeURIComponent(`${direccion}, España`);
|
||||
const url = `https://nominatim.openstreetmap.org/search?q=${query}&format=json&limit=1&countrycodes=es`;
|
||||
const respuesta = await fetch(url, { headers: { "Accept-Language": "es", "User-Agent": "LocalesEspanoles/1.0" } });
|
||||
const datos = await respuesta.json();
|
||||
if (datos?.length) {
|
||||
return { lat: parseFloat(datos[0].lat), lng: parseFloat(datos[0].lon), displayName: datos[0].display_name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||