Prototipo YB01 #1
Generated
+1622
-1012
File diff suppressed because it is too large
Load Diff
+9
-4
@@ -2,18 +2,23 @@
|
||||
"name": "locales-espanoles",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"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": {
|
||||
"firebase": "^10.12.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^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"
|
||||
"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}`));
|
||||
+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;
|
||||
}
|
||||
+16
-112
@@ -1,112 +1,16 @@
|
||||
// firebase.js
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PASO 1 — Pega tu config de Firebase (Configuración ⚙️ → Tu app → Web)
|
||||
// PASO 2 — Activa Authentication → Sign-in method → Email/Password
|
||||
// PASO 3 — Crea Firestore Database en modo producción (región: eur3)
|
||||
// PASO 4 — Pega estas Reglas en Firestore → Reglas y publica:
|
||||
//
|
||||
// rules_version = '2';
|
||||
// service cloud.firestore {
|
||||
// match /databases/{database}/documents {
|
||||
// // Locales aprobados: solo lectura pública, escritura solo autenticados
|
||||
// match /locales/{id} {
|
||||
// allow read: if true;
|
||||
// allow write: if request.auth != null;
|
||||
// }
|
||||
// // Propuestas pendientes: cualquiera puede crear, solo admin gestiona
|
||||
// match /pendientes/{id} {
|
||||
// allow create: if true;
|
||||
// allow read, update, delete: if request.auth != null;
|
||||
// }
|
||||
// // Config (filtro de palabras): lectura pública, escritura solo admin
|
||||
// match /config/{id} {
|
||||
// allow read: if true;
|
||||
// allow write: if request.auth != null;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { initializeApp } from "firebase/app";
|
||||
import {
|
||||
getFirestore, collection, addDoc, deleteDoc,
|
||||
doc, onSnapshot, query, orderBy, getDoc, setDoc,
|
||||
} from "firebase/firestore";
|
||||
import {
|
||||
getAuth, signInWithEmailAndPassword, createUserWithEmailAndPassword,
|
||||
signOut, onAuthStateChanged,
|
||||
} from "firebase/auth";
|
||||
|
||||
// ▼▼▼ PEGA AQUÍ TU CONFIG DE FIREBASE ▼▼▼
|
||||
const firebaseConfig = {
|
||||
apiKey: "PEGA_TU_API_KEY",
|
||||
authDomain: "PEGA_TU_AUTH_DOMAIN",
|
||||
projectId: "PEGA_TU_PROJECT_ID",
|
||||
storageBucket: "PEGA_TU_STORAGE_BUCKET",
|
||||
messagingSenderId: "PEGA_TU_SENDER_ID",
|
||||
appId: "PEGA_TU_APP_ID",
|
||||
};
|
||||
// ▲▲▲ ─────────────────────────────── ▲▲▲
|
||||
|
||||
const fbApp = initializeApp(firebaseConfig);
|
||||
export const db = getFirestore(fbApp);
|
||||
export const auth = getAuth(fbApp);
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
export const escucharAuth = (cb) => onAuthStateChanged(auth, cb);
|
||||
export const loginAdmin = (e, p) => signInWithEmailAndPassword(auth, e, p);
|
||||
export const registrarAdmin = (e, p) => createUserWithEmailAndPassword(auth, e, p);
|
||||
export const cerrarSesion = () => signOut(auth);
|
||||
|
||||
// ── Locales aprobados (público, solo lectura) ─────────────────────────────────
|
||||
export function suscribirLocales(onData, onError) {
|
||||
const q = query(collection(db, "locales"), orderBy("fecha", "desc"));
|
||||
return onSnapshot(q, snap => onData(snap.docs.map(d => ({ id: d.id, ...d.data() }))), onError);
|
||||
}
|
||||
export const deleteLocal = (id) => deleteDoc(doc(db, "locales", id));
|
||||
|
||||
// ── Propuestas pendientes ─────────────────────────────────────────────────────
|
||||
/** Cualquier visitante puede enviar una propuesta */
|
||||
export async function enviarPropuesta(local) {
|
||||
const { id: _x, ...datos } = local;
|
||||
return addDoc(collection(db, "pendientes"), {
|
||||
...datos,
|
||||
estado: "pendiente",
|
||||
fechaPropuesta: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Solo el admin suscribe y gestiona pendientes */
|
||||
export function suscribirPendientes(onData, onError) {
|
||||
const q = query(collection(db, "pendientes"), orderBy("fechaPropuesta", "desc"));
|
||||
return onSnapshot(q, snap => onData(snap.docs.map(d => ({ id: d.id, ...d.data() }))), onError);
|
||||
}
|
||||
|
||||
/** Admin aprueba: mueve a /locales y borra de /pendientes */
|
||||
export async function aprobarPropuesta(p) {
|
||||
const { id, estado, fechaPropuesta, ...datos } = p;
|
||||
await addDoc(collection(db, "locales"), { ...datos, fecha: new Date().toISOString() });
|
||||
await deleteDoc(doc(db, "pendientes", id));
|
||||
}
|
||||
|
||||
/** Admin rechaza: borra de /pendientes */
|
||||
export const rechazarPropuesta = (id) => deleteDoc(doc(db, "pendientes", id));
|
||||
|
||||
// ── Filtro de palabras ────────────────────────────────────────────────────────
|
||||
export async function obtenerPalabrasFiltradas() {
|
||||
try {
|
||||
const snap = await getDoc(doc(db, "config", "filtro_palabras"));
|
||||
if (snap.exists()) return snap.data().palabras || [];
|
||||
} catch {}
|
||||
return [];
|
||||
}
|
||||
export const guardarPalabrasFiltradas = (palabras) =>
|
||||
setDoc(doc(db, "config", "filtro_palabras"), { palabras });
|
||||
|
||||
/** Devuelve true si el texto contiene alguna palabra prohibida */
|
||||
export function contienepalabrasProhibidas(texto, lista) {
|
||||
if (!lista?.length) return false;
|
||||
const norm = s => s.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
||||
const t = norm(texto);
|
||||
return lista.some(p => t.includes(norm(p)));
|
||||
}
|
||||
// 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";
|
||||
|
||||
@@ -3,4 +3,12 @@ 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