Prototipo YB01 #1

Open
edgar.friendly wants to merge 16 commits from prototipo-yb01 into master
20 changed files with 674 additions and 360 deletions
Showing only changes of commit 257fb9c622 - Show all commits
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+12 -2
View File
@@ -1,5 +1,15 @@
const CACHE_NAME = "localesp-shell-v1"; const CACHE_NAME = "localesp-shell-v2";
const SHELL_ASSETS = ["/", "/manifest.webmanifest", "/icon-192.png", "/icon-512.png"]; 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) => { self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(
+16 -18
View File
@@ -7,14 +7,8 @@ import {
suscribirLocales, deleteLocal, suscribirLocales, deleteLocal,
obtenerPalabrasFiltradas, guardarPalabrasFiltradas, obtenerPalabrasFiltradas, guardarPalabrasFiltradas,
} from "./firebase.js"; } from "./firebase.js";
import { obtenerCategoria } from "./constants/categorias.js";
const CATEGORIAS = { import StarRating from "./components/StarRating.jsx";
"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" },
};
const S = { const S = {
page: { minHeight:"100vh", background:"#F2EDE8", fontFamily:"-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" }, page: { minHeight:"100vh", background:"#F2EDE8", fontFamily:"-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" },
@@ -85,21 +79,21 @@ function PantallaAuth() {
// ── Tarjeta propuesta pendiente ─────────────────────────────────────────────── // ── Tarjeta propuesta pendiente ───────────────────────────────────────────────
function TarjetaPropuesta({ p, onAprobar, onRechazar, busy }) { 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" }); const fecha = new Date(p.fechaPropuesta).toLocaleDateString("es-ES", { day:"numeric", month:"short", hour:"2-digit", minute:"2-digit" });
return ( 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={{ display:"flex", justifyContent:"space-between", alignItems:"flex-start", flexWrap:"wrap", gap:12 }}>
<div style={{ flex:1 }}> <div style={{ flex:1 }}>
<div style={{ display:"flex", alignItems:"center", gap:8, flexWrap:"wrap", marginBottom:6 }}> <div style={{ display:"flex", alignItems:"center", gap:8, flexWrap:"wrap", marginBottom:6 }}>
<span style={{ fontWeight:700, fontSize:17 }}>{p.nombre}</span> <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>} {p.subcategoria && <span style={{ fontSize:12, color:"#888", background:"#F5F5F5", padding:"2px 8px", borderRadius:20 }}>{p.subcategoria}</span>}
</div> </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> <span>📍 {p.provincia}</span>
{p.direccion && <span>🗺 {p.direccion}</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> <span style={{ color:"#AAA" }}>Enviado: {fecha}</span>
</div> </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>} {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(() => { useEffect(() => {
setCargando(true); setCargando(true);
const u1 = suscribirPendientes(l => { setPendientes(l); setCargando(false); }, () => setCargando(false)); let cancelado = false;
const u2 = suscribirLocales(l => setPublicados(l), () => {}); let limpiarPendientes, limpiarPublicados;
obtenerPalabrasFiltradas().then(p => setPalabras(p)); suscribirPendientes(l => { if (!cancelado) { setPendientes(l); setCargando(false); } }, () => !cancelado && setCargando(false))
return () => { u1(); u2(); }; .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); }; 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={{ flex:1 }}>
<div style={{ display:"flex", alignItems:"center", gap:8, flexWrap:"wrap" }}> <div style={{ display:"flex", alignItems:"center", gap:8, flexWrap:"wrap" }}>
<span style={{ fontWeight:600, fontSize:15 }}>{l.nombre}</span> <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> <span style={{ fontSize:12, color:"#AAA" }}>📍 {l.provincia} · {new Date(l.fecha).toLocaleDateString("es-ES",{day:"numeric",month:"short",year:"numeric"})}</span>
</div> </div>
{l.direccion && <p style={{ margin:"3px 0 0", fontSize:13, color:"#888" }}>{l.direccion}</p>} {l.direccion && <p style={{ margin:"3px 0 0", fontSize:13, color:"#888" }}>{l.direccion}</p>}
+107 -339
View File
@@ -1,266 +1,18 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useEffect, useRef, useState } from "react";
import { suscribirLocales, enviarPropuesta, obtenerPalabrasFiltradas, contienepalabrasProhibidas } from "./firebase.js"; 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 = { const FORM_VACIO = {
"Restauración": { nombre: "", provincia: "", categoria: "", subcategoria: "",
emoji: "🍽️", color: "#8B0000", bgLight: "#FFF0F0", textColor: "#8B0000", direccion: "", enlaceGoogleMaps: "", lat: null, lng: null,
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"] puntuacion: 0, descripcion: "",
},
"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 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 ───────────────────────────────────────────────────────────── // ── App principal ─────────────────────────────────────────────────────────────
export default function App() { export default function App() {
@@ -281,28 +33,29 @@ export default function App() {
const mapInstanceRef = useRef(null); const mapInstanceRef = useRef(null);
const markersRef = useRef([]); const markersRef = useRef([]);
const [form, setForm] = useState({ const [form, setForm] = useState(FORM_VACIO);
nombre: "", provincia: "", categoria: "", subcategoria: "",
direccion: "", enlaceGoogleMaps: "", lat: null, lng: null,
puntuacion: 0, descripcion: ""
});
// 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(() => { useEffect(() => {
setLoading(true); setLoading(true);
const unsub = suscribirLocales( let cancelado = false;
lista => { setLocales(lista); setLoading(false); }, let limpiar;
() => { setLocales([]); setLoading(false); } suscribirLocales(
); (lista) => { if (!cancelado) { setLocales(lista); setLoading(false); } },
return () => unsub(); () => { 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(() => { 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 () => { const enviarLocal = async () => {
setErrorFiltro(""); setErrorFiltro("");
@@ -313,11 +66,11 @@ export default function App() {
return; return;
} }
let lat = form.lat, lng = form.lng; let { lat, lng } = form;
if (!lat || !lng) { if (!lat || !lng) {
const fb = COORDS_PROVINCIAS[form.provincia] || [40.4, -3.7]; const fallback = COORDS_PROVINCIAS[form.provincia] || CENTRO_ESPANA;
lat = fb[0] + (Math.random() - 0.5) * 0.04; lat = fallback[0] + (Math.random() - 0.5) * 0.04;
lng = fb[1] + (Math.random() - 0.5) * 0.04; lng = fallback[1] + (Math.random() - 0.5) * 0.04;
} }
setSaving(true); setSaving(true);
@@ -333,14 +86,16 @@ export default function App() {
resetForm(); resetForm();
setMostrarForm(false); setMostrarForm(false);
setTimeout(() => setEnviado(false), 5000); setTimeout(() => setEnviado(false), 5000);
} catch(e) { console.error(e); } } catch (e) {
console.error(e);
}
setSaving(false); setSaving(false);
}; };
const subcatsFiltro = filtroCategoria ? CATEGORIAS[filtroCategoria]?.subcategorias || [] : []; const subcatsFiltro = filtroCategoria ? CATEGORIAS[filtroCategoria]?.subcategorias || [] : [];
const localesFiltrados = locales const localesFiltrados = locales
.filter(l => { .filter((l) => {
const q = busqueda.toLowerCase(); 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) && (!filtroCategoria || l.categoria === filtroCategoria)
@@ -359,9 +114,17 @@ export default function App() {
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (!mapRef.current) return; if (!mapRef.current) return;
if (!window.L) { 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 link = document.createElement("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); link.rel = "stylesheet";
} else { initMap(); } 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); }, 100);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [vista]); }, [vista]);
@@ -383,44 +146,47 @@ export default function App() {
const L = window.L; const L = window.L;
const map = mapObj || mapInstanceRef.current; const map = mapObj || mapInstanceRef.current;
if (!L || !map) return; if (!L || !map) return;
markersRef.current.forEach(m => m.remove()); markersRef.current.forEach((m) => m.remove());
markersRef.current = []; markersRef.current = [];
locales.forEach(local => { locales.forEach((local) => {
if (!local.lat || !local.lng) return; if (!local.lat || !local.lng) return;
const color = COLORES_MAPA[local.categoria] || "#555"; const cat = obtenerCategoria(local.categoria);
const cat = CATEGORIAS[local.categoria]; const color = COLORES_MAPA[local.categoria] || cat.color;
const emoji = cat?.emoji || "📌"; const puntuacion = Math.min(5, Math.max(0, Math.round(Number(local.puntuacion) || 0)));
const stars = "★".repeat(local.puntuacion) + "☆".repeat(5 - local.puntuacion); const stars = "★".repeat(puntuacion) + "☆".repeat(5 - puntuacion);
const labelDir = local.direccion ? ` · ${local.direccion.split(",")[0]}` : ""; const labelDir = local.direccion ? ` · ${local.direccion.split(",")[0]}` : "";
const marker = L.marker([local.lat, local.lng], { 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); }).addTo(map);
const gmLink = local.enlaceGoogleMaps || (local.direccion ? `https://www.google.com/maps/search/${encodeURIComponent(local.nombre + " " + local.direccion)}` : null); 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>`); 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); markersRef.current.push(marker);
}); });
} }
const conteosCat = NOMBRES_CATEGORIAS.reduce((acc, c) => { acc[c] = locales.filter(l => l.categoria === c).length; return acc; }, {}); 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 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 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 tieneUbicacion = !!(form.direccion || form.enlaceGoogleMaps);
const formValido = form.nombre && form.provincia && form.categoria && form.puntuacion > 0 && tieneUbicacion; const formValido = form.nombre && form.provincia && form.categoria && form.puntuacion > 0 && tieneUbicacion;
return ( return (
<div style={{ maxWidth: 720, margin: "0 auto", padding: "1.5rem 1rem", fontFamily: "var(--font-sans)" }}> <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 */} {/* Header */}
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: "1.5rem", gap: 12, flexWrap: "wrap" }}> <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: "1.5rem", gap: 12, flexWrap: "wrap" }}>
<div> <div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}> <h1 style={{ margin: 0, lineHeight: 0 }}>
<span style={{ fontSize: 28 }}>🇪🇸</span> <img
<h1 style={{ margin: 0, fontSize: 22, fontWeight: 500 }}>Locales Españoles</h1> src="/logo-localesp.png"
</div> srcSet="/logo-localesp.png 1x, /logo-localesp@2x.png 2x"
<p style={{ margin: "4px 0 0", fontSize: 13, color: "var(--color-text-secondary)" }}>Directorio colaborativo de negocios por toda España</p> 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> </div>
<button onClick={() => { setMostrarForm(!mostrarForm); setErrorFiltro(""); }} <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" }}> 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 */} {/* Confirmación enviada */}
{enviado && ( {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 }}> <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 }}></span> <span style={{ fontSize: 20 }} aria-hidden="true"></span>
<div> <div>
<p style={{ margin: 0, fontWeight: 600, color: "#1E8449", fontSize: 14 }}>¡Propuesta enviada!</p> <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> <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 */} {/* Stats */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10, marginBottom: "1.25rem" }}> <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" }}> <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: 0, fontSize: 13, color: "var(--color-text-secondary)" }}>{s.label}</p>
<p style={{ margin: "4px 0 0", fontSize: 24, fontWeight: 500 }}>{s.value}</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 */} {/* Chips categorías */}
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginBottom: "1.25rem" }}> <div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginBottom: "1.25rem" }}>
{NOMBRES_CATEGORIAS.map(cat => { {NOMBRES_CATEGORIAS.map((cat) => {
const c = CATEGORIAS[cat]; const c = CATEGORIAS[cat];
const activo = filtroCategoria === cat; const activo = filtroCategoria === cat;
return ( return (
<button key={cat} onClick={() => { setFiltroCategoria(activo ? "" : cat); setFiltroSubcat(""); }} <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} {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>} {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> </button>
@@ -473,45 +240,45 @@ export default function App() {
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
<div> <div>
<label style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Nombre del local *</label> <label htmlFor="campo-nombre" 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 }))} /> <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>
<div> <div>
<label style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Provincia *</label> <label htmlFor="campo-provincia" 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 }))}> <select id="campo-provincia" style={inputStyle} value={form.provincia} onChange={(e) => setForm((f) => ({ ...f, provincia: e.target.value }))}>
<option value="">Selecciona provincia...</option> <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> </select>
</div> </div>
<div> <div>
<label style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Categoría *</label> <label htmlFor="campo-categoria" 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: "" }))}> <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> <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> </select>
</div> </div>
<div> <div>
<label style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Tipo específico</label> <label htmlFor="campo-subcategoria" 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}> <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> <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> </select>
</div> </div>
<CampoUbicacion form={form} setForm={setForm} /> <CampoUbicacion form={form} setForm={setForm} />
</div> </div>
<div style={{ marginTop: 10 }}> <div style={{ marginTop: 10 }}>
<label style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Descripción / comentario</label> <label htmlFor="campo-descripcion" 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 }))} /> <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>
<div style={{ marginTop: 10 }}> <div style={{ marginTop: 10 }}>
<label style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 6 }}>Puntuación *</label> <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 }))} /> <StarRating value={form.puntuacion} onChange={(v) => setForm((f) => ({ ...f, puntuacion: v }))} />
</div> </div>
{/* Error filtro palabras */} {/* Error filtro palabras */}
{errorFiltro && ( {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} <i className="ti ti-ban" aria-hidden="true"></i> {errorFiltro}
</div> </div>
)} )}
@@ -527,9 +294,10 @@ export default function App() {
)} )}
{/* Tabs */} {/* Tabs */}
<div style={{ display: "flex", gap: 6, marginBottom: "1rem", borderBottom: "0.5px solid var(--color-border-tertiary)", paddingBottom: 8 }}> <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 => ( {[{ 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 }}> <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} <i className={`ti ${v.icon}`} aria-hidden="true"></i> {v.label}
</button> </button>
))} ))}
@@ -538,16 +306,16 @@ export default function App() {
{vista === "lista" && ( {vista === "lista" && (
<> <>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr auto", gap: 8, marginBottom: "1rem" }}> <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)} /> <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}> <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> <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>
<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> <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>
<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="fecha">Reciente</option>
<option value="puntuacion"> Mejor</option> <option value="puntuacion"> Mejor</option>
<option value="nombre">A-Z</option> <option value="nombre">A-Z</option>
@@ -556,18 +324,18 @@ export default function App() {
{loading ? ( {loading ? (
<div style={{ textAlign: "center", padding: "3rem", color: "var(--color-text-tertiary)", fontSize: 14 }}> <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... Cargando locales...
</div> </div>
) : localesFiltrados.length === 0 ? ( ) : localesFiltrados.length === 0 ? (
<div style={{ textAlign: "center", padding: "3rem", color: "var(--color-text-tertiary)" }}> <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: 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 }}> el primero en proponer uno el administrador lo revisará y publicará</p> <p style={{ margin: "6px 0 0", fontSize: 13 }}> el primero en proponer uno el administrador lo revisará y publicará</p>
</div> </div>
) : ( ) : (
<div style={{ display: "grid", gap: 10 }}> <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 && ( {localesFiltrados.length < locales.length && (
<p style={{ textAlign: "center", fontSize: 12, color: "var(--color-text-tertiary)", margin: "4px 0" }}> <p style={{ textAlign: "center", fontSize: 12, color: "var(--color-text-tertiary)", margin: "4px 0" }}>
Mostrando {localesFiltrados.length} de {locales.length} locales Mostrando {localesFiltrados.length} de {locales.length} locales
@@ -581,15 +349,15 @@ export default function App() {
{vista === "mapa" && ( {vista === "mapa" && (
<div style={{ borderRadius: "var(--border-radius-lg)", overflow: "hidden", border: "0.5px solid var(--color-border-tertiary)" }}> <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 }}> <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 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} {CATEGORIAS[cat].emoji} {cat}
</span> </span>
))} ))}
</div> </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>} {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" }}> <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)" }}> <span style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
<i className="ti ti-map-pin" aria-hidden="true" style={{ marginRight: 4 }}></i> <i className="ti ti-map-pin" aria-hidden="true" style={{ marginRight: 4 }}></i>
+62
View File
@@ -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>
);
}
+95
View File
@@ -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>
);
}
+19
View File
@@ -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>
);
}
+58
View File
@@ -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>
);
}
+67
View File
@@ -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>
);
}
+86
View File
@@ -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;
}
+34
View File
@@ -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];
+72
View File
@@ -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 };
}
+16
View File
@@ -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",
};
+29
View File
@@ -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;
}