Compare commits
2
Commits
master
...
bd4282c426
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd4282c426 | ||
|
|
1b67900d54 |
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Locales Españoles</title>
|
||||||
|
<meta name="description" content="Directorio colaborativo de negocios por toda España" />
|
||||||
|
<!-- No indexar el admin -->
|
||||||
|
<meta name="robots" content="index, follow" />
|
||||||
|
<!-- Tabler Icons -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3.11.0/dist/tabler-icons.min.css" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
--color-background-primary: #ffffff;
|
||||||
|
--color-background-secondary: #f5f5f7;
|
||||||
|
--color-text-primary: #1d1d1f;
|
||||||
|
--color-text-secondary: #6e6e73;
|
||||||
|
--color-text-tertiary: #aeaeb2;
|
||||||
|
--color-text-info: #1a73e8;
|
||||||
|
--color-border-secondary: #d2d2d7;
|
||||||
|
--color-border-tertiary: #e5e5ea;
|
||||||
|
--border-radius-md: 8px;
|
||||||
|
--border-radius-lg: 12px;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--color-background-primary: #1c1c1e;
|
||||||
|
--color-background-secondary: #2c2c2e;
|
||||||
|
--color-text-primary: #f5f5f7;
|
||||||
|
--color-text-secondary: #aeaeb2;
|
||||||
|
--color-text-tertiary: #636366;
|
||||||
|
--color-border-secondary: #3a3a3c;
|
||||||
|
--color-border-tertiary: #2c2c2e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; background: var(--color-background-secondary); color: var(--color-text-primary); font-family: var(--font-sans); min-height: 100vh; }
|
||||||
|
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[build]
|
||||||
|
command = "npm run build"
|
||||||
|
publish = "dist"
|
||||||
|
|
||||||
|
# SPA: todas las rutas van al index.html (React gestiona /admin y /movil)
|
||||||
|
[[redirects]]
|
||||||
|
from = "/*"
|
||||||
|
to = "/index.html"
|
||||||
|
status = 200
|
||||||
Generated
+3303
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "locales-espanoles",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"server": "node server/index.js",
|
||||||
|
"dev:all": "concurrently -k -n vite,api -c cyan,green \"npm run dev\" \"npm run server\""
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"better-sqlite3": "^12.0.0",
|
||||||
|
"express": "^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"vite": "^5.2.0",
|
||||||
|
"concurrently": "^8.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { dirname, join } from "path";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const dbPath = join(__dirname, "../localesp.db");
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS locales (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
nombre TEXT NOT NULL,
|
||||||
|
descripcion TEXT,
|
||||||
|
ubicacion TEXT,
|
||||||
|
telefono TEXT,
|
||||||
|
email TEXT,
|
||||||
|
web TEXT,
|
||||||
|
categoria TEXT,
|
||||||
|
fecha TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
lat REAL,
|
||||||
|
lng REAL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pendientes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
nombre TEXT NOT NULL,
|
||||||
|
descripcion TEXT,
|
||||||
|
ubicacion TEXT,
|
||||||
|
telefono TEXT,
|
||||||
|
email TEXT,
|
||||||
|
web TEXT,
|
||||||
|
categoria TEXT,
|
||||||
|
estado TEXT DEFAULT 'pendiente',
|
||||||
|
fechaPropuesta TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
lat REAL,
|
||||||
|
lng REAL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS config (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
datos TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
created TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
export default db;
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import express from "express";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { dirname, join } from "path";
|
||||||
|
import db from "./db.js";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static(join(__dirname, "../dist")));
|
||||||
|
|
||||||
|
// Locales (público, solo lectura)
|
||||||
|
app.get("/api/locales", (_req, res) => {
|
||||||
|
const locales = db.prepare("SELECT * FROM locales ORDER BY fecha DESC").all();
|
||||||
|
res.json(locales);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pendientes (admin)
|
||||||
|
app.get("/api/pendientes", (_req, res) => {
|
||||||
|
const pendientes = db.prepare("SELECT * FROM pendientes ORDER BY fechaPropuesta DESC").all();
|
||||||
|
res.json(pendientes);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enviar propuesta
|
||||||
|
app.post("/api/propuestas", (req, res) => {
|
||||||
|
const { nombre, descripcion, ubicacion, telefono, email, web, categoria, lat, lng } = req.body;
|
||||||
|
const id = randomUUID();
|
||||||
|
const fechaPropuesta = new Date().toISOString();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO pendientes (id, nombre, descripcion, ubicacion, telefono, email, web, categoria, estado, fechaPropuesta, lat, lng)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pendiente', ?, ?, ?)
|
||||||
|
`).run(id, nombre, descripcion, ubicacion, telefono, email, web, categoria, fechaPropuesta, lat, lng);
|
||||||
|
|
||||||
|
res.status(201).json({ id, nombre, descripcion, ubicacion, telefono, email, web, categoria, estado: "pendiente", fechaPropuesta, lat, lng });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aprobar propuesta
|
||||||
|
app.post("/api/aprobar/:id", (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const propuesta = db.prepare("SELECT * FROM pendientes WHERE id = ?").get(id);
|
||||||
|
|
||||||
|
if (!propuesta) return res.status(404).json({ error: "Propuesta no encontrada" });
|
||||||
|
|
||||||
|
const localeId = randomUUID();
|
||||||
|
const fecha = new Date().toISOString();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO locales (id, nombre, descripcion, ubicacion, telefono, email, web, categoria, fecha, lat, lng)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(localeId, propuesta.nombre, propuesta.descripcion, propuesta.ubicacion, propuesta.telefono, propuesta.email, propuesta.web, propuesta.categoria, fecha, propuesta.lat, propuesta.lng);
|
||||||
|
|
||||||
|
db.prepare("DELETE FROM pendientes WHERE id = ?").run(id);
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rechazar propuesta
|
||||||
|
app.post("/api/rechazar/:id", (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
db.prepare("DELETE FROM pendientes WHERE id = ?").run(id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Eliminar local
|
||||||
|
app.delete("/api/locales/:id", (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
db.prepare("DELETE FROM locales WHERE id = ?").run(id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Config (palabras filtradas)
|
||||||
|
app.get("/api/config/filtro_palabras", (_req, res) => {
|
||||||
|
const config = db.prepare("SELECT datos FROM config WHERE id = ?").get("filtro_palabras");
|
||||||
|
if (config) {
|
||||||
|
res.json({ palabras: JSON.parse(config.datos) });
|
||||||
|
} else {
|
||||||
|
res.json({ palabras: [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/config/filtro_palabras", (req, res) => {
|
||||||
|
const { palabras } = req.body;
|
||||||
|
db.prepare("INSERT OR REPLACE INTO config (id, datos) VALUES (?, ?)").run("filtro_palabras", JSON.stringify(palabras));
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// SPA fallback
|
||||||
|
app.get(/^\/(?!api\/).*/, (_req, res) => {
|
||||||
|
res.sendFile(join(__dirname, "../dist/index.html"));
|
||||||
|
});
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
app.listen(PORT, () => console.log(`Server listening on http://localhost:${PORT}`));
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
// AdminPanel.jsx — /admin
|
||||||
|
// No aparece en ningún menú público. Acceso solo por URL directa.
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
escucharAuth, loginAdmin, registrarAdmin, cerrarSesion,
|
||||||
|
suscribirPendientes, aprobarPropuesta, rechazarPropuesta,
|
||||||
|
suscribirLocales, deleteLocal,
|
||||||
|
obtenerPalabrasFiltradas, guardarPalabrasFiltradas,
|
||||||
|
} from "./firebase.js";
|
||||||
|
|
||||||
|
const CATEGORIAS = {
|
||||||
|
"Restauración": { emoji:"🍽️", color:"#8B0000", bg:"#FFF0F0" },
|
||||||
|
"Pequeño comercio": { emoji:"🛍️", color:"#1A5276", bg:"#EBF5FB" },
|
||||||
|
"Peluquería y estética": { emoji:"✂️", color:"#76448A", bg:"#F5EEF8" },
|
||||||
|
"Servicios del hogar": { emoji:"🔧", color:"#1E8449", bg:"#EAFAF1" },
|
||||||
|
"Otros": { emoji:"📌", color:"#784212", bg:"#FDF2E9" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const S = {
|
||||||
|
page: { minHeight:"100vh", background:"#F2EDE8", fontFamily:"-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" },
|
||||||
|
header: { background:"#1A1A1A", color:"white", padding:"14px 28px", display:"flex", alignItems:"center", justifyContent:"space-between", position:"sticky", top:0, zIndex:10 },
|
||||||
|
wrap: { maxWidth:920, margin:"0 auto", padding:"28px 20px 60px" },
|
||||||
|
card: { background:"white", borderRadius:12, border:"1px solid #E8E0D5", padding:"20px 24px", marginBottom:14 },
|
||||||
|
input: { width:"100%", boxSizing:"border-box", padding:"10px 13px", border:"1px solid #DDD", borderRadius:8, fontSize:14, fontFamily:"inherit", outline:"none" },
|
||||||
|
btn: (bg="#8B0000",fg="white") => ({ padding:"9px 20px", background:bg, color:fg, border:"none", borderRadius:8, cursor:"pointer", fontWeight:600, fontSize:14, fontFamily:"inherit" }),
|
||||||
|
tab: (a) => ({ padding:"11px 22px", border:"none", background:"none", cursor:"pointer", fontFamily:"inherit", fontSize:14, fontWeight:a?700:400, color:a?"#8B0000":"#888", borderBottom:a?"2px solid #8B0000":"2px solid transparent", marginBottom:-2 }),
|
||||||
|
badge: (c,bg) => ({ display:"inline-block", padding:"3px 10px", borderRadius:20, fontSize:11, fontWeight:600, background:bg, color:c }),
|
||||||
|
tag: { display:"inline-flex", alignItems:"center", gap:5, background:"#F0EBE5", borderRadius:20, padding:"4px 12px", fontSize:13 },
|
||||||
|
label: { fontSize:11, color:"#999", fontWeight:700, textTransform:"uppercase", letterSpacing:"0.5px", display:"block", marginBottom:5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Pantalla de login/registro ────────────────────────────────────────────────
|
||||||
|
function PantallaAuth() {
|
||||||
|
const [modo, setModo] = useState("login");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [pass, setPass] = useState("");
|
||||||
|
const [pass2, setPass2] = useState("");
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const errMsg = code => ({ "auth/wrong-password":"Contraseña incorrecta.", "auth/user-not-found":"No existe ningún admin con ese email.", "auth/invalid-credential":"Email o contraseña incorrectos.", "auth/email-already-in-use":"Ese email ya está registrado.", "auth/weak-password":"Mínimo 6 caracteres.", "auth/invalid-email":"Email no válido.", "auth/too-many-requests":"Demasiados intentos. Espera un momento." }[code] || "Error inesperado.");
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setErr("");
|
||||||
|
if (!email || !pass) { setErr("Rellena todos los campos."); return; }
|
||||||
|
if (modo === "registro" && pass !== pass2) { setErr("Las contraseñas no coinciden."); return; }
|
||||||
|
setBusy(true);
|
||||||
|
try { modo === "login" ? await loginAdmin(email, pass) : await registrarAdmin(email, pass); }
|
||||||
|
catch(e) { setErr(errMsg(e.code)); }
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ ...S.page, display:"flex", alignItems:"center", justifyContent:"center" }}>
|
||||||
|
<div style={{ width:"100%", maxWidth:400, padding:"0 20px" }}>
|
||||||
|
<div style={{ textAlign:"center", marginBottom:28 }}>
|
||||||
|
<div style={{ fontSize:52, marginBottom:8 }}>🔐</div>
|
||||||
|
<h1 style={{ margin:0, fontSize:22, color:"#1A1A1A" }}>Panel de Administración</h1>
|
||||||
|
<p style={{ margin:"6px 0 0", color:"#999", fontSize:13 }}>Locales Españoles · acceso restringido</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ ...S.card, padding:"24px 28px" }}>
|
||||||
|
<div style={{ display:"flex", borderBottom:"1px solid #EEE", marginBottom:20 }}>
|
||||||
|
{[{id:"login",l:"Iniciar sesión"},{id:"registro",l:"Crear admin"}].map(t => (
|
||||||
|
<button key={t.id} onClick={() => { setModo(t.id); setErr(""); }}
|
||||||
|
style={{ flex:1, padding:"10px 0", border:"none", background:"none", cursor:"pointer", fontFamily:"inherit", fontSize:14, fontWeight:modo===t.id?700:400, color:modo===t.id?"#8B0000":"#888", borderBottom:modo===t.id?"2px solid #8B0000":"2px solid transparent", marginBottom:-1 }}>
|
||||||
|
{t.l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display:"flex", flexDirection:"column", gap:14 }}>
|
||||||
|
<div><label style={S.label}>Email</label><input style={S.input} type="email" placeholder="admin@ejemplo.com" value={email} onChange={e=>setEmail(e.target.value)} onKeyDown={e=>e.key==="Enter"&&submit()} /></div>
|
||||||
|
<div><label style={S.label}>Contraseña</label><input style={S.input} type="password" placeholder="Mínimo 6 caracteres" value={pass} onChange={e=>setPass(e.target.value)} onKeyDown={e=>e.key==="Enter"&&submit()} /></div>
|
||||||
|
{modo==="registro" && <div><label style={S.label}>Repetir contraseña</label><input style={S.input} type="password" placeholder="Repite la contraseña" value={pass2} onChange={e=>setPass2(e.target.value)} onKeyDown={e=>e.key==="Enter"&&submit()} /></div>}
|
||||||
|
{err && <p style={{ margin:0, color:"#C0392B", fontSize:13, background:"#FDEDEC", padding:"8px 12px", borderRadius:8 }}>{err}</p>}
|
||||||
|
<button onClick={submit} disabled={busy} style={{ ...S.btn(), opacity:busy?0.6:1, marginTop:4 }}>
|
||||||
|
{busy ? "..." : modo==="login" ? "Entrar" : "Crear cuenta de administrador"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style={{ textAlign:"center", fontSize:12, color:"#CCC", marginTop:14 }}>Esta página no está indexada en el sitio público</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tarjeta propuesta pendiente ───────────────────────────────────────────────
|
||||||
|
function TarjetaPropuesta({ p, onAprobar, onRechazar, busy }) {
|
||||||
|
const cat = CATEGORIAS[p.categoria] || {};
|
||||||
|
const fecha = new Date(p.fechaPropuesta).toLocaleDateString("es-ES", { day:"numeric", month:"short", hour:"2-digit", minute:"2-digit" });
|
||||||
|
return (
|
||||||
|
<div style={{ ...S.card, borderLeft:`4px solid ${cat.color||"#CCC"}`, marginBottom:10 }}>
|
||||||
|
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"flex-start", flexWrap:"wrap", gap:12 }}>
|
||||||
|
<div style={{ flex:1 }}>
|
||||||
|
<div style={{ display:"flex", alignItems:"center", gap:8, flexWrap:"wrap", marginBottom:6 }}>
|
||||||
|
<span style={{ fontWeight:700, fontSize:17 }}>{p.nombre}</span>
|
||||||
|
{cat.emoji && <span style={S.badge(cat.color, cat.bg)}>{cat.emoji} {p.categoria}</span>}
|
||||||
|
{p.subcategoria && <span style={{ fontSize:12, color:"#888", background:"#F5F5F5", padding:"2px 8px", borderRadius:20 }}>{p.subcategoria}</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ display:"flex", flexWrap:"wrap", gap:16, fontSize:13, color:"#666" }}>
|
||||||
|
<span>📍 {p.provincia}</span>
|
||||||
|
{p.direccion && <span>🗺 {p.direccion}</span>}
|
||||||
|
<span style={{ color:"#D4AF37" }}>{"★".repeat(p.puntuacion||0)}{"☆".repeat(5-(p.puntuacion||0))}</span>
|
||||||
|
<span style={{ color:"#AAA" }}>Enviado: {fecha}</span>
|
||||||
|
</div>
|
||||||
|
{p.descripcion && <p style={{ margin:"8px 0 0", fontSize:13, color:"#666", fontStyle:"italic", background:"#FAFAFA", padding:"8px 12px", borderRadius:8 }}>"{p.descripcion}"</p>}
|
||||||
|
{p.enlaceGoogleMaps && <a href={p.enlaceGoogleMaps} target="_blank" rel="noopener noreferrer" style={{ display:"inline-block", marginTop:6, fontSize:12, color:"#1A73E8" }}>Ver en Google Maps ↗</a>}
|
||||||
|
</div>
|
||||||
|
<div style={{ display:"flex", gap:8, flexShrink:0 }}>
|
||||||
|
<button onClick={() => onAprobar(p)} disabled={busy} style={{ ...S.btn("#1E8449"), padding:"8px 16px", fontSize:13 }}>✓ Aprobar</button>
|
||||||
|
<button onClick={() => onRechazar(p.id)} disabled={busy} style={{ ...S.btn("#C0392B"), padding:"8px 16px", fontSize:13 }}>✕ Rechazar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Panel principal ───────────────────────────────────────────────────────────
|
||||||
|
function PanelAdmin({ email }) {
|
||||||
|
const [tab, setTab] = useState("pendientes");
|
||||||
|
const [pendientes, setPendientes] = useState([]);
|
||||||
|
const [publicados, setPublicados] = useState([]);
|
||||||
|
const [palabras, setPalabras] = useState([]);
|
||||||
|
const [nuevaP, setNuevaP] = useState("");
|
||||||
|
const [cargando, setCargando] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [guardando, setGuardando] = useState(false);
|
||||||
|
const [buscador, setBuscador] = useState("");
|
||||||
|
|
||||||
|
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(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const aprobar = async (p) => { setBusy(true); try { await aprobarPropuesta(p); } catch(e) { alert("Error: "+e.message); } setBusy(false); };
|
||||||
|
const rechazar = async (id) => { if (!confirm("¿Rechazar y eliminar esta propuesta?")) return; try { await rechazarPropuesta(id); } catch(e) { alert("Error: "+e.message); } };
|
||||||
|
const eliminar = async (id) => { if (!confirm("¿Eliminar este local publicado?")) return; try { await deleteLocal(id); } catch(e) { alert("Error: "+e.message); } };
|
||||||
|
|
||||||
|
const addPalabra = () => {
|
||||||
|
const p = nuevaP.trim().toLowerCase();
|
||||||
|
if (!p || palabras.includes(p)) return;
|
||||||
|
setPalabras([...palabras, p]); setNuevaP("");
|
||||||
|
};
|
||||||
|
const guardarFiltro = async () => {
|
||||||
|
setGuardando(true);
|
||||||
|
try { await guardarPalabrasFiltradas(palabras); alert("✅ Filtro guardado."); }
|
||||||
|
catch(e) { alert("Error: "+e.message); }
|
||||||
|
setGuardando(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pubFiltrados = publicados.filter(l =>
|
||||||
|
!buscador || l.nombre.toLowerCase().includes(buscador.toLowerCase()) || l.provincia?.toLowerCase().includes(buscador.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id:"pendientes", l:`Pendientes (${pendientes.length})` },
|
||||||
|
{ id:"publicados", l:`Publicados (${publicados.length})` },
|
||||||
|
{ id:"filtro", l:"Filtro de palabras" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={S.page}>
|
||||||
|
<div style={S.header}>
|
||||||
|
<div style={{ display:"flex", alignItems:"center", gap:12 }}>
|
||||||
|
<span style={{ fontSize:24 }}>🛡️</span>
|
||||||
|
<div>
|
||||||
|
<p style={{ margin:0, fontWeight:700, fontSize:16 }}>Panel de Administración</p>
|
||||||
|
<p style={{ margin:0, fontSize:12, opacity:0.55 }}>Locales Españoles · {email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={cerrarSesion} style={{ background:"rgba(255,255,255,0.1)", border:"1px solid rgba(255,255,255,0.2)", color:"white", padding:"7px 16px", borderRadius:8, cursor:"pointer", fontSize:13, fontFamily:"inherit" }}>
|
||||||
|
Cerrar sesión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={S.wrap}>
|
||||||
|
{/* Tabs */}
|
||||||
|
<div style={{ display:"flex", borderBottom:"2px solid #E8E0D5", marginBottom:24 }}>
|
||||||
|
{TABS.map(t => <button key={t.id} onClick={() => setTab(t.id)} style={S.tab(tab===t.id)}>{t.l}</button>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Pendientes ── */}
|
||||||
|
{tab === "pendientes" && (
|
||||||
|
cargando ? <p style={{ color:"#999", textAlign:"center", padding:"3rem" }}>Cargando propuestas...</p>
|
||||||
|
: pendientes.length === 0 ? (
|
||||||
|
<div style={{ textAlign:"center", padding:"4rem", color:"#AAA" }}>
|
||||||
|
<div style={{ fontSize:52, marginBottom:12 }}>🎉</div>
|
||||||
|
<p style={{ margin:0, fontSize:16, fontWeight:600, color:"#888" }}>Sin propuestas pendientes</p>
|
||||||
|
<p style={{ margin:"6px 0 0", fontSize:13 }}>Cuando un usuario proponga un local, aparecerá aquí para que lo apruebes o rechaces.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p style={{ margin:"0 0 16px", fontSize:13, color:"#888" }}>
|
||||||
|
{pendientes.length} propuesta{pendientes.length!==1?"s":""} esperando revisión.
|
||||||
|
</p>
|
||||||
|
{pendientes.map(p => <TarjetaPropuesta key={p.id} p={p} onAprobar={aprobar} onRechazar={rechazar} busy={busy} />)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Publicados ── */}
|
||||||
|
{tab === "publicados" && (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom:16 }}>
|
||||||
|
<input style={{ ...S.input, maxWidth:380 }} placeholder="🔍 Buscar por nombre o provincia..." value={buscador} onChange={e => setBuscador(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
{pubFiltrados.length === 0
|
||||||
|
? <p style={{ color:"#AAA", textAlign:"center", padding:"2rem" }}>{publicados.length===0 ? "No hay locales publicados." : "Sin resultados."}</p>
|
||||||
|
: pubFiltrados.map(l => (
|
||||||
|
<div key={l.id} style={{ ...S.card, padding:"12px 18px", marginBottom:8 }}>
|
||||||
|
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"center", gap:12 }}>
|
||||||
|
<div style={{ flex:1 }}>
|
||||||
|
<div style={{ display:"flex", alignItems:"center", gap:8, flexWrap:"wrap" }}>
|
||||||
|
<span style={{ fontWeight:600, fontSize:15 }}>{l.nombre}</span>
|
||||||
|
{CATEGORIAS[l.categoria] && <span style={S.badge(CATEGORIAS[l.categoria].color, CATEGORIAS[l.categoria].bg)}>{CATEGORIAS[l.categoria].emoji} {l.categoria}</span>}
|
||||||
|
<span style={{ fontSize:12, color:"#AAA" }}>📍 {l.provincia} · {new Date(l.fecha).toLocaleDateString("es-ES",{day:"numeric",month:"short",year:"numeric"})}</span>
|
||||||
|
</div>
|
||||||
|
{l.direccion && <p style={{ margin:"3px 0 0", fontSize:13, color:"#888" }}>{l.direccion}</p>}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => eliminar(l.id)} style={{ background:"none", border:"1px solid #FADADD", borderRadius:8, padding:"6px 14px", color:"#C0392B", cursor:"pointer", fontSize:12, fontWeight:600, flexShrink:0 }}>Eliminar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Filtro de palabras ── */}
|
||||||
|
{tab === "filtro" && (
|
||||||
|
<div style={{ maxWidth:620 }}>
|
||||||
|
<div style={S.card}>
|
||||||
|
<p style={{ margin:"0 0 4px", fontWeight:700, fontSize:16 }}>Palabras prohibidas</p>
|
||||||
|
<p style={{ margin:"0 0 18px", fontSize:13, color:"#666", lineHeight:1.6 }}>
|
||||||
|
Las propuestas que contengan estas palabras en nombre, dirección o descripción serán bloqueadas automáticamente, sin llegar siquiera al panel de moderación. No distingue mayúsculas ni tildes.
|
||||||
|
</p>
|
||||||
|
<div style={{ display:"flex", gap:8, marginBottom:16 }}>
|
||||||
|
<input style={{ ...S.input, flex:1 }} placeholder="Añadir palabra o frase..." value={nuevaP}
|
||||||
|
onChange={e => setNuevaP(e.target.value)} onKeyDown={e => e.key==="Enter" && addPalabra()} />
|
||||||
|
<button onClick={addPalabra} style={S.btn()}>Añadir</button>
|
||||||
|
</div>
|
||||||
|
{palabras.length === 0
|
||||||
|
? <p style={{ color:"#BBB", fontSize:13, fontStyle:"italic" }}>No hay palabras filtradas todavía.</p>
|
||||||
|
: <div style={{ display:"flex", flexWrap:"wrap", gap:8, marginBottom:16 }}>
|
||||||
|
{palabras.map(p => (
|
||||||
|
<span key={p} style={S.tag}>
|
||||||
|
{p}
|
||||||
|
<button onClick={() => setPalabras(palabras.filter(x=>x!==p))} style={{ background:"none", border:"none", cursor:"pointer", color:"#AAA", padding:0, fontSize:15, lineHeight:1 }}>✕</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<button onClick={guardarFiltro} disabled={guardando} style={{ ...S.btn(), opacity:guardando?0.6:1 }}>
|
||||||
|
{guardando ? "Guardando..." : "💾 Guardar filtro"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ ...S.card, background:"#FFFBF0", border:"1px solid #F5E6C8" }}>
|
||||||
|
<p style={{ margin:"0 0 8px", fontWeight:600, fontSize:14, color:"#784212" }}>ℹ️ Cómo funciona el flujo completo</p>
|
||||||
|
<ol style={{ margin:0, paddingLeft:20, fontSize:13, color:"#555", lineHeight:2 }}>
|
||||||
|
<li>El usuario rellena el formulario público y pulsa <em>"Enviar propuesta"</em>.</li>
|
||||||
|
<li>Antes de guardar, se comprueba el filtro de palabras. Si hay coincidencia → se rechaza al instante con un aviso.</li>
|
||||||
|
<li>Si pasa el filtro → se guarda en <code>/pendientes</code> en Firestore.</li>
|
||||||
|
<li>Aquí en el panel aparece la tarjeta. Tú pulsas <strong>Aprobar</strong> → pasa a <code>/locales</code> y se publica. O <strong>Rechazar</strong> → se elimina sin publicar.</li>
|
||||||
|
<li>Solo los locales en <code>/locales</code> son visibles en la web y la app móvil.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Raíz del panel ────────────────────────────────────────────────────────────
|
||||||
|
export default function AdminPanel() {
|
||||||
|
const [user, setUser] = useState(undefined);
|
||||||
|
useEffect(() => escucharAuth(u => setUser(u)), []);
|
||||||
|
if (user === undefined) return <div style={{ ...S.page, display:"flex", alignItems:"center", justifyContent:"center" }}><p style={{ color:"#999" }}>Cargando...</p></div>;
|
||||||
|
if (!user) return <PantallaAuth />;
|
||||||
|
return <PanelAdmin email={user.email} />;
|
||||||
|
}
|
||||||
+609
@@ -0,0 +1,609 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { suscribirLocales, enviarPropuesta, obtenerPalabrasFiltradas, contienepalabrasProhibidas } from "./firebase.js";
|
||||||
|
|
||||||
|
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 NOMBRES_CATEGORIAS = Object.keys(CATEGORIAS);
|
||||||
|
|
||||||
|
const PROVINCIAS = [
|
||||||
|
"Álava","Albacete","Alicante","Almería","Asturias","Ávila","Badajoz","Barcelona","Burgos","Cáceres",
|
||||||
|
"Cádiz","Cantabria","Castellón","Ciudad Real","Córdoba","Cuenca","Gerona","Granada","Guadalajara","Guipúzcoa",
|
||||||
|
"Huelva","Huesca","Islas Baleares","Jaén","La Coruña","La Rioja","Las Palmas","León","Lérida","Lugo",
|
||||||
|
"Madrid","Málaga","Murcia","Navarra","Orense","Palencia","Pontevedra","Salamanca","Santa Cruz de Tenerife","Segovia",
|
||||||
|
"Sevilla","Soria","Tarragona","Teruel","Toledo","Valencia","Valladolid","Vizcaya","Zamora","Zaragoza"
|
||||||
|
];
|
||||||
|
|
||||||
|
const COORDS_PROVINCIAS = {
|
||||||
|
"Álava":[42.8467,-2.6726],"Albacete":[38.9942,-1.8585],"Alicante":[38.3452,-0.4815],
|
||||||
|
"Almería":[36.834,-2.4637],"Asturias":[43.3614,-5.8593],"Ávila":[40.6566,-4.6814],
|
||||||
|
"Badajoz":[38.8794,-6.9707],"Barcelona":[41.3851,2.1734],"Burgos":[42.344,-3.6969],
|
||||||
|
"Cáceres":[39.4753,-6.3724],"Cádiz":[36.5271,-6.2886],"Cantabria":[43.4623,-3.8099],
|
||||||
|
"Castellón":[39.9864,-0.0513],"Ciudad Real":[38.9848,-3.9274],"Córdoba":[37.8882,-4.7794],
|
||||||
|
"Cuenca":[40.0704,-2.1374],"Gerona":[41.9794,2.8214],"Granada":[37.1773,-3.5986],
|
||||||
|
"Guadalajara":[40.6328,-3.1614],"Guipúzcoa":[43.3183,-1.9812],"Huelva":[37.2614,-6.9447],
|
||||||
|
"Huesca":[42.1401,-0.4089],"Islas Baleares":[39.5696,2.6502],"Jaén":[37.7796,-3.7849],
|
||||||
|
"La Coruña":[43.3623,-8.4115],"La Rioja":[42.4627,-2.4449],"Las Palmas":[28.1235,-15.4366],
|
||||||
|
"León":[42.5987,-5.5671],"Lérida":[41.6178,0.62],"Lugo":[43.0097,-7.5567],
|
||||||
|
"Madrid":[40.4168,-3.7038],"Málaga":[36.7213,-4.4214],"Murcia":[37.9922,-1.1307],
|
||||||
|
"Navarra":[42.8169,-1.6432],"Orense":[42.3362,-7.8639],"Palencia":[42.0097,-4.5288],
|
||||||
|
"Pontevedra":[42.4333,-8.65],"Salamanca":[40.9701,-5.6635],"Santa Cruz de Tenerife":[28.4636,-16.2518],
|
||||||
|
"Segovia":[40.9429,-4.1088],"Sevilla":[37.3891,-5.9845],"Soria":[41.764,-2.464],
|
||||||
|
"Tarragona":[41.1189,1.2445],"Teruel":[40.344,-1.1065],"Toledo":[39.8628,-4.0273],
|
||||||
|
"Valencia":[39.4699,-0.3763],"Valladolid":[41.6523,-4.7245],"Vizcaya":[43.263,-2.935],
|
||||||
|
"Zamora":[41.5034,-5.7446],"Zaragoza":[41.6488,-0.8891],
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLORES_MAPA = {
|
||||||
|
"Restauración":"#8B0000","Pequeño comercio":"#1A5276",
|
||||||
|
"Peluquería y estética":"#76448A","Servicios del hogar":"#1E8449","Otros":"#784212",
|
||||||
|
};
|
||||||
|
|
||||||
|
function parsearEnlaceGoogleMaps(url) {
|
||||||
|
try {
|
||||||
|
let m = url.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||||
|
if (m) return { lat: parseFloat(m[1]), lng: parseFloat(m[2]) };
|
||||||
|
m = url.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/);
|
||||||
|
if (m) return { lat: parseFloat(m[1]), lng: parseFloat(m[2]) };
|
||||||
|
m = url.match(/[?&]q=(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||||
|
if (m) return { lat: parseFloat(m[1]), lng: parseFloat(m[2]) };
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function geocodificarDireccion(direccion) {
|
||||||
|
const query = encodeURIComponent(direccion + ", España");
|
||||||
|
const url = `https://nominatim.openstreetmap.org/search?q=${query}&format=json&limit=1&countrycodes=es`;
|
||||||
|
const res = await fetch(url, { headers: { "Accept-Language": "es", "User-Agent": "LocalesEspanoles/1.0" } });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data?.length) return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon), displayName: data[0].display_name };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StarRating({ value, onChange, readOnly = false }) {
|
||||||
|
const [hover, setHover] = useState(0);
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", gap: 2 }}>
|
||||||
|
{[1,2,3,4,5].map(star => (
|
||||||
|
<span key={star}
|
||||||
|
onClick={() => !readOnly && onChange && onChange(star)}
|
||||||
|
onMouseEnter={() => !readOnly && setHover(star)}
|
||||||
|
onMouseLeave={() => !readOnly && setHover(0)}
|
||||||
|
style={{ fontSize: 22, cursor: readOnly ? "default" : "pointer", color: star <= (hover || value) ? "#D4AF37" : "#ccc", transition: "color 0.15s", userSelect: "none" }}
|
||||||
|
>★</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoriaBadge({ categoria, subcategoria }) {
|
||||||
|
const cat = CATEGORIAS[categoria];
|
||||||
|
if (!cat) return null;
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>
|
||||||
|
<span style={{ background: cat.bgLight, color: cat.textColor, fontSize: 12, padding: "3px 10px", borderRadius: "var(--border-radius-md)", fontWeight: 500 }}>
|
||||||
|
{cat.emoji} {categoria}
|
||||||
|
</span>
|
||||||
|
{subcategoria && (
|
||||||
|
<span style={{ background: "var(--color-background-secondary)", color: "var(--color-text-secondary)", fontSize: 12, padding: "3px 10px", borderRadius: "var(--border-radius-md)" }}>
|
||||||
|
{subcategoria}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocalCard({ local }) {
|
||||||
|
const gmUrl = local.enlaceGoogleMaps || (local.direccion
|
||||||
|
? `https://www.google.com/maps/search/${encodeURIComponent(local.nombre + " " + local.direccion)}`
|
||||||
|
: null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: "var(--color-background-primary)", border: "0.5px solid var(--color-border-tertiary)", borderRadius: "var(--border-radius-lg)", padding: "1rem 1.25rem", display: "flex", flexDirection: "column", gap: 8, transition: "border-color 0.2s" }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.borderColor = "var(--color-border-secondary)"}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.borderColor = "var(--color-border-tertiary)"}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<p style={{ fontWeight: 500, fontSize: 16, margin: 0 }}>{local.nombre}</p>
|
||||||
|
<p style={{ fontSize: 13, color: "var(--color-text-secondary)", margin: "2px 0 0" }}>{local.provincia}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CategoriaBadge categoria={local.categoria} subcategoria={local.subcategoria} />
|
||||||
|
{local.direccion && (
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-start", gap: 6 }}>
|
||||||
|
<i className="ti ti-map-pin" aria-hidden="true" style={{ fontSize: 14, color: "var(--color-text-tertiary)", marginTop: 1, flexShrink: 0 }}></i>
|
||||||
|
<span style={{ fontSize: 13, color: "var(--color-text-secondary)" }}>{local.direccion}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{gmUrl && (
|
||||||
|
<a href={gmUrl} target="_blank" rel="noopener noreferrer"
|
||||||
|
style={{ display: "inline-flex", alignItems: "center", gap: 5, fontSize: 12, color: "#1A73E8", textDecoration: "none", width: "fit-content", background: "#E8F0FE", padding: "4px 10px", borderRadius: "var(--border-radius-md)", fontWeight: 500 }}>
|
||||||
|
<i className="ti ti-brand-google-maps" aria-hidden="true" style={{ fontSize: 14 }}></i>
|
||||||
|
Ver en Google Maps
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<StarRating value={local.puntuacion} readOnly />
|
||||||
|
{local.descripcion && (
|
||||||
|
<p style={{ fontSize: 13, color: "var(--color-text-secondary)", margin: 0, fontStyle: "italic" }}>"{local.descripcion}"</p>
|
||||||
|
)}
|
||||||
|
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: 0 }}>
|
||||||
|
Publicado {new Date(local.fecha).toLocaleDateString("es-ES", { day: "numeric", month: "long", year: "numeric" })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CampoUbicacion({ form, setForm }) {
|
||||||
|
const [modoInput, setModoInput] = useState("direccion");
|
||||||
|
const [geocodificando, setGeocodificando] = useState(false);
|
||||||
|
const [resultadoGeo, setResultadoGeo] = useState(null);
|
||||||
|
const debounceRef = useRef(null);
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
width: "100%", boxSizing: "border-box", padding: "8px 12px",
|
||||||
|
borderRadius: "var(--border-radius-md)", border: "0.5px solid var(--color-border-secondary)",
|
||||||
|
background: "var(--color-background-primary)", color: "var(--color-text-primary)", fontSize: 14, outline: "none"
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDireccionChange = (val) => {
|
||||||
|
setForm(f => ({ ...f, direccion: val, lat: null, lng: null, enlaceGoogleMaps: "" }));
|
||||||
|
setResultadoGeo(null);
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
if (val.trim().length < 8) return;
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
setGeocodificando(true);
|
||||||
|
try {
|
||||||
|
const geo = await geocodificarDireccion(val);
|
||||||
|
if (geo) {
|
||||||
|
setForm(f => ({ ...f, lat: geo.lat, lng: geo.lng }));
|
||||||
|
setResultadoGeo({ ok: true, texto: geo.displayName });
|
||||||
|
} else {
|
||||||
|
setResultadoGeo({ ok: false, texto: "No se encontró la dirección. Se usará el centro de la provincia." });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setResultadoGeo({ ok: false, texto: "Error al geocodificar. Se usará el centro de la provincia." });
|
||||||
|
}
|
||||||
|
setGeocodificando(false);
|
||||||
|
}, 900);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnlaceChange = (val) => {
|
||||||
|
setForm(f => ({ ...f, enlaceGoogleMaps: val, lat: null, lng: null, direccion: "" }));
|
||||||
|
setResultadoGeo(null);
|
||||||
|
if (!val.trim()) return;
|
||||||
|
const coords = parsearEnlaceGoogleMaps(val);
|
||||||
|
if (coords) {
|
||||||
|
setForm(f => ({ ...f, lat: coords.lat, lng: coords.lng }));
|
||||||
|
setResultadoGeo({ ok: true, texto: `Coordenadas detectadas: ${coords.lat.toFixed(5)}, ${coords.lng.toFixed(5)}` });
|
||||||
|
} else {
|
||||||
|
setResultadoGeo({ ok: false, texto: "No se pudieron extraer coordenadas. Copia la URL completa desde Google Maps." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ gridColumn: "1 / -1", marginTop: 4 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 6 }}>
|
||||||
|
<label style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
|
||||||
|
Ubicación <span style={{ color: "#8B0000" }}>*</span>
|
||||||
|
</label>
|
||||||
|
<div style={{ display: "flex", border: "0.5px solid var(--color-border-secondary)", borderRadius: "var(--border-radius-md)", overflow: "hidden" }}>
|
||||||
|
{[{ id: "direccion", label: "📍 Dirección" }, { id: "enlace", label: "🔗 Google Maps" }].map(opt => (
|
||||||
|
<button key={opt.id} onClick={() => { setModoInput(opt.id); setResultadoGeo(null); setForm(f => ({ ...f, lat: null, lng: null, direccion: "", enlaceGoogleMaps: "" })); }}
|
||||||
|
style={{ background: modoInput === opt.id ? "var(--color-background-secondary)" : "none", border: "none", padding: "4px 10px", fontSize: 11, cursor: "pointer", fontWeight: modoInput === opt.id ? 500 : 400, color: modoInput === opt.id ? "var(--color-text-primary)" : "var(--color-text-secondary)" }}>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{modoInput === "direccion" ? (
|
||||||
|
<input style={inputStyle} value={form.direccion || ""} placeholder="Ej: Calle Mayor 5, Alcalá de Henares, Madrid"
|
||||||
|
onChange={e => handleDireccionChange(e.target.value)} />
|
||||||
|
) : (
|
||||||
|
<input style={inputStyle} value={form.enlaceGoogleMaps || ""} placeholder="Pega aquí el enlace de Google Maps..."
|
||||||
|
onChange={e => handleEnlaceChange(e.target.value)} />
|
||||||
|
)}
|
||||||
|
{geocodificando && (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 6, fontSize: 12, color: "var(--color-text-secondary)" }}>
|
||||||
|
<i className="ti ti-loader" aria-hidden="true" style={{ fontSize: 14 }}></i> Buscando dirección...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{resultadoGeo && !geocodificando && (
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-start", gap: 6, marginTop: 6, fontSize: 12,
|
||||||
|
color: resultadoGeo.ok ? "#1E8449" : "#784212",
|
||||||
|
background: resultadoGeo.ok ? "#EAFAF1" : "#FDF2E9",
|
||||||
|
padding: "6px 10px", borderRadius: "var(--border-radius-md)" }}>
|
||||||
|
<i className={`ti ${resultadoGeo.ok ? "ti-circle-check" : "ti-alert-triangle"}`} aria-hidden="true" style={{ fontSize: 14, flexShrink: 0, marginTop: 1 }}></i>
|
||||||
|
<span style={{ lineHeight: 1.4 }}>{resultadoGeo.texto}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Banner descarga app móvil ─────────────────────────────────────────────────
|
||||||
|
function BannerMovil() {
|
||||||
|
const [visible, setVisible] = useState(true);
|
||||||
|
if (!visible) return null;
|
||||||
|
return (
|
||||||
|
<div style={{ position: "fixed", bottom: 20, right: 20, zIndex: 999, background: "white", border: "1px solid #E5DDD5", borderRadius: 14, padding: "14px 18px", boxShadow: "0 6px 24px rgba(0,0,0,0.10)", maxWidth: 280, fontFamily: "var(--font-sans)" }}>
|
||||||
|
<button onClick={() => setVisible(false)} style={{ position: "absolute", top: 8, right: 10, background: "none", border: "none", cursor: "pointer", color: "#CCC", fontSize: 16, padding: 0 }}>✕</button>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 10 }}>
|
||||||
|
<span style={{ fontSize: 30 }}>📱</span>
|
||||||
|
<div>
|
||||||
|
<p style={{ margin: 0, fontWeight: 700, fontSize: 14 }}>App para móvil</p>
|
||||||
|
<p style={{ margin: 0, fontSize: 12, color: "#888" }}>Sincronizada en tiempo real</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/movil" target="_blank" rel="noopener noreferrer"
|
||||||
|
style={{ display: "block", textAlign: "center", padding: "9px 0", background: "#8B0000", color: "white", borderRadius: 8, fontSize: 13, fontWeight: 700, textDecoration: "none" }}>
|
||||||
|
📲 Abrir app móvil
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── App principal ─────────────────────────────────────────────────────────────
|
||||||
|
export default function App() {
|
||||||
|
const [locales, setLocales] = useState([]);
|
||||||
|
const [vista, setVista] = useState("lista");
|
||||||
|
const [mostrarForm, setMostrarForm] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [enviado, setEnviado] = useState(false); // confirmación de propuesta enviada
|
||||||
|
const [errorFiltro, setErrorFiltro] = useState(""); // palabra prohibida detectada
|
||||||
|
const [busqueda, setBusqueda] = useState("");
|
||||||
|
const [filtroCategoria, setFiltroCategoria] = useState("");
|
||||||
|
const [filtroSubcat, setFiltroSubcat] = useState("");
|
||||||
|
const [filtroProvincia, setFiltroProvincia] = useState("");
|
||||||
|
const [orden, setOrden] = useState("fecha");
|
||||||
|
const [palabrasProhibidas, setPalabrasProhibidas] = useState([]);
|
||||||
|
const mapRef = useRef(null);
|
||||||
|
const mapInstanceRef = useRef(null);
|
||||||
|
const markersRef = useRef([]);
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
nombre: "", provincia: "", categoria: "", subcategoria: "",
|
||||||
|
direccion: "", enlaceGoogleMaps: "", lat: null, lng: null,
|
||||||
|
puntuacion: 0, descripcion: ""
|
||||||
|
});
|
||||||
|
|
||||||
|
// Carga locales aprobados en tiempo real
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
const unsub = suscribirLocales(
|
||||||
|
lista => { setLocales(lista); setLoading(false); },
|
||||||
|
() => { setLocales([]); setLoading(false); }
|
||||||
|
);
|
||||||
|
return () => unsub();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Carga filtro de palabras desde Firestore
|
||||||
|
useEffect(() => {
|
||||||
|
obtenerPalabrasFiltradas().then(p => setPalabrasProhibidas(p));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetForm = () => setForm({ nombre: "", provincia: "", categoria: "", subcategoria: "", direccion: "", enlaceGoogleMaps: "", lat: null, lng: null, puntuacion: 0, descripcion: "" });
|
||||||
|
|
||||||
|
const enviarLocal = async () => {
|
||||||
|
setErrorFiltro("");
|
||||||
|
// Comprobar filtro de palabras
|
||||||
|
const textoCompleto = [form.nombre, form.descripcion, form.direccion].join(" ");
|
||||||
|
if (contienepalabrasProhibidas(textoCompleto, palabrasProhibidas)) {
|
||||||
|
setErrorFiltro("Tu propuesta contiene palabras no permitidas. Por favor, revisa el nombre, dirección o descripción.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lat = form.lat, lng = form.lng;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await enviarPropuesta({
|
||||||
|
nombre: form.nombre, provincia: form.provincia,
|
||||||
|
categoria: form.categoria, subcategoria: form.subcategoria,
|
||||||
|
direccion: form.direccion, enlaceGoogleMaps: form.enlaceGoogleMaps,
|
||||||
|
puntuacion: form.puntuacion, descripcion: form.descripcion,
|
||||||
|
lat, lng,
|
||||||
|
});
|
||||||
|
setEnviado(true);
|
||||||
|
resetForm();
|
||||||
|
setMostrarForm(false);
|
||||||
|
setTimeout(() => setEnviado(false), 5000);
|
||||||
|
} catch(e) { console.error(e); }
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const subcatsFiltro = filtroCategoria ? CATEGORIAS[filtroCategoria]?.subcategorias || [] : [];
|
||||||
|
|
||||||
|
const localesFiltrados = locales
|
||||||
|
.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))
|
||||||
|
&& (!filtroCategoria || l.categoria === filtroCategoria)
|
||||||
|
&& (!filtroSubcat || l.subcategoria === filtroSubcat)
|
||||||
|
&& (!filtroProvincia || l.provincia === filtroProvincia);
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (orden === "fecha") return new Date(b.fecha) - new Date(a.fecha);
|
||||||
|
if (orden === "puntuacion") return b.puntuacion - a.puntuacion;
|
||||||
|
return a.nombre.localeCompare(b.nombre);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mapa Leaflet
|
||||||
|
useEffect(() => {
|
||||||
|
if (vista !== "mapa") return;
|
||||||
|
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(); }
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [vista]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (vista === "mapa" && mapInstanceRef.current && window.L) updateMarkers();
|
||||||
|
}, [locales, vista]);
|
||||||
|
|
||||||
|
function initMap() {
|
||||||
|
if (!mapRef.current || mapInstanceRef.current) return;
|
||||||
|
const L = window.L;
|
||||||
|
const map = L.map(mapRef.current, { zoomControl: true }).setView([40.416, -3.703], 6);
|
||||||
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>', maxZoom: 19 }).addTo(map);
|
||||||
|
mapInstanceRef.current = map;
|
||||||
|
updateMarkers(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMarkers(mapObj) {
|
||||||
|
const L = window.L;
|
||||||
|
const map = mapObj || mapInstanceRef.current;
|
||||||
|
if (!L || !map) return;
|
||||||
|
markersRef.current.forEach(m => m.remove());
|
||||||
|
markersRef.current = [];
|
||||||
|
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 labelDir = local.direccion ? ` · ${local.direccion.split(",")[0]}` : "";
|
||||||
|
const marker = L.marker([local.lat, local.lng], {
|
||||||
|
icon: L.divIcon({ className: "", html: `<div style="background:${color};color:white;border-radius:8px 8px 8px 0;padding:5px 10px;font-size:12px;font-weight:600;white-space:nowrap;box-shadow:0 2px 8px rgba(0,0,0,0.3);border:2px solid white;max-width:220px;overflow:hidden;text-overflow:ellipsis;">${emoji} ${local.nombre}${labelDir}</div>`, iconAnchor: [0, 32] })
|
||||||
|
}).addTo(map);
|
||||||
|
const gmLink = local.enlaceGoogleMaps || (local.direccion ? `https://www.google.com/maps/search/${encodeURIComponent(local.nombre + " " + local.direccion)}` : null);
|
||||||
|
marker.bindPopup(`<div style="font-family:sans-serif;min-width:200px;max-width:260px"><strong style="font-size:15px">${local.nombre}</strong><br><span style="color:#666;font-size:12px">${local.provincia}</span><br><span style="background:${cat?.bgLight||"#eee"};color:${color};font-size:12px;padding:2px 8px;border-radius:4px;display:inline-block;margin:4px 0;font-weight:500">${emoji} ${local.categoria}</span>${local.subcategoria?`<span style="font-size:11px;color:#666;margin-left:4px">${local.subcategoria}</span>`:""}${local.direccion?`<br><span style="font-size:12px;color:#444;margin-top:4px;display:block">📍 ${local.direccion}</span>`:""}${gmLink?`<br><a href="${gmLink}" target="_blank" style="display:inline-block;margin-top:6px;font-size:12px;color:#1A73E8;font-weight:500;text-decoration:none;background:#E8F0FE;padding:3px 8px;border-radius:4px">Ver en Google Maps ↗</a>`:"" }<br><span style="color:#D4AF37;font-size:16px;display:block;margin-top:4px">${stars}</span>${local.descripcion?`<em style="font-size:12px;color:#555">"${local.descripcion}"</em>`:""}</div>`);
|
||||||
|
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 mediaGlobal = locales.length ? (locales.reduce((s, l) => s + l.puntuacion, 0) / locales.length).toFixed(1) : "—";
|
||||||
|
|
||||||
|
const inputStyle = { width: "100%", boxSizing: "border-box", padding: "8px 12px", borderRadius: "var(--border-radius-md)", border: "0.5px solid var(--color-border-secondary)", background: "var(--color-background-primary)", color: "var(--color-text-primary)", fontSize: 14, outline: "none" };
|
||||||
|
const tieneUbicacion = !!(form.direccion || form.enlaceGoogleMaps);
|
||||||
|
const formValido = form.nombre && form.provincia && form.categoria && form.puntuacion > 0 && tieneUbicacion;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 720, margin: "0 auto", padding: "1.5rem 1rem", fontFamily: "var(--font-sans)" }}>
|
||||||
|
<h2 className="sr-only">Directorio de locales españoles</h2>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: "1.5rem", gap: 12, flexWrap: "wrap" }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<span style={{ fontSize: 28 }}>🇪🇸</span>
|
||||||
|
<h1 style={{ margin: 0, fontSize: 22, fontWeight: 500 }}>Locales Españoles</h1>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: "4px 0 0", fontSize: 13, color: "var(--color-text-secondary)" }}>Directorio colaborativo de negocios por toda España</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => { setMostrarForm(!mostrarForm); setErrorFiltro(""); }}
|
||||||
|
style={{ background: "#8B0000", color: "white", border: "none", borderRadius: "var(--border-radius-md)", padding: "8px 16px", fontSize: 14, fontWeight: 500, cursor: "pointer", display: "flex", alignItems: "center", gap: 6, whiteSpace: "nowrap" }}>
|
||||||
|
<i className="ti ti-plus" aria-hidden="true"></i> Proponer local
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirmación enviada */}
|
||||||
|
{enviado && (
|
||||||
|
<div style={{ background: "#EAFAF1", border: "1px solid #A9DFBF", borderRadius: "var(--border-radius-md)", padding: "12px 16px", marginBottom: "1rem", display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<span style={{ fontSize: 20 }}>✅</span>
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10, marginBottom: "1.25rem" }}>
|
||||||
|
{[{ label: "Locales", value: locales.length }, { label: "Provincias", value: provincias.length }, { label: "Media ★", value: mediaGlobal }].map(s => (
|
||||||
|
<div key={s.label} style={{ background: "var(--color-background-secondary)", borderRadius: "var(--border-radius-md)", padding: "0.75rem 1rem", textAlign: "center" }}>
|
||||||
|
<p style={{ margin: 0, fontSize: 13, color: "var(--color-text-secondary)" }}>{s.label}</p>
|
||||||
|
<p style={{ margin: "4px 0 0", fontSize: 24, fontWeight: 500 }}>{s.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chips categorías */}
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginBottom: "1.25rem" }}>
|
||||||
|
{NOMBRES_CATEGORIAS.map(cat => {
|
||||||
|
const c = CATEGORIAS[cat];
|
||||||
|
const activo = filtroCategoria === cat;
|
||||||
|
return (
|
||||||
|
<button key={cat} onClick={() => { setFiltroCategoria(activo ? "" : cat); setFiltroSubcat(""); }}
|
||||||
|
style={{ background: activo ? c.bgLight : "var(--color-background-secondary)", color: activo ? c.textColor : "var(--color-text-secondary)", border: "0.5px solid " + (activo ? c.textColor : "var(--color-border-tertiary)"), borderRadius: 20, padding: "5px 12px", fontSize: 12, fontWeight: activo ? 500 : 400, cursor: "pointer", display: "flex", alignItems: "center", gap: 5, transition: "all 0.15s" }}>
|
||||||
|
{c.emoji} {cat}
|
||||||
|
{conteosCat[cat] > 0 && <span style={{ background: activo ? c.textColor : "var(--color-border-secondary)", color: activo ? "white" : "var(--color-text-secondary)", borderRadius: 10, padding: "0 6px", fontSize: 11 }}>{conteosCat[cat]}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filtroCategoria && <button onClick={() => { setFiltroCategoria(""); setFiltroSubcat(""); }} style={{ background: "none", border: "0.5px solid var(--color-border-secondary)", borderRadius: 20, padding: "5px 10px", fontSize: 12, cursor: "pointer", color: "var(--color-text-tertiary)" }}>✕ limpiar</button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Formulario de propuesta */}
|
||||||
|
{mostrarForm && (
|
||||||
|
<div style={{ background: "var(--color-background-primary)", border: "0.5px solid var(--color-border-secondary)", borderRadius: "var(--border-radius-lg)", padding: "1.25rem", marginBottom: "1.5rem" }}>
|
||||||
|
<p style={{ margin: "0 0 4px", fontWeight: 500, fontSize: 16 }}>Proponer un local</p>
|
||||||
|
<p style={{ margin: "0 0 1rem", fontSize: 13, color: "var(--color-text-secondary)" }}>El administrador revisará tu propuesta antes de publicarla.</p>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Nombre del local *</label>
|
||||||
|
<input style={inputStyle} placeholder="Bar El Olivo, Clínica San José..." value={form.nombre} onChange={e => setForm(f => ({ ...f, nombre: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Provincia *</label>
|
||||||
|
<select style={inputStyle} value={form.provincia} onChange={e => setForm(f => ({ ...f, provincia: e.target.value }))}>
|
||||||
|
<option value="">Selecciona provincia...</option>
|
||||||
|
{PROVINCIAS.map(p => <option key={p} value={p}>{p}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Categoría *</label>
|
||||||
|
<select style={inputStyle} value={form.categoria} onChange={e => setForm(f => ({ ...f, categoria: e.target.value, subcategoria: "" }))}>
|
||||||
|
<option value="">Selecciona categoría...</option>
|
||||||
|
{NOMBRES_CATEGORIAS.map(c => <option key={c} value={c}>{CATEGORIAS[c].emoji} {c}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Tipo específico</label>
|
||||||
|
<select style={{ ...inputStyle, opacity: form.categoria ? 1 : 0.5 }} value={form.subcategoria} onChange={e => setForm(f => ({ ...f, subcategoria: e.target.value }))} disabled={!form.categoria}>
|
||||||
|
<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>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<CampoUbicacion form={form} setForm={setForm} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<label style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Descripción / comentario</label>
|
||||||
|
<textarea style={{ ...inputStyle, resize: "vertical", minHeight: 60 }} placeholder="¿Qué lo hace especial? Horarios, servicios..." value={form.descripcion} onChange={e => setForm(f => ({ ...f, descripcion: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<label style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 6 }}>Puntuación *</label>
|
||||||
|
<StarRating value={form.puntuacion} onChange={v => setForm(f => ({ ...f, puntuacion: v }))} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error filtro palabras */}
|
||||||
|
{errorFiltro && (
|
||||||
|
<div style={{ marginTop: 12, background: "#FDEDEC", border: "1px solid #F5B7B1", borderRadius: "var(--border-radius-md)", padding: "10px 14px", fontSize: 13, color: "#922B21", display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<i className="ti ti-ban" aria-hidden="true"></i> {errorFiltro}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: 8, marginTop: "1rem" }}>
|
||||||
|
<button onClick={enviarLocal} disabled={!formValido || saving}
|
||||||
|
style={{ background: formValido ? "#8B0000" : "var(--color-background-secondary)", color: formValido ? "white" : "var(--color-text-tertiary)", border: "none", borderRadius: "var(--border-radius-md)", padding: "8px 20px", fontSize: 14, fontWeight: 500, cursor: formValido ? "pointer" : "not-allowed" }}>
|
||||||
|
{saving ? "Enviando..." : "📨 Enviar propuesta"}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setMostrarForm(false); setErrorFiltro(""); }} style={{ background: "none", border: "0.5px solid var(--color-border-secondary)", borderRadius: "var(--border-radius-md)", padding: "8px 16px", fontSize: 14, cursor: "pointer", color: "var(--color-text-secondary)" }}>Cancelar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div style={{ display: "flex", gap: 6, marginBottom: "1rem", borderBottom: "0.5px solid var(--color-border-tertiary)", paddingBottom: 8 }}>
|
||||||
|
{[{ id: "lista", label: "Lista", icon: "ti-list" }, { id: "mapa", label: "Mapa", icon: "ti-map-2" }].map(v => (
|
||||||
|
<button key={v.id} onClick={() => setVista(v.id)} style={{ background: vista === v.id ? "#8B0000" : "none", color: vista === v.id ? "white" : "var(--color-text-secondary)", border: "0.5px solid " + (vista === v.id ? "#8B0000" : "var(--color-border-tertiary)"), borderRadius: "var(--border-radius-md)", padding: "6px 14px", fontSize: 13, fontWeight: 500, cursor: "pointer", display: "flex", alignItems: "center", gap: 5 }}>
|
||||||
|
<i className={`ti ${v.icon}`} aria-hidden="true"></i> {v.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{vista === "lista" && (
|
||||||
|
<>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr auto", gap: 8, marginBottom: "1rem" }}>
|
||||||
|
<input style={inputStyle} placeholder="🔍 Buscar por nombre, dirección..." value={busqueda} onChange={e => setBusqueda(e.target.value)} />
|
||||||
|
<select style={{ ...inputStyle, opacity: filtroCategoria ? 1 : 0.6 }} value={filtroSubcat} onChange={e => setFiltroSubcat(e.target.value)} disabled={!filtroCategoria}>
|
||||||
|
<option value="">{filtroCategoria ? "Todos los tipos" : "Elige categoría arriba"}</option>
|
||||||
|
{subcatsFiltro.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
<select style={inputStyle} value={filtroProvincia} onChange={e => setFiltroProvincia(e.target.value)}>
|
||||||
|
<option value="">Todas las provincias</option>
|
||||||
|
{PROVINCIAS.map(p => <option key={p} value={p}>{p}</option>)}
|
||||||
|
</select>
|
||||||
|
<select style={{ ...inputStyle, width: "auto" }} value={orden} onChange={e => setOrden(e.target.value)}>
|
||||||
|
<option value="fecha">Reciente</option>
|
||||||
|
<option value="puntuacion">★ Mejor</option>
|
||||||
|
<option value="nombre">A-Z</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: "center", padding: "3rem", color: "var(--color-text-tertiary)", fontSize: 14 }}>
|
||||||
|
<i className="ti ti-loader" style={{ fontSize: 24, display: "block", marginBottom: 8 }}></i>
|
||||||
|
Cargando locales...
|
||||||
|
</div>
|
||||||
|
) : localesFiltrados.length === 0 ? (
|
||||||
|
<div style={{ textAlign: "center", padding: "3rem", color: "var(--color-text-tertiary)" }}>
|
||||||
|
<div style={{ fontSize: 40, marginBottom: 12 }}>🏘️</div>
|
||||||
|
<p style={{ margin: 0, fontWeight: 500 }}>{locales.length === 0 ? "Todavía no hay locales publicados" : "No hay locales que coincidan"}</p>
|
||||||
|
<p style={{ margin: "6px 0 0", fontSize: 13 }}>Sé el primero en proponer uno — el administrador lo revisará y publicará</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "grid", gap: 10 }}>
|
||||||
|
{localesFiltrados.map(local => <LocalCard key={local.id} local={local} />)}
|
||||||
|
{localesFiltrados.length < locales.length && (
|
||||||
|
<p style={{ textAlign: "center", fontSize: 12, color: "var(--color-text-tertiary)", margin: "4px 0" }}>
|
||||||
|
Mostrando {localesFiltrados.length} de {locales.length} locales
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vista === "mapa" && (
|
||||||
|
<div style={{ borderRadius: "var(--border-radius-lg)", overflow: "hidden", border: "0.5px solid var(--color-border-tertiary)" }}>
|
||||||
|
<div style={{ padding: "8px 12px", background: "var(--color-background-secondary)", borderBottom: "0.5px solid var(--color-border-tertiary)", display: "flex", flexWrap: "wrap", gap: 10 }}>
|
||||||
|
{NOMBRES_CATEGORIAS.map(cat => (
|
||||||
|
<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>
|
||||||
|
{CATEGORIAS[cat].emoji} {cat}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{locales.length === 0 && <div style={{ textAlign: "center", padding: "1rem", background: "var(--color-background-secondary)", fontSize: 13, color: "var(--color-text-secondary)" }}>Todavía no hay locales publicados</div>}
|
||||||
|
<div ref={mapRef} style={{ height: 440, width: "100%" }}></div>
|
||||||
|
<div style={{ padding: "8px 12px", background: "var(--color-background-secondary)", borderTop: "0.5px solid var(--color-border-tertiary)", display: "flex", gap: 12, alignItems: "center" }}>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
|
||||||
|
<i className="ti ti-map-pin" aria-hidden="true" style={{ marginRight: 4 }}></i>
|
||||||
|
{locales.length} {locales.length === 1 ? "local marcado" : "locales marcados"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", textAlign: "center", marginTop: "1.5rem" }}>
|
||||||
|
Los locales pasan por revisión antes de publicarse · ¿Tienes un negocio? Proponlo arriba
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<BannerMovil />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { suscribirLocales, enviarPropuesta, obtenerPalabrasFiltradas, contienepalabrasProhibidas } from "./firebase.js";
|
||||||
|
|
||||||
|
const CATEGORIAS = {
|
||||||
|
"Restauración": { emoji:"🍽️", color:"#8B0000", bg:"#FFF0F0", text:"#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", bg:"#EBF5FB", text:"#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", bg:"#F5EEF8", text:"#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", bg:"#EAFAF1", text:"#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", bg:"#FDF2E9", text:"#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 CATS = 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 = {"Álava":[42.85,-2.67],"Albacete":[38.99,-1.86],"Alicante":[38.35,-0.48],"Almería":[36.83,-2.46],"Asturias":[43.36,-5.86],"Ávila":[40.66,-4.68],"Badajoz":[38.88,-6.97],"Barcelona":[41.39,2.17],"Burgos":[42.34,-3.70],"Cáceres":[39.48,-6.37],"Cádiz":[36.53,-6.29],"Cantabria":[43.46,-3.81],"Castellón":[39.99,-0.05],"Ciudad Real":[38.98,-3.93],"Córdoba":[37.89,-4.78],"Cuenca":[40.07,-2.14],"Gerona":[41.98,2.82],"Granada":[37.18,-3.60],"Guadalajara":[40.63,-3.16],"Guipúzcoa":[43.32,-1.98],"Huelva":[37.26,-6.94],"Huesca":[42.14,-0.41],"Islas Baleares":[39.57,2.65],"Jaén":[37.78,-3.78],"La Coruña":[43.36,-8.41],"La Rioja":[42.46,-2.44],"Las Palmas":[28.12,-15.44],"León":[42.60,-5.57],"Lérida":[41.62,0.62],"Lugo":[43.01,-7.56],"Madrid":[40.42,-3.70],"Málaga":[36.72,-4.42],"Murcia":[37.99,-1.13],"Navarra":[42.82,-1.64],"Orense":[42.34,-7.86],"Palencia":[42.01,-4.53],"Pontevedra":[42.43,-8.65],"Salamanca":[40.97,-5.66],"Santa Cruz de Tenerife":[28.46,-16.25],"Segovia":[40.94,-4.11],"Sevilla":[37.39,-5.98],"Soria":[41.76,-2.46],"Tarragona":[41.12,1.24],"Teruel":[40.34,-1.11],"Toledo":[39.86,-4.03],"Valencia":[39.47,-0.38],"Valladolid":[41.65,-4.72],"Vizcaya":[43.26,-2.94],"Zamora":[41.50,-5.74],"Zaragoza":[41.65,-0.89]};
|
||||||
|
|
||||||
|
async function geocodificar(dir) {
|
||||||
|
try {
|
||||||
|
const q = encodeURIComponent(dir + ", España");
|
||||||
|
const r = await fetch(`https://nominatim.openstreetmap.org/search?q=${q}&format=json&limit=1&countrycodes=es`, { headers:{"Accept-Language":"es","User-Agent":"LocalesMovil/1.0"} });
|
||||||
|
const d = await r.json();
|
||||||
|
if (d?.length) return { lat: parseFloat(d[0].lat), lng: parseFloat(d[0].lon) };
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsearGoogleMaps(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]) };
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Estilos base móvil ───────────────────────────────────────────────────────
|
||||||
|
const S = {
|
||||||
|
screen: { minHeight:"100dvh", display:"flex", flexDirection:"column", background:"#F8F5F0", fontFamily:"'Georgia', serif" },
|
||||||
|
header: { background:"#8B0000", color:"white", padding:"14px 16px 10px", position:"sticky", top:0, zIndex:10 },
|
||||||
|
headerTitle: { margin:0, fontSize:20, fontWeight:700, letterSpacing:"-0.3px" },
|
||||||
|
headerSub: { margin:"2px 0 0", fontSize:12, opacity:0.75 },
|
||||||
|
body: { flex:1, overflowY:"auto", padding:"12px 14px 80px" },
|
||||||
|
navBar: { position:"fixed", bottom:0, left:0, right:0, background:"white", borderTop:"1px solid #E5DDD5", display:"flex", zIndex:10, padding:"4px 0" },
|
||||||
|
navBtn: (active) => ({ flex:1, display:"flex", flexDirection:"column", alignItems:"center", gap:3, padding:"6px 0", background:"none", border:"none", cursor:"pointer", color: active ? "#8B0000" : "#999", fontSize:10, fontWeight: active ? 700 : 400 }),
|
||||||
|
navIcon: (active) => ({ fontSize:22, lineHeight:1, color: active ? "#8B0000" : "#999" }),
|
||||||
|
card: { background:"white", borderRadius:12, padding:"14px 16px", marginBottom:10, border:"1px solid #EEE8E0" },
|
||||||
|
badge: (cat) => { const c = CATEGORIAS[cat]; return { background: c?.bg||"#eee", color: c?.text||"#555", fontSize:11, padding:"3px 9px", borderRadius:20, fontWeight:500, display:"inline-block" }; },
|
||||||
|
input: { width:"100%", boxSizing:"border-box", padding:"11px 13px", borderRadius:10, border:"1px solid #DDD7CF", background:"white", fontSize:15, outline:"none", fontFamily:"inherit" },
|
||||||
|
label: { fontSize:12, color:"#888", display:"block", marginBottom:5, fontWeight:600, textTransform:"uppercase", letterSpacing:"0.4px" },
|
||||||
|
btn: (primary) => ({ width:"100%", padding:"13px", borderRadius:10, border:"none", cursor:"pointer", fontSize:15, fontWeight:700, background: primary ? "#8B0000" : "#F0EBE5", color: primary ? "white" : "#666", fontFamily:"inherit" }),
|
||||||
|
stars: (n) => "★".repeat(n)+"☆".repeat(5-n),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Pantalla: Lista ──────────────────────────────────────────────────────────
|
||||||
|
function PantallaLista({ locales, onDelete, onBuscar, busqueda, filtroCat, setFiltroCat }) {
|
||||||
|
const filtrados = locales.filter(l => {
|
||||||
|
const q = busqueda.toLowerCase();
|
||||||
|
return (!q || l.nombre.toLowerCase().includes(q) || (l.direccion||"").toLowerCase().includes(q) || l.provincia.toLowerCase().includes(q))
|
||||||
|
&& (!filtroCat || l.categoria === filtroCat);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom:12 }}>
|
||||||
|
<input style={S.input} placeholder="🔍 Buscar nombre, ciudad..." value={busqueda} onChange={e => onBuscar(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chips categoría */}
|
||||||
|
<div style={{ display:"flex", gap:6, overflowX:"auto", paddingBottom:8, marginBottom:10, scrollbarWidth:"none" }}>
|
||||||
|
<button onClick={() => setFiltroCat("")} style={{ ...S.badge("Restauración"), background: !filtroCat?"#8B0000":"#EEE8E0", color: !filtroCat?"white":"#777", flexShrink:0, cursor:"pointer", border:"none", fontFamily:"inherit" }}>Todos</button>
|
||||||
|
{CATS.map(c => (
|
||||||
|
<button key={c} onClick={() => setFiltroCat(filtroCat===c?"":c)}
|
||||||
|
style={{ ...S.badge(c), flexShrink:0, cursor:"pointer", border: filtroCat===c?"1.5px solid "+CATEGORIAS[c].color:"1px solid transparent", fontFamily:"inherit" }}>
|
||||||
|
{CATEGORIAS[c].emoji} {c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtrados.length === 0 ? (
|
||||||
|
<div style={{ textAlign:"center", padding:"3rem 1rem", color:"#AAA" }}>
|
||||||
|
<div style={{ fontSize:48, marginBottom:12 }}>🏘️</div>
|
||||||
|
<p style={{ margin:0, fontSize:15, color:"#888" }}>{locales.length===0 ? "¡Sé el primero en añadir un local!" : "Sin resultados"}</p>
|
||||||
|
</div>
|
||||||
|
) : filtrados.map(local => (
|
||||||
|
<div key={local.id} style={S.card}>
|
||||||
|
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"flex-start", marginBottom:8 }}>
|
||||||
|
<div style={{ flex:1 }}>
|
||||||
|
<p style={{ margin:0, fontWeight:700, fontSize:16, color:"#1A1A1A" }}>{local.nombre}</p>
|
||||||
|
<p style={{ margin:"2px 0 0", fontSize:13, color:"#888" }}>📍 {local.provincia}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => onDelete(local.id)} style={{ background:"none", border:"none", color:"#CCC", fontSize:18, cursor:"pointer", padding:"0 0 0 8px" }}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display:"flex", flexWrap:"wrap", gap:5, marginBottom:8 }}>
|
||||||
|
<span style={S.badge(local.categoria)}>{CATEGORIAS[local.categoria]?.emoji} {local.categoria}</span>
|
||||||
|
{local.subcategoria && <span style={{ fontSize:11, color:"#888", padding:"3px 8px", background:"#F5F5F5", borderRadius:20 }}>{local.subcategoria}</span>}
|
||||||
|
</div>
|
||||||
|
{local.direccion && <p style={{ margin:"0 0 6px", fontSize:13, color:"#666" }}>{local.direccion}</p>}
|
||||||
|
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"center" }}>
|
||||||
|
<span style={{ color:"#D4AF37", fontSize:18, letterSpacing:1 }}>{S.stars(local.puntuacion)}</span>
|
||||||
|
{(local.direccion || local.enlaceGoogleMaps) && (
|
||||||
|
<a href={local.enlaceGoogleMaps || `https://www.google.com/maps/search/${encodeURIComponent(local.nombre+" "+local.direccion)}`}
|
||||||
|
target="_blank" rel="noopener noreferrer"
|
||||||
|
style={{ fontSize:12, color:"#1A73E8", textDecoration:"none", background:"#E8F0FE", padding:"4px 10px", borderRadius:8, fontWeight:600 }}>
|
||||||
|
Maps ↗
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{local.descripcion && <p style={{ margin:"8px 0 0", fontSize:13, color:"#666", fontStyle:"italic" }}>"{local.descripcion}"</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pantalla: Añadir ─────────────────────────────────────────────────────────
|
||||||
|
function PantallaAnadir({ onGuardar }) {
|
||||||
|
const [form, setForm] = useState({ nombre:"", provincia:"", categoria:"", subcategoria:"", direccion:"", enlaceGoogleMaps:"", puntuacion:0, descripcion:"" });
|
||||||
|
const [modoUbic, setModoUbic] = useState("direccion");
|
||||||
|
const [geocodif, setGeocodif] = useState(null);
|
||||||
|
const [guardando, setGuardando] = useState(false);
|
||||||
|
const [ok, setOk] = useState(false);
|
||||||
|
|
||||||
|
const valido = form.nombre && form.provincia && form.categoria && form.puntuacion > 0 && (form.direccion || form.enlaceGoogleMaps);
|
||||||
|
|
||||||
|
const guardar = async () => {
|
||||||
|
if (!valido) return;
|
||||||
|
setGuardando(true);
|
||||||
|
let lat, lng;
|
||||||
|
if (modoUbic === "direccion" && form.direccion) {
|
||||||
|
const geo = await geocodificar(form.direccion);
|
||||||
|
if (geo) { lat = geo.lat; lng = geo.lng; }
|
||||||
|
} else if (form.enlaceGoogleMaps) {
|
||||||
|
const c = parsearGoogleMaps(form.enlaceGoogleMaps);
|
||||||
|
if (c) { lat = c.lat; lng = c.lng; }
|
||||||
|
}
|
||||||
|
if (!lat) {
|
||||||
|
const fb = COORDS[form.provincia] || [40.4,-3.7];
|
||||||
|
lat = fb[0] + (Math.random()-0.5)*0.04;
|
||||||
|
lng = fb[1] + (Math.random()-0.5)*0.04;
|
||||||
|
}
|
||||||
|
await onGuardar({ ...form, lat, lng, id: Date.now().toString(), fecha: new Date().toISOString() });
|
||||||
|
setOk(true);
|
||||||
|
setGuardando(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
setOk(false);
|
||||||
|
setForm({ nombre:"", provincia:"", categoria:"", subcategoria:"", direccion:"", enlaceGoogleMaps:"", puntuacion:0, descripcion:"" });
|
||||||
|
setGeocodif(null);
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ok) return (
|
||||||
|
<div style={{ display:"flex", flexDirection:"column", alignItems:"center", justifyContent:"center", minHeight:"60vh", gap:16 }}>
|
||||||
|
<div style={{ fontSize:64 }}>✅</div>
|
||||||
|
<p style={{ fontSize:18, fontWeight:700, color:"#1E8449", margin:0 }}>¡Local añadido!</p>
|
||||||
|
<p style={{ fontSize:14, color:"#888", margin:0 }}>Sincronizando con la web...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ paddingBottom:20 }}>
|
||||||
|
<div style={{ marginBottom:16 }}>
|
||||||
|
<label style={S.label}>Nombre del local *</label>
|
||||||
|
<input style={S.input} placeholder="Bar El Olivo, Clínica San José..." value={form.nombre} onChange={e => setForm(f=>({...f,nombre:e.target.value}))} />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom:16 }}>
|
||||||
|
<label style={S.label}>Provincia *</label>
|
||||||
|
<select style={S.input} value={form.provincia} onChange={e => setForm(f=>({...f,provincia:e.target.value}))}>
|
||||||
|
<option value="">Selecciona...</option>
|
||||||
|
{PROVINCIAS.map(p => <option key={p} value={p}>{p}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom:16 }}>
|
||||||
|
<label style={S.label}>Categoría *</label>
|
||||||
|
<select style={S.input} value={form.categoria} onChange={e => setForm(f=>({...f,categoria:e.target.value,subcategoria:""}))}>
|
||||||
|
<option value="">Selecciona...</option>
|
||||||
|
{CATS.map(c => <option key={c} value={c}>{CATEGORIAS[c].emoji} {c}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{form.categoria && (
|
||||||
|
<div style={{ marginBottom:16 }}>
|
||||||
|
<label style={S.label}>Tipo específico</label>
|
||||||
|
<select style={S.input} value={form.subcategoria} onChange={e => setForm(f=>({...f,subcategoria:e.target.value}))}>
|
||||||
|
<option value="">Selecciona...</option>
|
||||||
|
{CATEGORIAS[form.categoria].subcategorias.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ubicación */}
|
||||||
|
<div style={{ marginBottom:16 }}>
|
||||||
|
<label style={S.label}>Ubicación *</label>
|
||||||
|
<div style={{ display:"flex", border:"1px solid #DDD7CF", borderRadius:10, overflow:"hidden", marginBottom:8 }}>
|
||||||
|
{[{id:"direccion",label:"📍 Dirección"},{id:"enlace",label:"🔗 Google Maps"}].map(opt => (
|
||||||
|
<button key={opt.id} onClick={() => { setModoUbic(opt.id); setForm(f=>({...f,direccion:"",enlaceGoogleMaps:""})); setGeocodif(null); }}
|
||||||
|
style={{ flex:1, padding:"10px 6px", border:"none", background: modoUbic===opt.id?"#F0EBE5":"white", fontSize:13, cursor:"pointer", fontWeight: modoUbic===opt.id?700:400, color: modoUbic===opt.id?"#8B0000":"#666", fontFamily:"inherit" }}>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{modoUbic === "direccion" ? (
|
||||||
|
<input style={S.input} placeholder="Calle Mayor 5, Madrid..." value={form.direccion}
|
||||||
|
onChange={e => { setForm(f=>({...f,direccion:e.target.value})); setGeocodif(null); }} />
|
||||||
|
) : (
|
||||||
|
<input style={S.input} placeholder="Pega el enlace de Google Maps..." value={form.enlaceGoogleMaps}
|
||||||
|
onChange={e => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setForm(f=>({...f,enlaceGoogleMaps:val}));
|
||||||
|
const c = parsearGoogleMaps(val);
|
||||||
|
setGeocodif(c ? "✅ Coordenadas detectadas" : val ? "⚠️ No se detectaron coordenadas" : null);
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
{geocodif && <p style={{ fontSize:12, color:geocodif.startsWith("✅")?"#1E8449":"#784212", margin:"6px 0 0" }}>{geocodif}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Puntuación */}
|
||||||
|
<div style={{ marginBottom:16 }}>
|
||||||
|
<label style={S.label}>Puntuación *</label>
|
||||||
|
<div style={{ display:"flex", gap:8 }}>
|
||||||
|
{[1,2,3,4,5].map(n => (
|
||||||
|
<button key={n} onClick={() => setForm(f=>({...f,puntuacion:n}))}
|
||||||
|
style={{ fontSize:32, background:"none", border:"none", cursor:"pointer", color: n<=form.puntuacion?"#D4AF37":"#DDD", padding:0 }}>★</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom:24 }}>
|
||||||
|
<label style={S.label}>Descripción (opcional)</label>
|
||||||
|
<textarea style={{ ...S.input, minHeight:80, resize:"vertical" }} placeholder="Horarios, especialidades, qué lo hace especial..."
|
||||||
|
value={form.descripcion} onChange={e => setForm(f=>({...f,descripcion:e.target.value}))} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={guardar} disabled={!valido||guardando} style={{ ...S.btn(true), opacity: valido?1:0.4 }}>
|
||||||
|
{guardando ? "Enviando..." : "📨 Enviar propuesta"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pantalla: Estadísticas ───────────────────────────────────────────────────
|
||||||
|
function PantallaStats({ locales }) {
|
||||||
|
const media = locales.length ? (locales.reduce((s,l)=>s+l.puntuacion,0)/locales.length).toFixed(1) : "—";
|
||||||
|
const provincias = [...new Set(locales.map(l=>l.provincia))].length;
|
||||||
|
const porCat = CATS.map(c => ({ cat:c, n: locales.filter(l=>l.categoria===c).length })).filter(x=>x.n>0).sort((a,b)=>b.n-a.n);
|
||||||
|
const top5 = [...locales].sort((a,b)=>b.puntuacion-a.puntuacion).slice(0,5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display:"grid", gridTemplateColumns:"1fr 1fr", gap:10, marginBottom:14 }}>
|
||||||
|
{[{v:locales.length,l:"Locales"},{v:provincias,l:"Provincias"},{v:media,l:"Media ★"},{v:porCat.length,l:"Categorías"}].map(s=>(
|
||||||
|
<div key={s.l} style={{ background:"white", borderRadius:12, padding:"14px 16px", border:"1px solid #EEE8E0", textAlign:"center" }}>
|
||||||
|
<p style={{ margin:0, fontSize:11, color:"#AAA", textTransform:"uppercase", letterSpacing:"0.5px", fontWeight:600 }}>{s.l}</p>
|
||||||
|
<p style={{ margin:"6px 0 0", fontSize:28, fontWeight:700, color:"#1A1A1A" }}>{s.v}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{porCat.length > 0 && (
|
||||||
|
<div style={S.card}>
|
||||||
|
<p style={{ margin:"0 0 12px", fontWeight:700, fontSize:14, color:"#555", textTransform:"uppercase", letterSpacing:"0.4px" }}>Por categoría</p>
|
||||||
|
{porCat.map(({cat,n}) => {
|
||||||
|
const pct = Math.round((n/locales.length)*100);
|
||||||
|
return (
|
||||||
|
<div key={cat} style={{ marginBottom:10 }}>
|
||||||
|
<div style={{ display:"flex", justifyContent:"space-between", marginBottom:4 }}>
|
||||||
|
<span style={{ fontSize:14 }}>{CATEGORIAS[cat].emoji} {cat}</span>
|
||||||
|
<span style={{ fontSize:13, fontWeight:700, color:CATEGORIAS[cat].color }}>{n}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ background:"#F0EBE5", borderRadius:4, height:6, overflow:"hidden" }}>
|
||||||
|
<div style={{ height:"100%", background:CATEGORIAS[cat].color, width:pct+"%", borderRadius:4 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{top5.length > 0 && (
|
||||||
|
<div style={S.card}>
|
||||||
|
<p style={{ margin:"0 0 12px", fontWeight:700, fontSize:14, color:"#555", textTransform:"uppercase", letterSpacing:"0.4px" }}>Top valorados</p>
|
||||||
|
{top5.map((l,i) => (
|
||||||
|
<div key={l.id} style={{ display:"flex", alignItems:"center", gap:10, marginBottom:10 }}>
|
||||||
|
<span style={{ fontSize:18, fontWeight:700, color:"#D4AF37", minWidth:24 }}>#{i+1}</span>
|
||||||
|
<div style={{ flex:1 }}>
|
||||||
|
<p style={{ margin:0, fontSize:14, fontWeight:600 }}>{l.nombre}</p>
|
||||||
|
<p style={{ margin:0, fontSize:12, color:"#888" }}>{l.provincia}</p>
|
||||||
|
</div>
|
||||||
|
<span style={{ color:"#D4AF37", fontSize:14 }}>{"★".repeat(l.puntuacion)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p style={{ textAlign:"center", fontSize:12, color:"#BBB", marginTop:16 }}>
|
||||||
|
Datos sincronizados con la versión web
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── App principal ────────────────────────────────────────────────────────────
|
||||||
|
export default function AppMovil() {
|
||||||
|
const [tab, setTab] = useState("lista");
|
||||||
|
const [locales, setLocales] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busqueda, setBusqueda] = useState("");
|
||||||
|
const [filtroCat, setFiltroCat] = useState("");
|
||||||
|
|
||||||
|
const [palabrasProhibidas, setPalabrasProhibidas] = useState([]);
|
||||||
|
|
||||||
|
const cargar = useCallback(() => {}, []); // Firestore ya escucha en tiempo real
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
const unsub = suscribirLocales(
|
||||||
|
(lista) => { setLocales(lista); setLoading(false); },
|
||||||
|
() => { setLocales([]); setLoading(false); }
|
||||||
|
);
|
||||||
|
return () => unsub();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { obtenerPalabrasFiltradas().then(p => setPalabrasProhibidas(p)); }, []);
|
||||||
|
|
||||||
|
const addLocal = async (local) => {
|
||||||
|
// Comprobar filtro de palabras
|
||||||
|
const texto = [local.nombre, local.descripcion||"", local.direccion||""].join(" ");
|
||||||
|
if (contienepalabrasProhibidas(texto, palabrasProhibidas)) {
|
||||||
|
alert("Tu propuesta contiene palabras no permitidas. Por favor, revísala.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try { await enviarPropuesta(local); } catch(e) { console.error(e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const delLocal = null; // Solo el admin puede eliminar locales
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id:"lista", icon:"🗂️", label:"Lista" },
|
||||||
|
{ id:"anadir", icon:"➕", label:"Añadir" },
|
||||||
|
{ id:"stats", icon:"📊", label:"Stats" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={S.screen}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={S.header}>
|
||||||
|
<div style={{ display:"flex", alignItems:"center", gap:10 }}>
|
||||||
|
<span style={{ fontSize:26 }}>🇪🇸</span>
|
||||||
|
<div>
|
||||||
|
<h1 style={S.headerTitle}>Locales Españoles</h1>
|
||||||
|
<p style={S.headerSub}>{loading ? "Cargando..." : `${locales.length} ${locales.length===1?"local":"locales"} · sincronizado`}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={cargar} style={{ marginLeft:"auto", background:"rgba(255,255,255,0.2)", border:"none", borderRadius:8, padding:"6px 10px", color:"white", cursor:"pointer", fontSize:18 }}>↻</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contenido */}
|
||||||
|
<div style={S.body}>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign:"center", padding:"4rem 1rem", color:"#AAA" }}>
|
||||||
|
<p style={{ fontSize:32, margin:"0 0 12px" }}>⏳</p>
|
||||||
|
<p style={{ margin:0, fontSize:15 }}>Sincronizando con la web...</p>
|
||||||
|
</div>
|
||||||
|
) : tab === "lista" ? (
|
||||||
|
<PantallaLista locales={locales} onDelete={null} onBuscar={setBusqueda} busqueda={busqueda} filtroCat={filtroCat} setFiltroCat={setFiltroCat} />
|
||||||
|
) : tab === "anadir" ? (
|
||||||
|
<PantallaAnadir onGuardar={async (local) => { await addLocal(local); setTab("lista"); }} />
|
||||||
|
) : (
|
||||||
|
<PantallaStats locales={locales} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav bar inferior */}
|
||||||
|
<nav style={S.navBar}>
|
||||||
|
{TABS.map(t => (
|
||||||
|
<button key={t.id} onClick={() => setTab(t.id)} style={S.navBtn(tab===t.id)}>
|
||||||
|
<span style={S.navIcon(tab===t.id)}>{t.icon}</span>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
// Local SQLite API instead of Firebase
|
||||||
|
|
||||||
|
const API_URL = "/api";
|
||||||
|
|
||||||
|
export async function suscribirLocales(onData, onError) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/locales`);
|
||||||
|
const data = await response.json();
|
||||||
|
onData(data);
|
||||||
|
// Simulate real-time by polling every 5 seconds
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/locales`);
|
||||||
|
const newData = await res.json();
|
||||||
|
onData(newData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
} catch (err) {
|
||||||
|
onError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function suscribirPendientes(onData, onError) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/pendientes`);
|
||||||
|
const data = await response.json();
|
||||||
|
onData(data);
|
||||||
|
// Simulate real-time by polling every 5 seconds
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/pendientes`);
|
||||||
|
const newData = await res.json();
|
||||||
|
onData(newData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
} catch (err) {
|
||||||
|
onError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enviarPropuesta(local) {
|
||||||
|
const { id: _x, ...datos } = local;
|
||||||
|
const response = await fetch(`${API_URL}/propuestas`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(datos),
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function aprobarPropuesta(p) {
|
||||||
|
const response = await fetch(`${API_URL}/aprobar/${p.id}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rechazarPropuesta(id) {
|
||||||
|
const response = await fetch(`${API_URL}/rechazar/${id}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLocal(id) {
|
||||||
|
const response = await fetch(`${API_URL}/locales/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function obtenerPalabrasFiltradas() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/config/filtro_palabras`);
|
||||||
|
const data = await response.json();
|
||||||
|
return data.palabras || [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function guardarPalabrasFiltradas(palabras) {
|
||||||
|
const response = await fetch(`${API_URL}/config/filtro_palabras`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ palabras }),
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contienepalabrasProhibidas(texto, lista) {
|
||||||
|
if (!lista?.length) return false;
|
||||||
|
const norm = s => s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
|
||||||
|
const t = norm(texto);
|
||||||
|
return lista.some(p => t.includes(norm(p)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth helpers (local implementation)
|
||||||
|
let currentUser = null;
|
||||||
|
|
||||||
|
export function escucharAuth(cb) {
|
||||||
|
// Simple localStorage-based auth
|
||||||
|
const user = localStorage.getItem("user");
|
||||||
|
currentUser = user ? JSON.parse(user) : null;
|
||||||
|
cb(currentUser);
|
||||||
|
|
||||||
|
// Listen for storage changes
|
||||||
|
const handleStorageChange = () => {
|
||||||
|
const user = localStorage.getItem("user");
|
||||||
|
currentUser = user ? JSON.parse(user) : null;
|
||||||
|
cb(currentUser);
|
||||||
|
};
|
||||||
|
window.addEventListener("storage", handleStorageChange);
|
||||||
|
return () => window.removeEventListener("storage", handleStorageChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginAdmin(email, password) {
|
||||||
|
// Simple email/password check (in production use proper auth)
|
||||||
|
if (email && password) {
|
||||||
|
const user = { email, uid: email };
|
||||||
|
localStorage.setItem("user", JSON.stringify(user));
|
||||||
|
currentUser = user;
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
throw new Error("Invalid email or password");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registrarAdmin(email, password) {
|
||||||
|
return loginAdmin(email, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cerrarSesion() {
|
||||||
|
localStorage.removeItem("user");
|
||||||
|
currentUser = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// firebase.js - Re-exports local API instead of Firebase
|
||||||
|
export {
|
||||||
|
escucharAuth,
|
||||||
|
loginAdmin,
|
||||||
|
registrarAdmin,
|
||||||
|
cerrarSesion,
|
||||||
|
suscribirLocales,
|
||||||
|
deleteLocal,
|
||||||
|
enviarPropuesta,
|
||||||
|
suscribirPendientes,
|
||||||
|
aprobarPropuesta,
|
||||||
|
rechazarPropuesta,
|
||||||
|
obtenerPalabrasFiltradas,
|
||||||
|
guardarPalabrasFiltradas,
|
||||||
|
contienepalabrasProhibidas,
|
||||||
|
} from "./api.js";
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import App from "./App.jsx";
|
||||||
|
import AppMovil from "./AppMovil.jsx";
|
||||||
|
import AdminPanel from "./AdminPanel.jsx";
|
||||||
|
|
||||||
|
// Enrutador mínimo sin dependencias extra
|
||||||
|
const ruta = window.location.pathname.replace(/\/$/, "");
|
||||||
|
|
||||||
|
function Root() {
|
||||||
|
if (ruta === "/admin") return <AdminPanel />;
|
||||||
|
if (ruta === "/movil") return <AppMovil />;
|
||||||
|
return <App />;
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")).render(
|
||||||
|
<StrictMode><Root /></StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user