}
@@ -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 }) {
{l.nombre}
- {CATEGORIAS[l.categoria] && {CATEGORIAS[l.categoria].emoji} {l.categoria} }
+ {l.categoria && {obtenerCategoria(l.categoria).emoji} {l.categoria} }
📍 {l.provincia} · {new Date(l.fecha).toLocaleDateString("es-ES",{day:"numeric",month:"short",year:"numeric"})}
{l.direccion &&
{l.direccion}
}
diff --git a/src/App.jsx b/src/App.jsx
index d475524..7862c13 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -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 (
-
- {[1,2,3,4,5].map(star => (
- !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" }}
- >★
- ))}
-
- );
-}
-
-function CategoriaBadge({ categoria, subcategoria }) {
- const cat = CATEGORIAS[categoria];
- if (!cat) return null;
- return (
-
-
- {cat.emoji} {categoria}
-
- {subcategoria && (
-
- {subcategoria}
-
- )}
-
- );
-}
-
-function LocalCard({ local }) {
- const gmUrl = local.enlaceGoogleMaps || (local.direccion
- ? `https://www.google.com/maps/search/${encodeURIComponent(local.nombre + " " + local.direccion)}`
- : null);
-
- return (
-
e.currentTarget.style.borderColor = "var(--color-border-secondary)"}
- onMouseLeave={e => e.currentTarget.style.borderColor = "var(--color-border-tertiary)"}
- >
-
-
-
{local.nombre}
-
{local.provincia}
-
-
-
- {local.direccion && (
-
-
- {local.direccion}
-
- )}
- {gmUrl && (
-
-
- Ver en Google Maps
-
- )}
-
- {local.descripcion && (
-
"{local.descripcion}"
- )}
-
- Publicado {new Date(local.fecha).toLocaleDateString("es-ES", { day: "numeric", month: "long", year: "numeric" })}
-
-
- );
-}
-
-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 (
-
-
-
- Ubicación *
-
-
- {[{ id: "direccion", label: "📍 Dirección" }, { id: "enlace", label: "🔗 Google Maps" }].map(opt => (
- { 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}
-
- ))}
-
-
- {modoInput === "direccion" ? (
-
handleDireccionChange(e.target.value)} />
- ) : (
-
handleEnlaceChange(e.target.value)} />
- )}
- {geocodificando && (
-
- Buscando dirección...
-
- )}
- {resultadoGeo && !geocodificando && (
-
-
- {resultadoGeo.texto}
-
- )}
-
- );
-}
-
-// ── Banner descarga app móvil ─────────────────────────────────────────────────
-function BannerMovil() {
- const [visible, setVisible] = useState(true);
- if (!visible) return null;
- return (
-
-
setVisible(false)} style={{ position: "absolute", top: 8, right: 10, background: "none", border: "none", cursor: "pointer", color: "#CCC", fontSize: 16, padding: 0 }}>✕
-
-
📱
-
-
App para móvil
-
Sincronizada en tiempo real
-
-
-
- 📲 Abrir app móvil
-
-
- );
-}
// ── 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: `
${emoji} ${local.nombre}${labelDir}
`, iconAnchor: [0, 32] })
+ icon: L.divIcon({ className: "", html: `
${cat.emoji} ${local.nombre}${labelDir}
`, 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(`
${local.nombre} ${local.provincia} ${emoji} ${local.categoria} ${local.subcategoria?`
${local.subcategoria} `:""}${local.direccion?`
📍 ${local.direccion} `:""}${gmLink?`
Ver en Google Maps ↗ `:"" }
${stars} ${local.descripcion?`
"${local.descripcion}" `:""}
`);
+ const gmLink = local.enlaceGoogleMaps || (local.direccion ? `https://www.google.com/maps/search/${encodeURIComponent(`${local.nombre} ${local.direccion}`)}` : null);
+ marker.bindPopup(`
${local.nombre} ${local.provincia} ${cat.emoji} ${local.categoria} ${local.subcategoria ? `
${local.subcategoria} ` : ""}${local.direccion ? `
📍 ${local.direccion} ` : ""}${gmLink ? `
Ver en Google Maps ↗ ` : ""}
${stars} ${local.descripcion ? `
"${local.descripcion}" ` : ""}
`);
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 (
-
Directorio de locales españoles
-
{/* Header */}
-
- 🇪🇸
-
Locales Españoles
-
-
Directorio colaborativo de negocios por toda España
+
+
+
+
Directorio colaborativo de negocios por toda España
{ 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 && (
-
-
✅
+
+
✅
¡Propuesta enviada!
El administrador revisará tu propuesta y la publicará si es correcta.
@@ -441,7 +207,7 @@ export default function App() {
{/* Stats */}
- {[{ 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) => (
{s.label}
{s.value}
@@ -451,12 +217,13 @@ export default function App() {
{/* Chips categorías */}
- {NOMBRES_CATEGORIAS.map(cat => {
+ {NOMBRES_CATEGORIAS.map((cat) => {
const c = CATEGORIAS[cat];
const activo = filtroCategoria === cat;
return (
{ 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 && {conteosCat[cat]} }
@@ -473,45 +240,45 @@ export default function App() {
- Nombre del local *
- setForm(f => ({ ...f, nombre: e.target.value }))} />
+ Nombre del local *
+ setForm((f) => ({ ...f, nombre: e.target.value }))} />
- Provincia *
- setForm(f => ({ ...f, provincia: e.target.value }))}>
+ Provincia *
+ setForm((f) => ({ ...f, provincia: e.target.value }))}>
Selecciona provincia...
- {PROVINCIAS.map(p => {p} )}
+ {PROVINCIAS.map((p) => {p} )}
- Categoría *
- setForm(f => ({ ...f, categoria: e.target.value, subcategoria: "" }))}>
+ Categoría *
+ setForm((f) => ({ ...f, categoria: e.target.value, subcategoria: "" }))}>
Selecciona categoría...
- {NOMBRES_CATEGORIAS.map(c => {CATEGORIAS[c].emoji} {c} )}
+ {NOMBRES_CATEGORIAS.map((c) => {CATEGORIAS[c].emoji} {c} )}
- Tipo específico
- setForm(f => ({ ...f, subcategoria: e.target.value }))} disabled={!form.categoria}>
+ Tipo específico
+ setForm((f) => ({ ...f, subcategoria: e.target.value }))} disabled={!form.categoria}>
{form.categoria ? "Selecciona tipo..." : "Elige categoría primero"}
- {form.categoria && CATEGORIAS[form.categoria]?.subcategorias.map(s => {s} )}
+ {form.categoria && CATEGORIAS[form.categoria]?.subcategorias.map((s) => {s} )}
- Descripción / comentario
-
- Puntuación *
- setForm(f => ({ ...f, puntuacion: v }))} />
+ Puntuación *
+ setForm((f) => ({ ...f, puntuacion: v }))} />
{/* Error filtro palabras */}
{errorFiltro && (
-
+
{errorFiltro}
)}
@@ -527,9 +294,10 @@ export default function App() {
)}
{/* Tabs */}
-
- {[{ id: "lista", label: "Lista", icon: "ti-list" }, { id: "mapa", label: "Mapa", icon: "ti-map-2" }].map(v => (
-
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 }}>
+
+ {[{ id: "lista", label: "Lista", icon: "ti-list" }, { id: "mapa", label: "Mapa", icon: "ti-map-2" }].map((v) => (
+
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 }}>
{v.label}
))}
@@ -538,16 +306,16 @@ export default function App() {
{vista === "lista" && (
<>
-
setBusqueda(e.target.value)} />
-
setFiltroSubcat(e.target.value)} disabled={!filtroCategoria}>
+ setBusqueda(e.target.value)} aria-label="Buscar por nombre, dirección o categoría" />
+ setFiltroSubcat(e.target.value)} disabled={!filtroCategoria} aria-label="Filtrar por tipo específico">
{filtroCategoria ? "Todos los tipos" : "Elige categoría arriba"}
- {subcatsFiltro.map(s => {s} )}
+ {subcatsFiltro.map((s) => {s} )}
- setFiltroProvincia(e.target.value)}>
+ setFiltroProvincia(e.target.value)} aria-label="Filtrar por provincia">
Todas las provincias
- {PROVINCIAS.map(p => {p} )}
+ {PROVINCIAS.map((p) => {p} )}
- setOrden(e.target.value)}>
+ setOrden(e.target.value)} aria-label="Ordenar locales">
Reciente
★ Mejor
A-Z
@@ -556,18 +324,18 @@ export default function App() {
{loading ? (
-
+
Cargando locales...
) : localesFiltrados.length === 0 ? (
-
🏘️
+
🏘️
{locales.length === 0 ? "Todavía no hay locales publicados" : "No hay locales que coincidan"}
Sé el primero en proponer uno — el administrador lo revisará y publicará
) : (
- {localesFiltrados.map(local =>
)}
+ {localesFiltrados.map((local) =>
)}
{localesFiltrados.length < locales.length && (
Mostrando {localesFiltrados.length} de {locales.length} locales
@@ -581,15 +349,15 @@ export default function App() {
{vista === "mapa" && (
- {NOMBRES_CATEGORIAS.map(cat => (
+ {NOMBRES_CATEGORIAS.map((cat) => (
-
+
{CATEGORIAS[cat].emoji} {cat}
))}
{locales.length === 0 &&
Todavía no hay locales publicados
}
-
+
diff --git a/src/components/BannerMovil.jsx b/src/components/BannerMovil.jsx
new file mode 100644
index 0000000..910dfa8
--- /dev/null
+++ b/src/components/BannerMovil.jsx
@@ -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 (
+
+
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 }}>✕
+
+
+
+
+
App para móvil
+
Sincronizada en tiempo real
+
+
+
+ {canPromptInstall && (
+
+ {instalando ? "Abriendo instalación..." : "📲 Instalar app"}
+
+ )}
+
+ {!canPromptInstall && isIos && (
+
+ Pulsa Compartir y luego "Añadir a pantalla de inicio" para instalarla.
+
+ )}
+
+ {!canPromptInstall && !isIos && (
+
+ Instálala desde el menú de tu navegador ("Instalar app" o "Añadir a pantalla de inicio").
+
+ )}
+
+ {resultado === "dismissed" && (
+
Puedes instalarla más tarde cuando quieras.
+ )}
+
+ );
+}
diff --git a/src/components/CampoUbicacion.jsx b/src/components/CampoUbicacion.jsx
new file mode 100644
index 0000000..f6aad6f
--- /dev/null
+++ b/src/components/CampoUbicacion.jsx
@@ -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 (
+
+
+
+ Ubicación *
+
+
+ {[{ id: "direccion", label: "📍 Dirección" }, { id: "enlace", label: "🔗 Google Maps" }].map((opt) => (
+ { 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}
+
+ ))}
+
+
+
+ {modoInput === "direccion" ? (
+
handleDireccionChange(e.target.value)} />
+ ) : (
+
handleEnlaceChange(e.target.value)} />
+ )}
+
+ {geocodificando && (
+
+ Buscando dirección...
+
+ )}
+
+ {resultadoGeo && !geocodificando && (
+
+
+ {resultadoGeo.texto}
+
+ )}
+
+ );
+}
diff --git a/src/components/CategoriaBadge.jsx b/src/components/CategoriaBadge.jsx
new file mode 100644
index 0000000..ad692b3
--- /dev/null
+++ b/src/components/CategoriaBadge.jsx
@@ -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 (
+
+
+ {cat.emoji} {categoria}
+
+ {subcategoria && (
+
+ {subcategoria}
+
+ )}
+
+ );
+}
diff --git a/src/components/LocalCard.jsx b/src/components/LocalCard.jsx
new file mode 100644
index 0000000..cbe3bd5
--- /dev/null
+++ b/src/components/LocalCard.jsx
@@ -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 (
+ { e.currentTarget.style.borderColor = "var(--color-border-secondary)"; }}
+ onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--color-border-tertiary)"; }}
+ >
+
+
+
{local.nombre}
+ {local.provincia &&
{local.provincia}
}
+
+
+
+
+
+ {local.direccion && (
+
+
+ {local.direccion}
+
+ )}
+
+ {gmUrl && (
+
+
+ Ver en Google Maps
+
+ )}
+
+
+
+ {local.descripcion && (
+
"{local.descripcion}"
+ )}
+
+ {fechaFormateada && (
+
Publicado {fechaFormateada}
+ )}
+
+ );
+}
diff --git a/src/components/StarRating.jsx b/src/components/StarRating.jsx
new file mode 100644
index 0000000..f67127b
--- /dev/null
+++ b/src/components/StarRating.jsx
@@ -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): 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 (
+
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 (
+
+ );
+ }
+
+ return (
+ onChange(estrella)}
+ onMouseEnter={() => setValorHover(estrella)}
+ onFocus={() => setValorHover(estrella)}
+ onBlur={() => setValorHover(0)}
+ style={{ background: "none", border: "none", padding: 2, margin: 0, cursor: "pointer", lineHeight: 0 }}
+ >
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/constants/categorias.js b/src/constants/categorias.js
new file mode 100644
index 0000000..72db138
--- /dev/null
+++ b/src/constants/categorias.js
@@ -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;
+}
diff --git a/src/constants/provincias.js b/src/constants/provincias.js
new file mode 100644
index 0000000..989bfb2
--- /dev/null
+++ b/src/constants/provincias.js
@@ -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];
diff --git a/src/hooks/usePwaInstall.js b/src/hooks/usePwaInstall.js
new file mode 100644
index 0000000..3012df9
--- /dev/null
+++ b/src/hooks/usePwaInstall.js
@@ -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 };
+}
diff --git a/src/styles/shared.js b/src/styles/shared.js
new file mode 100644
index 0000000..fe036a2
--- /dev/null
+++ b/src/styles/shared.js
@@ -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",
+};
diff --git a/src/utils/geo.js b/src/utils/geo.js
new file mode 100644
index 0000000..8768357
--- /dev/null
+++ b/src/utils/geo.js
@@ -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;
+}