Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d48b74cbd4 | ||
|
|
b36867225c | ||
|
|
257fb9c622 | ||
|
|
28d066b5af | ||
|
|
a3fd928073 | ||
|
|
d1fec59dcb | ||
|
|
bd4282c426 | ||
|
|
1b67900d54 |
@@ -0,0 +1,54 @@
|
||||
name: deploy-branch
|
||||
|
||||
# CI/CD: cada push a cualquier rama se despliega en <rama>.localesp.es
|
||||
# Estrategia A — un entorno por rama. El registro DNS A es manual (precondición).
|
||||
# Detalles: docs/07-cicd.md
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
delete:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: "Rama a desplegar (vacío = la actual)"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.event_name != 'delete'
|
||||
runs-on: self-hosted
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build (npm ci + vite build)
|
||||
shell: bash
|
||||
run: |
|
||||
# better-sqlite3 (v12) requiere C++20; el GCC 8 del sistema (AlmaLinux 8) no lo soporta.
|
||||
# gcc-toolset-12 (GCC 12) ya está instalado en /opt/rh.
|
||||
source /opt/rh/gcc-toolset-12/enable 2>/dev/null || echo "WARN: gcc-toolset-12 no encontrado"
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Deploy branch
|
||||
env:
|
||||
BRANCH: ${{ github.event.inputs.branch || github.ref_name }}
|
||||
run: bash scripts/deploy.sh "$BRANCH"
|
||||
|
||||
# Al borrar una rama, se limpia su entorno
|
||||
teardown:
|
||||
if: github.event_name == 'delete'
|
||||
runs-on: self-hosted
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout (default branch, trae scripts/)
|
||||
uses: actions/checkout@v4
|
||||
- name: Teardown branch env
|
||||
env:
|
||||
# github.event.ref en un 'delete' es refs/heads/<rama>
|
||||
BRANCH: ${{ github.event.ref }}
|
||||
run: bash scripts/teardown.sh "${BRANCH#refs/heads/}"
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
@@ -0,0 +1,54 @@
|
||||
<!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" />
|
||||
<!-- PWA -->
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#8B0000" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="LocalESP" />
|
||||
<!-- 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,10 @@
|
||||
[build]
|
||||
command = "npm run build"
|
||||
publish = "dist"
|
||||
|
||||
# SPA: todas las rutas van al index.html (React gestiona /admin; el resto es
|
||||
# la PWA principal, instalable en móvil)
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "LocalESP · Locales Españoles",
|
||||
"short_name": "LocalESP",
|
||||
"description": "Directorio colaborativo de negocios locales por toda España",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#8B0000",
|
||||
"lang": "es",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
const CACHE_NAME = "localesp-shell-v2";
|
||||
const SHELL_ASSETS = [
|
||||
"/",
|
||||
"/manifest.webmanifest",
|
||||
"/icon-192.png",
|
||||
"/icon-512.png",
|
||||
"/icon-512-maskable.png",
|
||||
"/apple-touch-icon.png",
|
||||
"/favicon-32.png",
|
||||
"/logo-localesp.png",
|
||||
"/logo-localesp@2x.png",
|
||||
];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_ASSETS))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Network-first para la API (los datos deben estar frescos); cache-first
|
||||
// para el resto del shell estático, para que la app abra offline.
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
if (event.request.method !== "GET") return;
|
||||
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() => caches.match(event.request))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
const network = fetch(event.request)
|
||||
.then((response) => {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||||
return response;
|
||||
})
|
||||
.catch(() => cached);
|
||||
return cached || network;
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy.sh — Despliega la rama actual a <slug>.localesp.es
|
||||
# Toda la lógica vive en lib/env.sh (modelo OOP). Éste es sólo el entrypoint.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib/env.sh
|
||||
. "$SCRIPT_DIR/lib/env.sh"
|
||||
|
||||
usage() {
|
||||
echo "usage: $0 <branch>" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
main() {
|
||||
local branch root
|
||||
|
||||
branch="${1:-${GITHUB_REF_NAME:-}}"
|
||||
if [ -z "$branch" ]; then
|
||||
usage
|
||||
fi
|
||||
root="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
Logger::set_tag deploy
|
||||
Env::new env "$branch" "$root"
|
||||
Logger::info "$(Env::banner env)"
|
||||
|
||||
Env::deploy env
|
||||
|
||||
Logger::info "LISTO: $(Env::url env) ($(Env::summary env))"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,450 @@
|
||||
#!/usr/bin/env bash
|
||||
# lib/env.sh — Modelo OOP de entornos de previsualización por rama.
|
||||
#
|
||||
# Estrategia (entorno por rama):
|
||||
# /opt/localesp/<slug> + systemd unit localesp-<slug> + vhost nginx + cert Let's Encrypt.
|
||||
# Reutiliza el puerto de la unit existente (adopta entornos legacy) o asigna uno libre (>=8082).
|
||||
#
|
||||
# PRECONDICIÓN MANUAL: el registro A <slug>.localesp.es debe existir (se gestiona a mano).
|
||||
# Si no resuelve aún, la app queda servida por HTTP y se emite el cert cuando el DNS apunte aquí.
|
||||
#
|
||||
# Representación OOP en bash:
|
||||
# - Instancia = array asociativo global, referenciado por NOMBRE.
|
||||
# - Constructor = Class::new <nombre> <args> (declara el array y establece invariantes).
|
||||
# - Método = Class::method <nombre> <args>; accede al estado vía `local -n self="$1"`.
|
||||
# - Composición = Env posee los nombres de sus colaboradores (<env>__unit, __vhost, __cert).
|
||||
# - Servicios sin estado (Slug, Host, Port, Artifacts, Deps, Logger) = métodos estáticos.
|
||||
#
|
||||
# Requiere bash >= 4.3 (namerefs). Sin efectos al sourcear: sólo define funciones/constantes.
|
||||
#
|
||||
# CONTRATO (importante): el NOMBRE de instancia pasado a Env::new (p.ej. "env") es el
|
||||
# nombre de un array asociativo global. NUNCA declares en el caller una `local` con ese
|
||||
# mismo nombre: bash usa ámbito dinámico y la local sombrearía al array global, rompiendo
|
||||
# el nameref `local -n self=env`. Pasa el nombre como literal: Env::new env ...
|
||||
|
||||
DOMAIN="localesp.es"
|
||||
PORT_BASE=8082 # 8080/8081 son entornos legacy
|
||||
LOGGER_TAG="env"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logger — servicio singleton (tag configurable según entrypoint).
|
||||
# ---------------------------------------------------------------------------
|
||||
Logger::set_tag() {
|
||||
LOGGER_TAG="$1"
|
||||
}
|
||||
|
||||
Logger::info() {
|
||||
printf '\033[1;34m[%s]\033[0m %s\n' "$LOGGER_TAG" "$*"
|
||||
}
|
||||
|
||||
Logger::error() {
|
||||
printf '\033[1;31m[%s:ERROR]\033[0m %s\n' "$LOGGER_TAG" "$*" >&2
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slug — value object estático: normaliza un nombre de rama a slug.
|
||||
# ---------------------------------------------------------------------------
|
||||
Slug::from() {
|
||||
local branch="$1"
|
||||
printf '%s' "$branch" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Host — servicio estático: IP pública y resolución DNS.
|
||||
# ---------------------------------------------------------------------------
|
||||
Host::public_ip() {
|
||||
curl -s4 --max-time 5 ifconfig.me || true
|
||||
}
|
||||
|
||||
Host::resolved_ip() {
|
||||
local host="$1"
|
||||
getent hosts "$host" | awk '{print $1}' | head -1 || true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Port — servicio estático: asigna/reutiliza puerto para una unit+dest.
|
||||
# ---------------------------------------------------------------------------
|
||||
Port::acquire() {
|
||||
local unit="$1"
|
||||
local dest="$2"
|
||||
local unit_file="/etc/systemd/system/$unit.service"
|
||||
local used
|
||||
local port
|
||||
|
||||
# Adopta entorno existente: reutiliza el puerto de la unit ya instalada.
|
||||
if [ -f "$unit_file" ]; then
|
||||
port="$(grep -oE 'PORT=[0-9]+' "$unit_file" | head -1 | cut -d= -f2 || true)"
|
||||
if [ -n "$port" ]; then
|
||||
echo "$port"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
if [ -f "$dest/.port" ]; then
|
||||
cat "$dest/.port"
|
||||
return
|
||||
fi
|
||||
|
||||
used="$( { grep -rhoE 'PORT=[0-9]+' /etc/systemd/system/localesp*.service 2>/dev/null | cut -d= -f2
|
||||
grep -rhoE 'localhost:[0-9]+' /etc/nginx/conf.d/*.conf 2>/dev/null | cut -d: -f2; } | sort -un)"
|
||||
port="$PORT_BASE"
|
||||
while printf '%s\n' "$used" | grep -qx "$port"; do
|
||||
port=$((port+1))
|
||||
done
|
||||
echo "$port"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Artifacts — servicio estático: artefactos buildados.
|
||||
# ---------------------------------------------------------------------------
|
||||
Artifacts::require_built() {
|
||||
local root="$1"
|
||||
|
||||
if [ ! -d "$root/dist" ]; then
|
||||
Logger::error "falta dist/ — ¿se ejecutó el build?"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
Artifacts::sync() {
|
||||
local root="$1"
|
||||
local dest="$2"
|
||||
|
||||
mkdir -p "$dest/dist" "$dest/server"
|
||||
rsync -a --delete "$root/dist/" "$dest/dist/"
|
||||
rsync -a --delete "$root/server/" "$dest/server/"
|
||||
cp -a "$root/package.json" "$root/package-lock.json" "$dest/"
|
||||
# localesp.db se PRESERVA (datos de usuarios). No se toca aquí.
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deps — servicio estático: dependencias de producción.
|
||||
# ---------------------------------------------------------------------------
|
||||
Deps::install() {
|
||||
local root="$1"
|
||||
local dest="$2"
|
||||
|
||||
# Reutiliza los node_modules ya compilados por el workflow (better-sqlite3 v12 exige
|
||||
# C++20, GCC 12). Copiar + npm prune evita recompilar en cada despliegue.
|
||||
# Fallback a npm ci --omit=dev si se ejecuta a mano sin build previo.
|
||||
if [ -d "$root/node_modules" ]; then
|
||||
mkdir -p "$dest/node_modules"
|
||||
rsync -a --delete "$root/node_modules/" "$dest/node_modules/"
|
||||
( cd "$dest" && npm prune --omit=dev --no-audit --no-fund )
|
||||
else
|
||||
( cd "$dest" && npm ci --omit=dev --no-audit --no-fund )
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SystemdUnit — entidad: fichero .service de un entorno.
|
||||
# ---------------------------------------------------------------------------
|
||||
SystemdUnit::new() {
|
||||
local self="$1"
|
||||
local name="$2"
|
||||
local dest="$3"
|
||||
local branch="$4"
|
||||
local host="$5"
|
||||
local port="$6"
|
||||
|
||||
declare -gA "$self"
|
||||
local -n u="$self"
|
||||
u[name]="$name"
|
||||
u[dest]="$dest"
|
||||
u[branch]="$branch"
|
||||
u[host]="$host"
|
||||
u[port]="$port"
|
||||
u[file]="/etc/systemd/system/${name}.service"
|
||||
}
|
||||
|
||||
SystemdUnit::render() {
|
||||
local self="$1"
|
||||
local -n u="$self"
|
||||
|
||||
cat <<EOF
|
||||
[Unit]
|
||||
Description=LocaleSP env rama '${u[branch]}' (${u[host]})
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=${u[dest]}
|
||||
ExecStart=/usr/bin/node server/index.js
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
Environment=NODE_ENV=production
|
||||
Environment=PORT=${u[port]}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
}
|
||||
|
||||
SystemdUnit::write() {
|
||||
local self="$1"
|
||||
local -n u="$self"
|
||||
|
||||
Logger::info "escribiendo unit ${u[name]} (PORT=${u[port]})"
|
||||
SystemdUnit::render "$self" > "${u[file]}"
|
||||
echo "${u[port]}" > "${u[dest]}/.port"
|
||||
systemctl daemon-reload
|
||||
systemctl enable "${u[name]}" >/dev/null 2>&1 || true
|
||||
systemctl restart "${u[name]}"
|
||||
Logger::info "${u[name]} arrancada"
|
||||
}
|
||||
|
||||
SystemdUnit::remove() {
|
||||
local self="$1"
|
||||
local -n u="$self"
|
||||
|
||||
systemctl disable --now "${u[name]}" 2>/dev/null || true
|
||||
rm -f "${u[file]}"
|
||||
systemctl daemon-reload
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NginxVhost — entidad: vhost nginx de un entorno.
|
||||
# ---------------------------------------------------------------------------
|
||||
NginxVhost::new() {
|
||||
local self="$1"
|
||||
local slug="$2"
|
||||
local host="$3"
|
||||
local port="$4"
|
||||
|
||||
declare -gA "$self"
|
||||
local -n v="$self"
|
||||
v[slug]="$slug"
|
||||
v[host]="$host"
|
||||
v[port]="$port"
|
||||
}
|
||||
|
||||
NginxVhost::find_path() {
|
||||
local self="$1"
|
||||
local -n v="$self"
|
||||
local host_re
|
||||
host_re="$(printf '%s' "${v[host]}" | sed 's/\./\\./g')"
|
||||
grep -rlE "server_name[[:space:]]+$host_re[[:space:]]*;" /etc/nginx/conf.d/*.conf 2>/dev/null | head -1 || true
|
||||
}
|
||||
|
||||
NginxVhost::render() {
|
||||
local self="$1"
|
||||
local -n v="$self"
|
||||
|
||||
cat <<EOF
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ${v[host]};
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:${v[port]};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
NginxVhost::create() {
|
||||
local self="$1"
|
||||
local -n v="$self"
|
||||
local path="/etc/nginx/conf.d/${v[slug]}.conf"
|
||||
|
||||
Logger::info "creando vhost HTTP $path"
|
||||
NginxVhost::render "$self" > "$path"
|
||||
}
|
||||
|
||||
NginxVhost::repoint() {
|
||||
local self="$1"
|
||||
local -n v="$self"
|
||||
local path="$2"
|
||||
|
||||
Logger::info "vhost existente: $path — forzando proxy_pass -> :${v[port]}"
|
||||
sed -i -E "s|proxy_pass http://localhost:[0-9]+;|proxy_pass http://localhost:${v[port]};|g" "$path"
|
||||
}
|
||||
|
||||
NginxVhost::configure() {
|
||||
local self="$1"
|
||||
local existing
|
||||
|
||||
existing="$(NginxVhost::find_path "$self")"
|
||||
if [ -z "$existing" ]; then
|
||||
NginxVhost::create "$self"
|
||||
else
|
||||
NginxVhost::repoint "$self" "$existing"
|
||||
fi
|
||||
nginx -t 2>&1 | tail -2
|
||||
systemctl reload nginx
|
||||
Logger::info "nginx recargado"
|
||||
}
|
||||
|
||||
NginxVhost::remove() {
|
||||
local self="$1"
|
||||
local path
|
||||
|
||||
path="$(NginxVhost::find_path "$self")"
|
||||
if [ -n "$path" ]; then
|
||||
Logger::info "borrando vhost $path"
|
||||
rm -f "$path"
|
||||
nginx -t && systemctl reload nginx
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TlsCert — entidad: certificado Let's Encrypt de un entorno.
|
||||
# ---------------------------------------------------------------------------
|
||||
TlsCert::new() {
|
||||
local self="$1"
|
||||
local host="$2"
|
||||
|
||||
declare -gA "$self"
|
||||
local -n c="$self"
|
||||
c[host]="$host"
|
||||
}
|
||||
|
||||
TlsCert::exists() {
|
||||
local self="$1"
|
||||
local -n c="$self"
|
||||
[ -d "/etc/letsencrypt/live/${c[host]}" ]
|
||||
}
|
||||
|
||||
TlsCert::ensure() {
|
||||
local self="$1"
|
||||
local -n c="$self"
|
||||
local host="${c[host]}"
|
||||
local pub_ip resolved
|
||||
|
||||
if TlsCert::exists "$self"; then
|
||||
Logger::info "cert TLS ya presente para $host"
|
||||
return
|
||||
fi
|
||||
|
||||
Logger::info "comprobando DNS de $host"
|
||||
pub_ip="$(Host::public_ip)"
|
||||
resolved="$(Host::resolved_ip "$host")"
|
||||
|
||||
if [ -n "$pub_ip" ] && [ "$resolved" = "$pub_ip" ]; then
|
||||
Logger::info "emitiendo cert con certbot --nginx"
|
||||
if certbot --nginx -d "$host" -n --redirect --keep-until-expiring; then
|
||||
Logger::info "cert emitido ✓"
|
||||
else
|
||||
Logger::error "certbot falló; $host sigue en HTTP. Revisa y vuelve a lanzar el workflow."
|
||||
fi
|
||||
else
|
||||
Logger::error "DNS de $host -> '${resolved:-<sin resolver>}', esperado $pub_ip."
|
||||
Logger::error "Crea el registro A $host -> $pub_ip y, tras propagar, re-lanza el workflow"
|
||||
Logger::error "(o ejecuta: certbot --nginx -d $host). La app ya vive en http://$host"
|
||||
fi
|
||||
}
|
||||
|
||||
TlsCert::delete() {
|
||||
local self="$1"
|
||||
local -n c="$self"
|
||||
local host="${c[host]}"
|
||||
|
||||
if TlsCert::exists "$self"; then
|
||||
Logger::info "borrando cert $host"
|
||||
certbot delete --cert-name "$host" -n 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
TlsCert::protocol() {
|
||||
local self="$1"
|
||||
|
||||
if TlsCert::exists "$self"; then
|
||||
echo "https"
|
||||
else
|
||||
echo "http"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Env — aggregate root: un entorno de rama. Compone SystemdUnit, NginxVhost, TlsCert.
|
||||
# ---------------------------------------------------------------------------
|
||||
Env::new() {
|
||||
local self="$1"
|
||||
local branch="$2"
|
||||
local root="$3"
|
||||
|
||||
declare -gA "$self"
|
||||
local -n s="$self"
|
||||
|
||||
s[branch]="$branch"
|
||||
s[root]="$root"
|
||||
s[slug]="$(Slug::from "$branch")"
|
||||
if [ -z "${s[slug]}" ]; then
|
||||
Logger::error "slug vacío para la rama '$branch'"
|
||||
exit 2
|
||||
fi
|
||||
s[host]="${s[slug]}.$DOMAIN"
|
||||
s[dest]="/opt/localesp/${s[slug]}"
|
||||
s[unit_name]="localesp-${s[slug]}"
|
||||
s[port]="$(Port::acquire "${s[unit_name]}" "${s[dest]}")"
|
||||
|
||||
# Composición: crea los colaboradores y guarda sus nombres en el agregado.
|
||||
SystemdUnit::new "${self}__unit" "${s[unit_name]}" "${s[dest]}" "$branch" "${s[host]}" "${s[port]}"
|
||||
NginxVhost::new "${self}__vhost" "${s[slug]}" "${s[host]}" "${s[port]}"
|
||||
TlsCert::new "${self}__cert" "${s[host]}"
|
||||
}
|
||||
|
||||
Env::deploy() {
|
||||
local self="$1"
|
||||
local -n s="$self"
|
||||
|
||||
Artifacts::require_built "${s[root]}"
|
||||
Logger::info "sincronizando artefactos -> ${s[dest]}"
|
||||
Artifacts::sync "${s[root]}" "${s[dest]}"
|
||||
|
||||
Logger::info "instalando dependencias (prod)"
|
||||
Deps::install "${s[root]}" "${s[dest]}"
|
||||
|
||||
SystemdUnit::write "${self}__unit"
|
||||
|
||||
Logger::info "configurando nginx para ${s[host]}"
|
||||
NginxVhost::configure "${self}__vhost"
|
||||
|
||||
TlsCert::ensure "${self}__cert"
|
||||
}
|
||||
|
||||
Env::teardown() {
|
||||
local self="$1"
|
||||
local -n s="$self"
|
||||
|
||||
SystemdUnit::remove "${self}__unit"
|
||||
TlsCert::delete "${self}__cert"
|
||||
NginxVhost::remove "${self}__vhost"
|
||||
rm -rf "${s[dest]}"
|
||||
}
|
||||
|
||||
Env::url() {
|
||||
local self="$1"
|
||||
local -n s="$self"
|
||||
local proto
|
||||
|
||||
proto="$(TlsCert::protocol "${self}__cert")"
|
||||
echo "$proto://${s[host]}"
|
||||
}
|
||||
|
||||
Env::banner() {
|
||||
local self="$1"
|
||||
local -n s="$self"
|
||||
|
||||
echo "rama=${s[branch]} slug=${s[slug]} host=${s[host]} dest=${s[dest]} puerto=${s[port]}"
|
||||
}
|
||||
|
||||
Env::identity() {
|
||||
local self="$1"
|
||||
local -n s="$self"
|
||||
|
||||
echo "${s[host]} (${s[dest]}, ${s[unit_name]})"
|
||||
}
|
||||
|
||||
Env::summary() {
|
||||
local self="$1"
|
||||
local -n s="$self"
|
||||
|
||||
echo "rama=${s[branch]} unit=${s[unit_name]} puerto=${s[port]}"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# teardown.sh — Elimina el entorno de una rama (al borrar la rama o a mano).
|
||||
# Toda la lógica vive en lib/env.sh (modelo OOP). Éste es sólo el entrypoint.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib/env.sh
|
||||
. "$SCRIPT_DIR/lib/env.sh"
|
||||
|
||||
usage() {
|
||||
echo "usage: $0 <branch>" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
main() {
|
||||
local branch root
|
||||
|
||||
branch="${1:-}"
|
||||
if [ -z "$branch" ]; then
|
||||
usage
|
||||
fi
|
||||
root="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
Logger::set_tag teardown
|
||||
Env::new env "$branch" "$root"
|
||||
Logger::info "$(Env::identity env)"
|
||||
|
||||
Env::teardown env
|
||||
|
||||
Logger::info "entorno eliminado ✓"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,87 @@
|
||||
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,
|
||||
provincia TEXT,
|
||||
subcategoria TEXT,
|
||||
direccion TEXT,
|
||||
enlaceGoogleMaps TEXT,
|
||||
puntuacion INTEGER DEFAULT 0,
|
||||
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,
|
||||
provincia TEXT,
|
||||
subcategoria TEXT,
|
||||
direccion TEXT,
|
||||
enlaceGoogleMaps TEXT,
|
||||
puntuacion INTEGER DEFAULT 0,
|
||||
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 reportes (
|
||||
id TEXT PRIMARY KEY,
|
||||
localId TEXT NOT NULL,
|
||||
motivo TEXT,
|
||||
comentario TEXT,
|
||||
fecha TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
estado TEXT DEFAULT 'pendiente'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
created TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
// Migración suave: si la base de datos ya existía con el esquema antiguo
|
||||
// (sin provincia/subcategoria/direccion/enlaceGoogleMaps/puntuacion),
|
||||
// añadimos las columnas que falten sin borrar nada, porque
|
||||
// CREATE TABLE IF NOT EXISTS no modifica tablas ya creadas.
|
||||
function ensureColumn(table, column, definition) {
|
||||
const cols = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
|
||||
if (!cols.includes(column)) {
|
||||
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const table of ["locales", "pendientes"]) {
|
||||
ensureColumn(table, "provincia", "TEXT");
|
||||
ensureColumn(table, "subcategoria", "TEXT");
|
||||
ensureColumn(table, "direccion", "TEXT");
|
||||
ensureColumn(table, "enlaceGoogleMaps", "TEXT");
|
||||
ensureColumn(table, "puntuacion", "INTEGER DEFAULT 0");
|
||||
}
|
||||
// fechaConfirmacion: última vez que alguien confirmó que el local sigue activo.
|
||||
// Al aprobarse por primera vez se inicializa igual a "fecha"; luego se actualiza
|
||||
// con /api/locales/:id/confirmar cada vez que un usuario pulsa "Sigue aquí".
|
||||
ensureColumn("locales", "fechaConfirmacion", "TEXT");
|
||||
db.prepare("UPDATE locales SET fechaConfirmacion = fecha WHERE fechaConfirmacion IS NULL").run();
|
||||
|
||||
export default db;
|
||||
@@ -0,0 +1,173 @@
|
||||
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);
|
||||
});
|
||||
|
||||
// Utilidades para detectar duplicados
|
||||
function normaliza(s) {
|
||||
return (s || "").toString().toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim();
|
||||
}
|
||||
function distanciaMetros(lat1, lng1, lat2, lng2) {
|
||||
const R = 6371000;
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
// Comprueba si ya existe un local con el mismo nombre (en la misma provincia o muy
|
||||
// cerca geográficamente) entre los ya publicados y los pendientes de revisión.
|
||||
app.post("/api/check-duplicado", (req, res) => {
|
||||
const { nombre, provincia, lat, lng } = req.body;
|
||||
const nombreN = normaliza(nombre);
|
||||
if (!nombreN) return res.json({ duplicado: false, coincidencias: [] });
|
||||
|
||||
const candidatos = [
|
||||
...db.prepare("SELECT id, nombre, provincia, direccion, categoria, lat, lng FROM locales").all().map((l) => ({ ...l, estado: "publicado" })),
|
||||
...db.prepare("SELECT id, nombre, provincia, direccion, categoria, lat, lng FROM pendientes").all().map((l) => ({ ...l, estado: "pendiente" })),
|
||||
];
|
||||
|
||||
const coincidencias = candidatos.filter((l) => {
|
||||
if (normaliza(l.nombre) !== nombreN) return false;
|
||||
const mismaProvincia = provincia && normaliza(l.provincia) === normaliza(provincia);
|
||||
const cerca = lat && lng && l.lat && l.lng && distanciaMetros(lat, lng, l.lat, l.lng) < 200;
|
||||
return mismaProvincia || cerca;
|
||||
});
|
||||
|
||||
res.json({ duplicado: coincidencias.length > 0, coincidencias });
|
||||
});
|
||||
|
||||
// Enviar propuesta
|
||||
app.post("/api/propuestas", (req, res) => {
|
||||
const { nombre, descripcion, provincia, subcategoria, direccion, enlaceGoogleMaps, puntuacion, categoria, lat, lng } = req.body;
|
||||
const id = randomUUID();
|
||||
const fechaPropuesta = new Date().toISOString();
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO pendientes (id, nombre, descripcion, provincia, subcategoria, direccion, enlaceGoogleMaps, puntuacion, categoria, estado, fechaPropuesta, lat, lng)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pendiente', ?, ?, ?)
|
||||
`).run(id, nombre, descripcion, provincia, subcategoria, direccion, enlaceGoogleMaps, puntuacion ?? 0, categoria, fechaPropuesta, lat, lng);
|
||||
|
||||
res.status(201).json({ id, nombre, descripcion, provincia, subcategoria, direccion, enlaceGoogleMaps, puntuacion: puntuacion ?? 0, 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, provincia, subcategoria, direccion, enlaceGoogleMaps, puntuacion, categoria, fecha, lat, lng, fechaConfirmacion)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(localeId, propuesta.nombre, propuesta.descripcion, propuesta.provincia, propuesta.subcategoria, propuesta.direccion, propuesta.enlaceGoogleMaps, propuesta.puntuacion ?? 0, propuesta.categoria, fecha, propuesta.lat, propuesta.lng, fecha);
|
||||
|
||||
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);
|
||||
db.prepare("DELETE FROM reportes WHERE localId = ?").run(id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Confirmar que un local sigue activo (renueva fechaConfirmacion)
|
||||
app.post("/api/locales/:id/confirmar", (req, res) => {
|
||||
const { id } = req.params;
|
||||
const local = db.prepare("SELECT id FROM locales WHERE id = ?").get(id);
|
||||
if (!local) return res.status(404).json({ error: "Local no encontrado" });
|
||||
const ahora = new Date().toISOString();
|
||||
db.prepare("UPDATE locales SET fechaConfirmacion = ? WHERE id = ?").run(ahora, id);
|
||||
res.json({ ok: true, fechaConfirmacion: ahora });
|
||||
});
|
||||
|
||||
// Reportar un local (cerrado / datos incorrectos / inapropiado / otro)
|
||||
app.post("/api/locales/:id/reportar", (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { motivo, comentario } = req.body;
|
||||
const local = db.prepare("SELECT id FROM locales WHERE id = ?").get(id);
|
||||
if (!local) return res.status(404).json({ error: "Local no encontrado" });
|
||||
const reporteId = randomUUID();
|
||||
db.prepare(`
|
||||
INSERT INTO reportes (id, localId, motivo, comentario, fecha, estado)
|
||||
VALUES (?, ?, ?, ?, ?, 'pendiente')
|
||||
`).run(reporteId, id, motivo || "otro", comentario || "", new Date().toISOString());
|
||||
res.status(201).json({ ok: true, id: reporteId });
|
||||
});
|
||||
|
||||
// Reportes (admin)
|
||||
app.get("/api/reportes", (_req, res) => {
|
||||
const reportes = db.prepare(`
|
||||
SELECT r.*, l.nombre AS localNombre, l.provincia AS localProvincia, l.direccion AS localDireccion
|
||||
FROM reportes r LEFT JOIN locales l ON l.id = r.localId
|
||||
WHERE r.estado = 'pendiente'
|
||||
ORDER BY r.fecha DESC
|
||||
`).all();
|
||||
res.json(reportes);
|
||||
});
|
||||
|
||||
// Descartar un reporte (el admin revisó y no hace falta actuar)
|
||||
app.post("/api/reportes/:id/descartar", (req, res) => {
|
||||
const { id } = req.params;
|
||||
db.prepare("UPDATE reportes SET estado = 'descartado' 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,351 @@
|
||||
// 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,
|
||||
obtenerReportes, descartarReporte,
|
||||
obtenerPalabrasFiltradas, guardarPalabrasFiltradas,
|
||||
} from "./firebase.js";
|
||||
import { obtenerCategoria } from "./constants/categorias.js";
|
||||
import { enlaceGoogleMapsDesdeLocal, enlaceWazeDesdeLocal } from "./utils/geo.js";
|
||||
import StarRating from "./components/StarRating.jsx";
|
||||
import Toast from "./components/Toast.jsx";
|
||||
import useToast from "./hooks/useToast.js";
|
||||
|
||||
const CLAVE_TOAST_LOGIN = "localesp_mostrar_toast_login";
|
||||
|
||||
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);
|
||||
try { sessionStorage.setItem(CLAVE_TOAST_LOGIN, modo === "login" ? "1" : "0"); } catch { /* sessionStorage no disponible */ }
|
||||
}
|
||||
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 = obtenerCategoria(p.categoria);
|
||||
const fecha = new Date(p.fechaPropuesta).toLocaleDateString("es-ES", { day:"numeric", month:"short", hour:"2-digit", minute:"2-digit" });
|
||||
const gmUrl = p.enlaceGoogleMaps || enlaceGoogleMapsDesdeLocal(p);
|
||||
const wazeUrl = enlaceWazeDesdeLocal(p);
|
||||
return (
|
||||
<div style={{ ...S.card, borderLeft:`4px solid ${cat.color}`, marginBottom:10 }}>
|
||||
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"flex-start", flexWrap:"wrap", gap:12 }}>
|
||||
<div style={{ flex:1 }}>
|
||||
<div style={{ display:"flex", alignItems:"center", gap:8, flexWrap:"wrap", marginBottom:6 }}>
|
||||
<span style={{ fontWeight:700, fontSize:17 }}>{p.nombre}</span>
|
||||
{p.categoria && <span style={S.badge(cat.textColor, cat.bgLight)}>{cat.emoji} {p.categoria}</span>}
|
||||
{p.subcategoria && <span style={{ fontSize:12, color:"#888", background:"#F5F5F5", padding:"2px 8px", borderRadius:20 }}>{p.subcategoria}</span>}
|
||||
</div>
|
||||
<div style={{ display:"flex", flexWrap:"wrap", alignItems:"center", gap:16, fontSize:13, color:"#666" }}>
|
||||
<span>📍 {p.provincia}</span>
|
||||
{p.direccion && (gmUrl ? <a href={gmUrl} target="_blank" rel="noopener noreferrer" style={{ color:"#666", textDecoration:"underline" }}>🗺 {p.direccion}</a> : <span>🗺 {p.direccion}</span>)}
|
||||
<StarRating value={p.puntuacion} readOnly size={14} />
|
||||
<span style={{ color:"#AAA" }}>Enviado: {fecha}</span>
|
||||
</div>
|
||||
{p.descripcion && <p style={{ margin:"8px 0 0", fontSize:13, color:"#666", fontStyle:"italic", background:"#FAFAFA", padding:"8px 12px", borderRadius:8 }}>"{p.descripcion}"</p>}
|
||||
<div style={{ display:"flex", gap:10 }}>
|
||||
{gmUrl && <a href={gmUrl} target="_blank" rel="noopener noreferrer" style={{ display:"inline-block", marginTop:6, fontSize:12, color:"#1A73E8" }}>Ver en Google Maps ↗</a>}
|
||||
{wazeUrl && <a href={wazeUrl} target="_blank" rel="noopener noreferrer" style={{ display:"inline-block", marginTop:6, fontSize:12, color:"#0A9FD9" }}>Ver en Waze ↗</a>}
|
||||
</div>
|
||||
</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 [reportes, setReportes] = 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("");
|
||||
const { toast, mostrarToast } = useToast();
|
||||
|
||||
const cargarReportes = () => obtenerReportes().then(setReportes).catch(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const marca = sessionStorage.getItem(CLAVE_TOAST_LOGIN);
|
||||
if (marca !== null) {
|
||||
mostrarToast(marca === "1" ? `Sesión iniciada como ${email}` : "Cuenta de administrador creada", "exito");
|
||||
sessionStorage.removeItem(CLAVE_TOAST_LOGIN);
|
||||
}
|
||||
} catch { /* sessionStorage no disponible */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setCargando(true);
|
||||
let cancelado = false;
|
||||
let limpiarPendientes, limpiarPublicados;
|
||||
suscribirPendientes(l => { if (!cancelado) { setPendientes(l); setCargando(false); } }, () => !cancelado && setCargando(false))
|
||||
.then(fn => { if (cancelado) fn?.(); else limpiarPendientes = fn; });
|
||||
suscribirLocales(l => !cancelado && setPublicados(l), () => {})
|
||||
.then(fn => { if (cancelado) fn?.(); else limpiarPublicados = fn; });
|
||||
obtenerPalabrasFiltradas().then(p => !cancelado && setPalabras(p));
|
||||
cargarReportes();
|
||||
return () => { cancelado = true; limpiarPendientes?.(); limpiarPublicados?.(); };
|
||||
}, []);
|
||||
|
||||
const aprobar = async (p) => { setBusy(true); try { await aprobarPropuesta(p); mostrarToast(`✅ "${p.nombre}" aprobado y publicado`, "exito"); } catch(e) { mostrarToast("Error: "+e.message, "error"); } setBusy(false); };
|
||||
const rechazar = async (id) => { if (!confirm("¿Rechazar y eliminar esta propuesta?")) return; try { await rechazarPropuesta(id); mostrarToast("Propuesta rechazada", "info"); } catch(e) { mostrarToast("Error: "+e.message, "error"); } };
|
||||
const eliminar = async (id) => { if (!confirm("¿Eliminar este local publicado?")) return; try { await deleteLocal(id); cargarReportes(); mostrarToast("Local eliminado", "info"); } catch(e) { mostrarToast("Error: "+e.message, "error"); } };
|
||||
const descartar = async (id) => { try { await descartarReporte(id); setReportes(r => r.filter(x => x.id !== id)); mostrarToast("Aviso descartado", "info"); } catch(e) { mostrarToast("Error: "+e.message, "error"); } };
|
||||
|
||||
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); mostrarToast("💾 Filtro de palabras guardado", "exito"); }
|
||||
catch(e) { mostrarToast("Error: "+e.message, "error"); }
|
||||
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:"reportes", l:`Reportes${reportes.length ? ` (${reportes.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>
|
||||
{l.categoria && <span style={S.badge(obtenerCategoria(l.categoria).textColor, obtenerCategoria(l.categoria).bgLight)}>{obtenerCategoria(l.categoria).emoji} {l.categoria}</span>}
|
||||
<span style={{ fontSize:12, color:"#AAA" }}>📍 {l.provincia} · {new Date(l.fecha).toLocaleDateString("es-ES",{day:"numeric",month:"short",year:"numeric"})}</span>
|
||||
</div>
|
||||
{l.direccion && (() => {
|
||||
const url = l.enlaceGoogleMaps || enlaceGoogleMapsDesdeLocal(l);
|
||||
return url
|
||||
? <a href={url} target="_blank" rel="noopener noreferrer" style={{ margin:"3px 0 0", fontSize:13, color:"#888", textDecoration:"underline", display:"block" }}>{l.direccion}</a>
|
||||
: <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>
|
||||
))
|
||||
}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Reportes ── */}
|
||||
{tab === "reportes" && (
|
||||
reportes.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 reportes pendientes</p>
|
||||
<p style={{ margin:"6px 0 0", fontSize:13 }}>Cuando alguien reporte un local (cerrado, datos incorrectos...), aparecerá aquí.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p style={{ margin:"0 0 16px", fontSize:13, color:"#888" }}>{reportes.length} reporte{reportes.length!==1?"s":""} sin revisar.</p>
|
||||
{reportes.map(r => (
|
||||
<div key={r.id} style={{ ...S.card, borderLeft:"4px solid #C0392B", 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:16 }}>{r.localNombre || "(local ya eliminado)"}</span>
|
||||
<span style={S.badge("#C0392B", "#FDEDEC")}>{{ cerrado:"Ha cerrado", datos_incorrectos:"Datos incorrectos", inapropiado:"Contenido inapropiado", otro:"Otro motivo" }[r.motivo] || r.motivo}</span>
|
||||
</div>
|
||||
<p style={{ margin:0, fontSize:13, color:"#666" }}>📍 {r.localProvincia} {r.localDireccion ? `· ${r.localDireccion}` : ""}</p>
|
||||
{r.comentario && <p style={{ margin:"8px 0 0", fontSize:13, color:"#666", fontStyle:"italic", background:"#FAFAFA", padding:"8px 12px", borderRadius:8 }}>"{r.comentario}"</p>}
|
||||
<p style={{ margin:"6px 0 0", fontSize:11, color:"#AAA" }}>Reportado: {new Date(r.fecha).toLocaleDateString("es-ES", { day:"numeric", month:"short", hour:"2-digit", minute:"2-digit" })}</p>
|
||||
</div>
|
||||
<div style={{ display:"flex", gap:8, flexShrink:0 }}>
|
||||
{r.localId && <button onClick={() => eliminar(r.localId)} style={{ ...S.btn("#C0392B"), padding:"8px 16px", fontSize:13 }}>🗑 Eliminar local</button>}
|
||||
<button onClick={() => descartar(r.id)} style={{ ...S.btn("#EEE","#666"), padding:"8px 16px", fontSize:13 }}>Descartar aviso</button>
|
||||
</div>
|
||||
</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>
|
||||
<Toast toast={toast} />
|
||||
</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} />;
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { comprobarDuplicado, contienepalabrasProhibidas, enviarPropuesta, obtenerPalabrasFiltradas, suscribirLocales } from "./firebase.js";
|
||||
import { CATEGORIAS, COLORES_MAPA, NOMBRES_CATEGORIAS, obtenerCategoria } from "./constants/categorias.js";
|
||||
import { COORDS_PROVINCIAS, PROVINCIAS, CENTRO_ESPANA } from "./constants/provincias.js";
|
||||
import { distanciaKm as calcularDistanciaKm, enlaceGoogleMapsDesdeLocal, enlaceWazeDesdeLocal } from "./utils/geo.js";
|
||||
import { inputStyle } from "./styles/shared.js";
|
||||
import StarRating from "./components/StarRating.jsx";
|
||||
import LocalCard from "./components/LocalCard.jsx";
|
||||
import CampoUbicacion from "./components/CampoUbicacion.jsx";
|
||||
import AutocompletarLocal from "./components/AutocompletarLocal.jsx";
|
||||
import BannerMovil from "./components/BannerMovil.jsx";
|
||||
import ModalPrivacidad from "./components/ModalPrivacidad.jsx";
|
||||
import useFavoritos from "./hooks/useFavoritos.js";
|
||||
import useUbicacion from "./hooks/useUbicacion.js";
|
||||
|
||||
const FORM_VACIO = {
|
||||
nombre: "", provincia: "", categoria: "", subcategoria: "",
|
||||
direccion: "", enlaceGoogleMaps: "", lat: null, lng: null,
|
||||
puntuacion: 0, descripcion: "",
|
||||
};
|
||||
|
||||
// ── 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 [comprobandoDuplicado, setComprobandoDuplicado] = useState(false);
|
||||
const [posibleDuplicado, setPosibleDuplicado] = useState(null); // { coincidencias, lat, lng }
|
||||
const [soloFavoritos, setSoloFavoritos] = useState(false);
|
||||
const [aceptaPrivacidad, setAceptaPrivacidad] = useState(false);
|
||||
const [mostrarPrivacidad, setMostrarPrivacidad] = useState(false);
|
||||
const [localDestacado, setLocalDestacado] = useState(null); // id del local abierto vía enlace compartido
|
||||
const { favoritos, esFavorito } = useFavoritos();
|
||||
const { ubicacion, buscando: buscandoUbicacion, error: errorUbicacion, pedirUbicacion, limpiarUbicacion } = useUbicacion();
|
||||
const mapRef = useRef(null);
|
||||
const mapInstanceRef = useRef(null);
|
||||
const markersRef = useRef([]);
|
||||
|
||||
const [form, setForm] = useState(FORM_VACIO);
|
||||
|
||||
// Carga locales aprobados en tiempo real.
|
||||
// suscribirLocales es async (hace fetch antes de devolver la función de
|
||||
// limpieza), así que su valor de retorno inmediato es una Promise, no la
|
||||
// función en sí: hay que esperarla antes de poder cancelar el intervalo.
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
let cancelado = false;
|
||||
let limpiar;
|
||||
suscribirLocales(
|
||||
(lista) => { if (!cancelado) { setLocales(lista); setLoading(false); } },
|
||||
() => { if (!cancelado) { setLocales([]); setLoading(false); } },
|
||||
).then((fn) => { if (cancelado) fn?.(); else limpiar = fn; });
|
||||
return () => { cancelado = true; limpiar?.(); };
|
||||
}, []);
|
||||
|
||||
// Carga filtro de palabras desde el backend
|
||||
useEffect(() => {
|
||||
obtenerPalabrasFiltradas().then((p) => setPalabrasProhibidas(p));
|
||||
}, []);
|
||||
|
||||
// Enlace compartido (?local=ID): al cargar, resalta y desplaza hasta esa ficha
|
||||
useEffect(() => {
|
||||
const id = new URLSearchParams(window.location.search).get("local");
|
||||
if (!id || loading || locales.length === 0) return;
|
||||
setLocalDestacado(id);
|
||||
const el = document.getElementById(`local-${id}`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const timer = setTimeout(() => setLocalDestacado(null), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [loading, locales]);
|
||||
|
||||
const resetForm = () => setForm(FORM_VACIO);
|
||||
|
||||
const guardarPropuesta = async (lat, lng) => {
|
||||
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();
|
||||
setAceptaPrivacidad(false);
|
||||
setMostrarForm(false);
|
||||
setPosibleDuplicado(null);
|
||||
setTimeout(() => setEnviado(false), 5000);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const enviarLocal = async () => {
|
||||
setErrorFiltro("");
|
||||
setPosibleDuplicado(null);
|
||||
// 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, lng } = form;
|
||||
if (!lat || !lng) {
|
||||
const fallback = COORDS_PROVINCIAS[form.provincia] || CENTRO_ESPANA;
|
||||
lat = fallback[0] + (Math.random() - 0.5) * 0.04;
|
||||
lng = fallback[1] + (Math.random() - 0.5) * 0.04;
|
||||
}
|
||||
|
||||
// Comprobar si ya existe un local igual (mismo nombre + provincia, o muy cerca)
|
||||
setComprobandoDuplicado(true);
|
||||
try {
|
||||
const chequeo = await comprobarDuplicado({ nombre: form.nombre, provincia: form.provincia, lat, lng });
|
||||
setComprobandoDuplicado(false);
|
||||
if (chequeo?.duplicado) {
|
||||
setPosibleDuplicado({ coincidencias: chequeo.coincidencias, lat, lng });
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setComprobandoDuplicado(false);
|
||||
// Si falla la comprobación, se permite continuar para no bloquear el envío
|
||||
}
|
||||
|
||||
await guardarPropuesta(lat, lng);
|
||||
};
|
||||
|
||||
const confirmarPeseADuplicado = () => {
|
||||
if (!posibleDuplicado) return;
|
||||
guardarPropuesta(posibleDuplicado.lat, posibleDuplicado.lng);
|
||||
};
|
||||
|
||||
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)
|
||||
&& (!soloFavoritos || esFavorito(l.id));
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (orden === "distancia" && ubicacion) {
|
||||
const da = a.lat && a.lng ? calcularDistanciaKm(ubicacion.lat, ubicacion.lng, a.lat, a.lng) : Infinity;
|
||||
const db = b.lat && b.lng ? calcularDistanciaKm(ubicacion.lat, ubicacion.lng, b.lat, b.lng) : Infinity;
|
||||
return da - db;
|
||||
}
|
||||
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. El contenedor se mantiene siempre montado (ver más abajo,
|
||||
// solo se oculta con CSS) para que la instancia de Leaflet no se pierda al
|
||||
// cambiar de pestaña. Si ya existe, solo recalculamos su tamaño: Leaflet no
|
||||
// lo hace solo cuando el contenedor pasa de display:none a visible, y por
|
||||
// eso antes hacía falta refrescar la página para que el mapa se viera bien.
|
||||
useEffect(() => {
|
||||
if (vista !== "mapa") return;
|
||||
const timer = setTimeout(() => {
|
||||
if (!mapRef.current) return;
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.invalidateSize();
|
||||
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, filtroCategoria, filtroSubcat, filtroProvincia, busqueda, soloFavoritos, favoritos]);
|
||||
|
||||
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 = [];
|
||||
localesFiltrados.forEach((local) => {
|
||||
if (!local.lat || !local.lng) return;
|
||||
const cat = obtenerCategoria(local.categoria);
|
||||
const color = COLORES_MAPA[local.categoria] || cat.color;
|
||||
const puntuacion = Math.min(5, Math.max(0, Math.round(Number(local.puntuacion) || 0)));
|
||||
const stars = "★".repeat(puntuacion) + "☆".repeat(5 - puntuacion);
|
||||
const labelDir = local.direccion ? ` · ${local.direccion.split(",")[0]}` : "";
|
||||
const marker = L.marker([local.lat, local.lng], {
|
||||
icon: L.divIcon({ className: "", html: `<div style="background:${color};color:white;border-radius:8px 8px 8px 0;padding:5px 10px;font-size:12px;font-weight:600;white-space:nowrap;box-shadow:0 2px 8px rgba(0,0,0,0.3);border:2px solid white;max-width:220px;overflow:hidden;text-overflow:ellipsis;">${cat.emoji} ${local.nombre}${labelDir}</div>`, iconAnchor: [0, 32] }),
|
||||
}).addTo(map);
|
||||
const gmLink = local.enlaceGoogleMaps || enlaceGoogleMapsDesdeLocal(local);
|
||||
const wazeLink = enlaceWazeDesdeLocal(local);
|
||||
const direccionHtml = local.direccion
|
||||
? `<br><span style="font-size:12px;color:#444;margin-top:4px;display:block">📍 ${gmLink ? `<a href="${gmLink}" target="_blank" style="color:#444;text-decoration:underline">${local.direccion}</a>` : local.direccion}</span>`
|
||||
: "";
|
||||
const enlacesHtml = `${gmLink ? `<a href="${gmLink}" target="_blank" style="display:inline-block;margin-top:6px;margin-right:6px;font-size:12px;color:#1A73E8;font-weight:500;text-decoration:none;background:#E8F0FE;padding:3px 8px;border-radius:4px">Google Maps ↗</a>` : ""}${wazeLink ? `<a href="${wazeLink}" target="_blank" style="display:inline-block;margin-top:6px;font-size:12px;color:#0A9FD9;font-weight:500;text-decoration:none;background:#E6FAFF;padding:3px 8px;border-radius:4px">Waze ↗</a>` : ""}`;
|
||||
marker.bindPopup(`<div style="font-family:sans-serif;min-width:200px;max-width:260px"><strong style="font-size:15px">${local.nombre}</strong><br><span style="color:#666;font-size:12px">${local.provincia}</span><br><span style="background:${cat.bgLight};color:${color};font-size:12px;padding:2px 8px;border-radius:4px;display:inline-block;margin:4px 0;font-weight:500">${cat.emoji} ${local.categoria}</span>${local.subcategoria ? `<span style="font-size:11px;color:#666;margin-left:4px">${local.subcategoria}</span>` : ""}${direccionHtml}<br>${enlacesHtml}<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 tieneUbicacion = !!(form.direccion || form.enlaceGoogleMaps);
|
||||
const formValido = form.nombre && form.provincia && form.categoria && form.puntuacion > 0 && tieneUbicacion && aceptaPrivacidad;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720, margin: "0 auto", padding: "1.5rem 1rem", fontFamily: "var(--font-sans)" }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: "1.5rem", gap: 12, flexWrap: "wrap" }}>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, lineHeight: 0 }}>
|
||||
<img
|
||||
src="/logo-localesp.png"
|
||||
srcSet="/logo-localesp.png 1x, /logo-localesp@2x.png 2x"
|
||||
alt="LocalESP · Locales Españoles"
|
||||
width={220}
|
||||
height={62}
|
||||
style={{ display: "block", width: "clamp(140px, 35vw, 200px)", height: "auto" }}
|
||||
/>
|
||||
</h1>
|
||||
<p style={{ margin: "6px 0 0", fontSize: 13, color: "var(--color-text-secondary)" }}>Directorio colaborativo de negocios por toda España</p>
|
||||
</div>
|
||||
<button onClick={() => { setMostrarForm(!mostrarForm); setErrorFiltro(""); }}
|
||||
style={{ background: "#8B0000", color: "white", border: "none", borderRadius: "var(--border-radius-md)", padding: "8px 16px", fontSize: 14, fontWeight: 500, cursor: "pointer", display: "flex", alignItems: "center", gap: 6, whiteSpace: "nowrap" }}>
|
||||
<i className="ti ti-plus" aria-hidden="true"></i> Proponer local
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Confirmación enviada */}
|
||||
{enviado && (
|
||||
<div role="status" style={{ background: "#EAFAF1", border: "1px solid #A9DFBF", borderRadius: "var(--border-radius-md)", padding: "12px 16px", marginBottom: "1rem", display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{ fontSize: 20 }} aria-hidden="true">✅</span>
|
||||
<div>
|
||||
<p style={{ margin: 0, fontWeight: 600, color: "#1E8449", fontSize: 14 }}>¡Propuesta enviada!</p>
|
||||
<p style={{ margin: 0, fontSize: 13, color: "#1E8449" }}>El administrador revisará tu propuesta y la publicará si es correcta.</p>
|
||||
</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" }}
|
||||
aria-pressed={activo}>
|
||||
{c.emoji} {cat}
|
||||
{conteosCat[cat] > 0 && <span style={{ background: activo ? c.textColor : "var(--color-border-secondary)", color: activo ? "white" : "var(--color-text-secondary)", borderRadius: 10, padding: "0 6px", fontSize: 11 }}>{conteosCat[cat]}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{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 }}>
|
||||
<AutocompletarLocal form={form} setForm={setForm} />
|
||||
<div>
|
||||
<label htmlFor="campo-provincia" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Provincia *</label>
|
||||
<select id="campo-provincia" style={inputStyle} value={form.provincia} onChange={(e) => setForm((f) => ({ ...f, provincia: e.target.value }))}>
|
||||
<option value="">Selecciona provincia...</option>
|
||||
{PROVINCIAS.map((p) => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="campo-categoria" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Categoría *</label>
|
||||
<select id="campo-categoria" style={inputStyle} value={form.categoria} onChange={(e) => setForm((f) => ({ ...f, categoria: e.target.value, subcategoria: "" }))}>
|
||||
<option value="">Selecciona categoría...</option>
|
||||
{NOMBRES_CATEGORIAS.map((c) => <option key={c} value={c}>{CATEGORIAS[c].emoji} {c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="campo-subcategoria" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Tipo específico</label>
|
||||
<select id="campo-subcategoria" style={{ ...inputStyle, opacity: form.categoria ? 1 : 0.5 }} value={form.subcategoria} onChange={(e) => setForm((f) => ({ ...f, subcategoria: e.target.value }))} disabled={!form.categoria}>
|
||||
<option value="">{form.categoria ? "Selecciona tipo..." : "Elige categoría primero"}</option>
|
||||
{form.categoria && CATEGORIAS[form.categoria]?.subcategorias.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<CampoUbicacion form={form} setForm={setForm} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<label htmlFor="campo-descripcion" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Descripción / comentario</label>
|
||||
<textarea id="campo-descripcion" style={{ ...inputStyle, resize: "vertical", minHeight: 60 }} placeholder="¿Qué lo hace especial? Horarios, servicios..." value={form.descripcion} onChange={(e) => setForm((f) => ({ ...f, descripcion: e.target.value }))} />
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 6 }}>Puntuación *</span>
|
||||
<StarRating value={form.puntuacion} onChange={(v) => setForm((f) => ({ ...f, puntuacion: v }))} />
|
||||
</div>
|
||||
|
||||
<label style={{ display: "flex", alignItems: "flex-start", gap: 8, marginTop: 12, fontSize: 12, color: "var(--color-text-secondary)", cursor: "pointer" }}>
|
||||
<input type="checkbox" checked={aceptaPrivacidad} onChange={(e) => setAceptaPrivacidad(e.target.checked)} style={{ marginTop: 2 }} />
|
||||
<span>
|
||||
He leído y acepto la{" "}
|
||||
<button type="button" onClick={() => setMostrarPrivacidad(true)} style={{ background: "none", border: "none", padding: 0, color: "#8B0000", textDecoration: "underline", cursor: "pointer", fontSize: 12 }}>
|
||||
política de privacidad
|
||||
</button>
|
||||
{" "}sobre cómo se usan estos datos.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Error filtro palabras */}
|
||||
{errorFiltro && (
|
||||
<div role="alert" style={{ marginTop: 12, background: "#FDEDEC", border: "1px solid #F5B7B1", borderRadius: "var(--border-radius-md)", padding: "10px 14px", fontSize: 13, color: "#922B21", display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<i className="ti ti-ban" aria-hidden="true"></i> {errorFiltro}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aviso de posible duplicado */}
|
||||
{posibleDuplicado && (
|
||||
<div role="alert" style={{ marginTop: 12, background: "#FDF2E9", border: "1px solid #F5CBA7", borderRadius: "var(--border-radius-md)", padding: "10px 14px", fontSize: 13, color: "#784212" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: 500 }}>
|
||||
<i className="ti ti-alert-triangle" aria-hidden="true"></i> Ya existe un local parecido
|
||||
</div>
|
||||
<ul style={{ margin: "6px 0", paddingLeft: 20 }}>
|
||||
{posibleDuplicado.coincidencias.map((c) => (
|
||||
<li key={c.id}>{c.nombre} · {c.provincia}{c.direccion ? ` · ${c.direccion}` : ""} ({c.estado === "pendiente" ? "pendiente de revisión" : "ya publicado"})</li>
|
||||
))}
|
||||
</ul>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 4 }}>
|
||||
<button onClick={confirmarPeseADuplicado} disabled={saving}
|
||||
style={{ background: "#8B0000", color: "white", border: "none", borderRadius: "var(--border-radius-md)", padding: "6px 14px", fontSize: 12, fontWeight: 500, cursor: "pointer" }}>
|
||||
{saving ? "Enviando..." : "Enviar de todos modos"}
|
||||
</button>
|
||||
<button onClick={() => setPosibleDuplicado(null)} style={{ background: "none", border: "0.5px solid var(--color-border-secondary)", borderRadius: "var(--border-radius-md)", padding: "6px 14px", fontSize: 12, cursor: "pointer", color: "var(--color-text-secondary)" }}>Revisar datos</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: 8, marginTop: "1rem" }}>
|
||||
<button onClick={enviarLocal} disabled={!formValido || saving || comprobandoDuplicado}
|
||||
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" }}>
|
||||
{comprobandoDuplicado ? "Comprobando..." : saving ? "Enviando..." : "📨 Enviar propuesta"}
|
||||
</button>
|
||||
<button onClick={() => { setMostrarForm(false); setErrorFiltro(""); setPosibleDuplicado(null); }} 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 role="tablist" aria-label="Vista de locales" style={{ display: "flex", gap: 6, marginBottom: "1rem", borderBottom: "0.5px solid var(--color-border-tertiary)", paddingBottom: 8 }}>
|
||||
{[{ id: "lista", label: "Lista", icon: "ti-list" }, { id: "mapa", label: "Mapa", icon: "ti-map-2" }].map((v) => (
|
||||
<button key={v.id} role="tab" aria-selected={vista === v.id} onClick={() => setVista(v.id)}
|
||||
style={{ background: vista === v.id ? "#8B0000" : "none", color: vista === v.id ? "white" : "var(--color-text-secondary)", border: `0.5px solid ${vista === v.id ? "#8B0000" : "var(--color-border-tertiary)"}`, borderRadius: "var(--border-radius-md)", padding: "6px 14px", fontSize: 13, fontWeight: 500, cursor: "pointer", display: "flex", alignItems: "center", gap: 5 }}>
|
||||
<i className={`ti ${v.icon}`} aria-hidden="true"></i> {v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{vista === "lista" && (
|
||||
<>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr auto", gap: 8, marginBottom: 8 }}>
|
||||
<input style={inputStyle} placeholder="🔍 Buscar por nombre, dirección..." value={busqueda} onChange={(e) => setBusqueda(e.target.value)} aria-label="Buscar por nombre, dirección o categoría" />
|
||||
<select style={{ ...inputStyle, opacity: filtroCategoria ? 1 : 0.6 }} value={filtroSubcat} onChange={(e) => setFiltroSubcat(e.target.value)} disabled={!filtroCategoria} aria-label="Filtrar por tipo específico">
|
||||
<option value="">{filtroCategoria ? "Todos los tipos" : "Elige categoría arriba"}</option>
|
||||
{subcatsFiltro.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<select style={inputStyle} value={filtroProvincia} onChange={(e) => setFiltroProvincia(e.target.value)} aria-label="Filtrar por provincia">
|
||||
<option value="">Todas las provincias</option>
|
||||
{PROVINCIAS.map((p) => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
<select style={{ ...inputStyle, width: "auto" }} value={orden} onChange={(e) => setOrden(e.target.value)} aria-label="Ordenar locales">
|
||||
<option value="fecha">Reciente</option>
|
||||
<option value="puntuacion">★ Mejor</option>
|
||||
<option value="nombre">A-Z</option>
|
||||
{ubicacion && <option value="distancia">📍 Más cerca</option>}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap", marginBottom: "1rem" }}>
|
||||
{!ubicacion ? (
|
||||
<button onClick={pedirUbicacion} disabled={buscandoUbicacion}
|
||||
style={{ display: "flex", alignItems: "center", gap: 5, background: "none", border: "0.5px solid var(--color-border-secondary)", borderRadius: 20, padding: "5px 12px", fontSize: 12, color: "var(--color-text-secondary)", cursor: "pointer" }}>
|
||||
<i className="ti ti-current-location" aria-hidden="true"></i> {buscandoUbicacion ? "Localizando..." : "Cerca de mí"}
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => { limpiarUbicacion(); if (orden === "distancia") setOrden("fecha"); }}
|
||||
style={{ display: "flex", alignItems: "center", gap: 5, background: "#EAFAF1", border: "0.5px solid #A9DFBF", borderRadius: 20, padding: "5px 12px", fontSize: 12, color: "#1E8449", cursor: "pointer" }}>
|
||||
<i className="ti ti-map-pin-check" aria-hidden="true"></i> Ubicación activada ✕
|
||||
</button>
|
||||
)}
|
||||
{errorUbicacion && <span style={{ fontSize: 12, color: "#922B21" }}>{errorUbicacion}</span>}
|
||||
<button onClick={() => setSoloFavoritos((v) => !v)} aria-pressed={soloFavoritos}
|
||||
style={{ display: "flex", alignItems: "center", gap: 5, background: soloFavoritos ? "#FDEDEC" : "none", border: `0.5px solid ${soloFavoritos ? "#8B0000" : "var(--color-border-secondary)"}`, borderRadius: 20, padding: "5px 12px", fontSize: 12, color: soloFavoritos ? "#8B0000" : "var(--color-text-secondary)", cursor: "pointer" }}>
|
||||
<i className={soloFavoritos ? "ti ti-heart-filled" : "ti ti-heart"} aria-hidden="true"></i> Favoritos {favoritos.length > 0 && `(${favoritos.length})`}
|
||||
</button>
|
||||
</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 }} aria-hidden="true"></i>
|
||||
Cargando locales...
|
||||
</div>
|
||||
) : localesFiltrados.length === 0 ? (
|
||||
<div style={{ textAlign: "center", padding: "3rem", color: "var(--color-text-tertiary)" }}>
|
||||
<div style={{ fontSize: 40, marginBottom: 12 }} aria-hidden="true">🏘️</div>
|
||||
<p style={{ margin: 0, fontWeight: 500 }}>
|
||||
{locales.length === 0 ? "Todavía no hay locales publicados" : soloFavoritos ? "Aún no tienes favoritos guardados" : "No hay locales que coincidan"}
|
||||
</p>
|
||||
<p style={{ margin: "6px 0 0", fontSize: 13 }}>{soloFavoritos ? "Pulsa el corazón ♥ en un local para guardarlo aquí" : "Sé el primero en proponer uno — el administrador lo revisará y publicará"}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "grid", gap: 10 }}>
|
||||
{localesFiltrados.map((local) => (
|
||||
<div key={local.id} style={localDestacado === local.id ? { outline: "2px solid #8B0000", borderRadius: "var(--border-radius-lg)" } : undefined}>
|
||||
<LocalCard
|
||||
local={local}
|
||||
distanciaKm={ubicacion && local.lat && local.lng ? calcularDistanciaKm(ubicacion.lat, ubicacion.lng, local.lat, local.lng) : null}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* El contenedor del mapa se mantiene siempre montado (solo se oculta con
|
||||
CSS) para que Leaflet no pierda su instancia al cambiar de pestaña. */}
|
||||
<div style={{ display: vista === "mapa" ? "block" : "none", 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 }} aria-hidden="true"></span>
|
||||
{CATEGORIAS[cat].emoji} {cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{locales.length === 0 && <div style={{ textAlign: "center", padding: "1rem", background: "var(--color-background-secondary)", fontSize: 13, color: "var(--color-text-secondary)" }}>Todavía no hay locales publicados</div>}
|
||||
{locales.length > 0 && localesFiltrados.length === 0 && <div style={{ textAlign: "center", padding: "1rem", background: "var(--color-background-secondary)", fontSize: 13, color: "var(--color-text-secondary)" }}>Ningún local coincide con el filtro seleccionado</div>}
|
||||
<div ref={mapRef} style={{ height: 440, width: "100%" }} role="img" aria-label="Mapa de España con los locales publicados"></div>
|
||||
<div style={{ padding: "8px 12px", background: "var(--color-background-secondary)", borderTop: "0.5px solid var(--color-border-tertiary)", display: "flex", gap: 12, alignItems: "center" }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
|
||||
<i className="ti ti-map-pin" aria-hidden="true" style={{ marginRight: 4 }}></i>
|
||||
{localesFiltrados.length} {localesFiltrados.length === 1 ? "local marcado" : "locales marcados"}
|
||||
{localesFiltrados.length !== locales.length && ` de ${locales.length}`}
|
||||
</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
|
||||
{" · "}
|
||||
<button type="button" onClick={() => setMostrarPrivacidad(true)} style={{ background: "none", border: "none", padding: 0, fontSize: 11, color: "var(--color-text-tertiary)", textDecoration: "underline", cursor: "pointer" }}>
|
||||
Política de privacidad
|
||||
</button>
|
||||
</p>
|
||||
|
||||
{mostrarPrivacidad && <ModalPrivacidad onCerrar={() => setMostrarPrivacidad(false)} />}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// 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 comprobarDuplicado({ nombre, provincia, lat, lng }) {
|
||||
const response = await fetch(`${API_URL}/check-duplicado`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ nombre, provincia, lat, lng }),
|
||||
});
|
||||
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 confirmarLocal(id) {
|
||||
const response = await fetch(`${API_URL}/locales/${id}/confirmar`, { method: "POST" });
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function reportarLocal(id, motivo, comentario) {
|
||||
const response = await fetch(`${API_URL}/locales/${id}/reportar`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ motivo, comentario }),
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function obtenerReportes() {
|
||||
const response = await fetch(`${API_URL}/reportes`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function descartarReporte(id) {
|
||||
const response = await fetch(`${API_URL}/reportes/${id}/descartar`, { method: "POST" });
|
||||
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,103 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { buscarLugares } from "../utils/geo.js";
|
||||
import { PROVINCIAS } from "../constants/provincias.js";
|
||||
import { inputStyle } from "../styles/shared.js";
|
||||
|
||||
const normaliza = (s) => (s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim();
|
||||
|
||||
function emparejarProvincia(texto) {
|
||||
const t = normaliza(texto);
|
||||
if (!t) return "";
|
||||
return PROVINCIAS.find((p) => normaliza(p) === t || t.includes(normaliza(p))) || "";
|
||||
}
|
||||
|
||||
// Campo "Nombre del local" con autocompletar: al escribir, busca lugares (OpenStreetMap)
|
||||
// y permite seleccionar uno para rellenar automáticamente nombre, dirección, provincia y coordenadas.
|
||||
export default function AutocompletarLocal({ form, setForm }) {
|
||||
const [sugerencias, setSugerencias] = useState([]);
|
||||
const [abierto, setAbierto] = useState(false);
|
||||
const [buscando, setBuscando] = useState(false);
|
||||
const debounceRef = useRef(null);
|
||||
const cajaRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
function alClicFuera(e) {
|
||||
if (cajaRef.current && !cajaRef.current.contains(e.target)) setAbierto(false);
|
||||
}
|
||||
document.addEventListener("mousedown", alClicFuera);
|
||||
return () => document.removeEventListener("mousedown", alClicFuera);
|
||||
}, []);
|
||||
|
||||
const handleChange = (val) => {
|
||||
setForm((f) => ({ ...f, nombre: val }));
|
||||
setAbierto(true);
|
||||
clearTimeout(debounceRef.current);
|
||||
if (val.trim().length < 3) { setSugerencias([]); return; }
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setBuscando(true);
|
||||
try {
|
||||
setSugerencias(await buscarLugares(val));
|
||||
} catch {
|
||||
setSugerencias([]);
|
||||
}
|
||||
setBuscando(false);
|
||||
}, 450);
|
||||
};
|
||||
|
||||
const seleccionar = (lugar) => {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
nombre: lugar.nombre,
|
||||
direccion: lugar.direccion,
|
||||
enlaceGoogleMaps: "",
|
||||
lat: lugar.lat,
|
||||
lng: lugar.lng,
|
||||
provincia: emparejarProvincia(lugar.provincia) || f.provincia,
|
||||
}));
|
||||
setSugerencias([]);
|
||||
setAbierto(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={cajaRef} style={{ position: "relative" }}>
|
||||
<label htmlFor="campo-nombre" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>Nombre del local *</label>
|
||||
<input
|
||||
id="campo-nombre"
|
||||
style={inputStyle}
|
||||
autoComplete="off"
|
||||
placeholder="Bar El Olivo, Clínica San José..."
|
||||
value={form.nombre}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onFocus={() => sugerencias.length && setAbierto(true)}
|
||||
role="combobox"
|
||||
aria-expanded={abierto && sugerencias.length > 0}
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
{buscando && (
|
||||
<i className="ti ti-loader" aria-hidden="true" style={{ position: "absolute", right: 10, top: 32, fontSize: 14, color: "var(--color-text-tertiary)" }}></i>
|
||||
)}
|
||||
{abierto && sugerencias.length > 0 && (
|
||||
<div role="listbox" style={{
|
||||
position: "absolute", zIndex: 20, top: "100%", left: 0, right: 0, marginTop: 4,
|
||||
background: "var(--color-background-primary)", border: "0.5px solid var(--color-border-secondary)",
|
||||
borderRadius: "var(--border-radius-md)", boxShadow: "0 6px 18px rgba(0,0,0,0.15)",
|
||||
maxHeight: 240, overflowY: "auto",
|
||||
}}>
|
||||
{sugerencias.map((s) => (
|
||||
<button key={s.id} type="button" role="option" onClick={() => seleccionar(s)}
|
||||
style={{
|
||||
display: "block", width: "100%", textAlign: "left", background: "none", border: "none",
|
||||
borderBottom: "0.5px solid var(--color-border-tertiary)", padding: "8px 10px", cursor: "pointer",
|
||||
}}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--color-text-primary)" }}>{s.nombre}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--color-text-secondary)", marginTop: 2 }}>{s.direccion}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: "4px 0 0" }}>
|
||||
Escribe el nombre y elige una opción para rellenar dirección y ubicación automáticamente.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import usePwaInstall from "../hooks/usePwaInstall.js";
|
||||
|
||||
const CLAVE_VISITAS = "localesp_visitas";
|
||||
const CLAVE_CERRADO = "localesp_banner_cerrado_hasta";
|
||||
const VISITAS_MINIMAS = 2;
|
||||
const DIAS_OCULTO_TRAS_CERRAR = 14;
|
||||
|
||||
function registrarVisitaYContar() {
|
||||
try {
|
||||
const actual = parseInt(localStorage.getItem(CLAVE_VISITAS) || "0", 10) + 1;
|
||||
localStorage.setItem(CLAVE_VISITAS, String(actual));
|
||||
return actual;
|
||||
} catch {
|
||||
return VISITAS_MINIMAS; // si localStorage falla, no bloqueamos el banner
|
||||
}
|
||||
}
|
||||
|
||||
function estaCerradoRecientemente() {
|
||||
try {
|
||||
const hasta = parseInt(localStorage.getItem(CLAVE_CERRADO) || "0", 10);
|
||||
return Date.now() < hasta;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Banner flotante para instalar/abrir la PWA. Usa el service worker +
|
||||
// manifest.webmanifest ya registrados en main.jsx/index.html: si el
|
||||
// navegador soporta la instalación nativa (evento beforeinstallprompt),
|
||||
// el botón la dispara directamente. Si la app ya se está ejecutando en
|
||||
// modo standalone (o el navegador confirma que está instalada), el banner
|
||||
// se oculta porque ya no aporta nada mostrarlo.
|
||||
//
|
||||
// Para no ser intrusivo, solo se muestra a partir de la 2ª visita del
|
||||
// usuario (contador en localStorage) y, si lo cierra, no vuelve a
|
||||
// aparecer durante 14 días.
|
||||
export default function BannerMovil() {
|
||||
const [cerrado, setCerrado] = useState(estaCerradoRecientemente);
|
||||
const [visitasSuficientes, setVisitasSuficientes] = useState(false);
|
||||
const { isInstalled, canPromptInstall, promptInstall, isIos } = usePwaInstall();
|
||||
const [instalando, setInstalando] = useState(false);
|
||||
const [resultado, setResultado] = useState(null); // "accepted" | "dismissed" | null
|
||||
|
||||
useEffect(() => {
|
||||
setVisitasSuficientes(registrarVisitaYContar() >= VISITAS_MINIMAS);
|
||||
}, []);
|
||||
|
||||
if (cerrado || isInstalled || !visitasSuficientes) return null;
|
||||
|
||||
const handleCerrar = () => {
|
||||
try {
|
||||
localStorage.setItem(CLAVE_CERRADO, String(Date.now() + DIAS_OCULTO_TRAS_CERRAR * 24 * 60 * 60 * 1000));
|
||||
} catch { /* localStorage no disponible: se cierra solo para esta sesión */ }
|
||||
setCerrado(true);
|
||||
};
|
||||
|
||||
const handleInstalar = async () => {
|
||||
setInstalando(true);
|
||||
const outcome = await promptInstall();
|
||||
setResultado(outcome);
|
||||
setInstalando(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: "fixed", bottom: 20, right: 20, zIndex: 999, background: "white", border: "1px solid #E5DDD5", borderRadius: 14, padding: "14px 18px", boxShadow: "0 6px 24px rgba(0,0,0,0.10)", maxWidth: 280, fontFamily: "var(--font-sans)" }}>
|
||||
<button onClick={handleCerrar} aria-label="Cerrar aviso de instalación"
|
||||
style={{ position: "absolute", top: 8, right: 10, background: "none", border: "none", cursor: "pointer", color: "#CCC", fontSize: 16, padding: 0 }}>✕</button>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 10 }}>
|
||||
<img src="/icon-192.png" alt="" width={30} height={30} style={{ borderRadius: 7, flexShrink: 0 }} />
|
||||
<div>
|
||||
<p style={{ margin: 0, fontWeight: 700, fontSize: 14 }}>App para móvil</p>
|
||||
<p style={{ margin: 0, fontSize: 12, color: "#888" }}>Sincronizada en tiempo real</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canPromptInstall && (
|
||||
<button onClick={handleInstalar} disabled={instalando}
|
||||
style={{ display: "block", width: "100%", textAlign: "center", padding: "9px 0", background: "#8B0000", color: "white", border: "none", borderRadius: 8, fontSize: 13, fontWeight: 700, cursor: instalando ? "default" : "pointer", opacity: instalando ? 0.7 : 1 }}>
|
||||
{instalando ? "Abriendo instalación..." : "📲 Instalar app"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!canPromptInstall && isIos && (
|
||||
<p style={{ margin: 0, fontSize: 12, color: "var(--color-text-secondary, #666)", lineHeight: 1.5 }}>
|
||||
Pulsa <i className="ti ti-share-2" aria-hidden="true"></i> <strong>Compartir</strong> y luego <strong>"Añadir a pantalla de inicio"</strong> para instalarla.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!canPromptInstall && !isIos && (
|
||||
<p style={{ margin: 0, fontSize: 12, color: "var(--color-text-secondary, #666)", lineHeight: 1.5 }}>
|
||||
Instálala desde el menú de tu navegador ("Instalar app" o "Añadir a pantalla de inicio").
|
||||
</p>
|
||||
)}
|
||||
|
||||
{resultado === "dismissed" && (
|
||||
<p style={{ margin: "8px 0 0", fontSize: 11, color: "#888" }}>Puedes instalarla más tarde cuando quieras.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { geocodificarDireccion, parsearEnlaceGoogleMaps } from "../utils/geo.js";
|
||||
|
||||
const inputStyle = {
|
||||
width: "100%", boxSizing: "border-box", padding: "8px 12px",
|
||||
borderRadius: "var(--border-radius-md)", border: "0.5px solid var(--color-border-secondary)",
|
||||
background: "var(--color-background-primary)", color: "var(--color-text-primary)", fontSize: 14, outline: "none",
|
||||
};
|
||||
|
||||
export default function CampoUbicacion({ form, setForm }) {
|
||||
const [modoInput, setModoInput] = useState("direccion");
|
||||
const [geocodificando, setGeocodificando] = useState(false);
|
||||
const [resultadoGeo, setResultadoGeo] = useState(null);
|
||||
const debounceRef = useRef(null);
|
||||
|
||||
const handleDireccionChange = (val) => {
|
||||
setForm((f) => ({ ...f, direccion: val, lat: null, lng: null, enlaceGoogleMaps: "" }));
|
||||
setResultadoGeo(null);
|
||||
clearTimeout(debounceRef.current);
|
||||
if (val.trim().length < 8) return;
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setGeocodificando(true);
|
||||
try {
|
||||
const geo = await geocodificarDireccion(val);
|
||||
if (geo) {
|
||||
setForm((f) => ({ ...f, lat: geo.lat, lng: geo.lng }));
|
||||
setResultadoGeo({ ok: true, texto: geo.displayName });
|
||||
} else {
|
||||
setResultadoGeo({ ok: false, texto: "No se encontró la dirección. Se usará el centro de la provincia." });
|
||||
}
|
||||
} catch {
|
||||
setResultadoGeo({ ok: false, texto: "Error al geocodificar. Se usará el centro de la provincia." });
|
||||
}
|
||||
setGeocodificando(false);
|
||||
}, 900);
|
||||
};
|
||||
|
||||
const handleEnlaceChange = (val) => {
|
||||
setForm((f) => ({ ...f, enlaceGoogleMaps: val, lat: null, lng: null, direccion: "" }));
|
||||
setResultadoGeo(null);
|
||||
if (!val.trim()) return;
|
||||
const coords = parsearEnlaceGoogleMaps(val);
|
||||
if (coords) {
|
||||
setForm((f) => ({ ...f, lat: coords.lat, lng: coords.lng }));
|
||||
setResultadoGeo({ ok: true, texto: `Coordenadas detectadas: ${coords.lat.toFixed(5)}, ${coords.lng.toFixed(5)}` });
|
||||
} else {
|
||||
setResultadoGeo({ ok: false, texto: "No se pudieron extraer coordenadas. Copia la URL completa desde Google Maps." });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ gridColumn: "1 / -1", marginTop: 4 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 6 }}>
|
||||
<label style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
|
||||
Ubicación <span style={{ color: "#8B0000" }}>*</span>
|
||||
</label>
|
||||
<div style={{ display: "flex", border: "0.5px solid var(--color-border-secondary)", borderRadius: "var(--border-radius-md)", overflow: "hidden" }}>
|
||||
{[{ id: "direccion", label: "📍 Dirección" }, { id: "enlace", label: "🔗 Google Maps" }].map((opt) => (
|
||||
<button key={opt.id} type="button"
|
||||
onClick={() => { setModoInput(opt.id); setResultadoGeo(null); setForm((f) => ({ ...f, lat: null, lng: null, direccion: "", enlaceGoogleMaps: "" })); }}
|
||||
style={{ background: modoInput === opt.id ? "var(--color-background-secondary)" : "none", border: "none", padding: "4px 10px", fontSize: 11, cursor: "pointer", fontWeight: modoInput === opt.id ? 500 : 400, color: modoInput === opt.id ? "var(--color-text-primary)" : "var(--color-text-secondary)" }}>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modoInput === "direccion" ? (
|
||||
<input style={inputStyle} value={form.direccion || ""} placeholder="Ej: Calle Mayor 5, Alcalá de Henares, Madrid"
|
||||
onChange={(e) => handleDireccionChange(e.target.value)} />
|
||||
) : (
|
||||
<input style={inputStyle} value={form.enlaceGoogleMaps || ""} placeholder="Pega aquí el enlace de Google Maps..."
|
||||
onChange={(e) => handleEnlaceChange(e.target.value)} />
|
||||
)}
|
||||
|
||||
{geocodificando && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 6, fontSize: 12, color: "var(--color-text-secondary)" }}>
|
||||
<i className="ti ti-loader" aria-hidden="true" style={{ fontSize: 14 }}></i> Buscando dirección...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resultadoGeo && !geocodificando && (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "flex-start", gap: 6, marginTop: 6, fontSize: 12,
|
||||
color: resultadoGeo.ok ? "#1E8449" : "#784212",
|
||||
background: resultadoGeo.ok ? "#EAFAF1" : "#FDF2E9",
|
||||
padding: "6px 10px", borderRadius: "var(--border-radius-md)",
|
||||
}}>
|
||||
<i className={`ti ${resultadoGeo.ok ? "ti-circle-check" : "ti-alert-triangle"}`} aria-hidden="true" style={{ fontSize: 14, flexShrink: 0, marginTop: 1 }}></i>
|
||||
<span style={{ lineHeight: 1.4 }}>{resultadoGeo.texto}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { obtenerCategoria } from "../constants/categorias.js";
|
||||
|
||||
export default function CategoriaBadge({ categoria, subcategoria }) {
|
||||
if (!categoria) return null;
|
||||
const cat = obtenerCategoria(categoria);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>
|
||||
<span style={{ background: cat.bgLight, color: cat.textColor, fontSize: 12, padding: "3px 10px", borderRadius: "var(--border-radius-md)", fontWeight: 500 }}>
|
||||
{cat.emoji} {categoria}
|
||||
</span>
|
||||
{subcategoria && (
|
||||
<span style={{ background: "var(--color-background-secondary)", color: "var(--color-text-secondary)", fontSize: 12, padding: "3px 10px", borderRadius: "var(--border-radius-md)" }}>
|
||||
{subcategoria}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useState } from "react";
|
||||
import CategoriaBadge from "./CategoriaBadge.jsx";
|
||||
import StarRating from "./StarRating.jsx";
|
||||
import ReportarLocal from "./ReportarLocal.jsx";
|
||||
import Toast from "./Toast.jsx";
|
||||
import useFavoritos from "../hooks/useFavoritos.js";
|
||||
import useToast from "../hooks/useToast.js";
|
||||
import { confirmarLocal } from "../firebase.js";
|
||||
import { enlaceGoogleMapsDesdeLocal, enlaceWazeDesdeLocal, formatearDistancia } from "../utils/geo.js";
|
||||
|
||||
const MESES_PARA_CADUCAR = 6;
|
||||
|
||||
function formatearFecha(fecha) {
|
||||
if (!fecha) return null;
|
||||
const date = new Date(fecha);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date.toLocaleDateString("es-ES", { day: "numeric", month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
function mesesDesde(fecha) {
|
||||
if (!fecha) return null;
|
||||
const date = new Date(fecha);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return (Date.now() - date.getTime()) / (1000 * 60 * 60 * 24 * 30);
|
||||
}
|
||||
|
||||
export default function LocalCard({ local, distanciaKm }) {
|
||||
const { esFavorito, alternarFavorito } = useFavoritos();
|
||||
const { toast, mostrarToast } = useToast();
|
||||
const [confirmando, setConfirmando] = useState(false);
|
||||
const [confirmado, setConfirmado] = useState(false);
|
||||
const [fechaConfirmacion, setFechaConfirmacion] = useState(local.fechaConfirmacion || local.fecha);
|
||||
|
||||
const gmUrl = local.enlaceGoogleMaps || enlaceGoogleMapsDesdeLocal(local);
|
||||
const wazeUrl = enlaceWazeDesdeLocal(local);
|
||||
const fechaFormateada = formatearFecha(local.fecha);
|
||||
const meses = mesesDesde(fechaConfirmacion);
|
||||
const sinConfirmarReciente = meses != null && meses >= MESES_PARA_CADUCAR;
|
||||
const favorito = esFavorito(local.id);
|
||||
|
||||
const handleConfirmar = async () => {
|
||||
setConfirmando(true);
|
||||
try {
|
||||
const res = await confirmarLocal(local.id);
|
||||
setFechaConfirmacion(res?.fechaConfirmacion || new Date().toISOString());
|
||||
setConfirmado(true);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
setConfirmando(false);
|
||||
};
|
||||
|
||||
const handleAlternarFavorito = () => {
|
||||
const nuevoEstado = !favorito;
|
||||
alternarFavorito(local.id);
|
||||
mostrarToast(nuevoEstado ? "❤️ Añadido a favoritos" : "Quitado de favoritos", nuevoEstado ? "exito" : "info");
|
||||
};
|
||||
|
||||
const compartir = async () => {
|
||||
const url = `${window.location.origin}${window.location.pathname}?local=${local.id}`;
|
||||
const texto = `${local.nombre}${local.provincia ? ` · ${local.provincia}` : ""}`;
|
||||
if (navigator.share) {
|
||||
try { await navigator.share({ title: local.nombre, text: texto, url }); } catch { /* usuario canceló */ }
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
window.open(`https://wa.me/?text=${encodeURIComponent(`${texto} ${url}`)}`, "_blank", "noopener,noreferrer");
|
||||
} catch {
|
||||
window.open(`https://wa.me/?text=${encodeURIComponent(`${texto} ${url}`)}`, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`local-${local.id}`}
|
||||
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", gap: 8 }}>
|
||||
<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}
|
||||
{distanciaKm != null && <span style={{ color: "var(--color-text-tertiary)" }}> · {formatearDistancia(distanciaKm)}</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
|
||||
<button type="button" onClick={compartir} title="Compartir"
|
||||
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 16, color: "var(--color-text-tertiary)", padding: 4 }}>
|
||||
<i className="ti ti-share-2" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" onClick={handleAlternarFavorito} title={favorito ? "Quitar de favoritos" : "Guardar en favoritos"}
|
||||
aria-pressed={favorito}
|
||||
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 16, color: favorito ? "#8B0000" : "var(--color-text-tertiary)", padding: 4 }}>
|
||||
<i className={favorito ? "ti ti-heart-filled" : "ti ti-heart"} aria-hidden="true"></i>
|
||||
</button>
|
||||
</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>
|
||||
{gmUrl ? (
|
||||
<a href={gmUrl} target="_blank" rel="noopener noreferrer" title="Abrir dirección en Google Maps"
|
||||
style={{ fontSize: 13, color: "var(--color-text-secondary)", textDecoration: "underline", textDecorationStyle: "dotted", textUnderlineOffset: 2 }}>
|
||||
{local.direccion}
|
||||
</a>
|
||||
) : (
|
||||
<span style={{ fontSize: 13, color: "var(--color-text-secondary)" }}>{local.direccion}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(gmUrl || wazeUrl) && (
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{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>
|
||||
Google Maps
|
||||
</a>
|
||||
)}
|
||||
{wazeUrl && (
|
||||
<a href={wazeUrl} target="_blank" rel="noopener noreferrer"
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 5, fontSize: 12, color: "#33CCFF", textDecoration: "none", width: "fit-content", background: "#E6FAFF", padding: "4px 10px", borderRadius: "var(--border-radius-md)", fontWeight: 500 }}>
|
||||
<i className="ti ti-brand-waze" aria-hidden="true" style={{ fontSize: 14 }}></i>
|
||||
Waze
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StarRating value={local.puntuacion} readOnly />
|
||||
|
||||
{local.descripcion && (
|
||||
<p style={{ fontSize: 13, color: "var(--color-text-secondary)", margin: 0, fontStyle: "italic" }}>"{local.descripcion}"</p>
|
||||
)}
|
||||
|
||||
{sinConfirmarReciente && !confirmado && (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, flexWrap: "wrap", background: "#FDF2E9", border: "1px solid #F5CBA7", borderRadius: "var(--border-radius-md)", padding: "6px 10px" }}>
|
||||
<span style={{ fontSize: 12, color: "#784212", display: "flex", alignItems: "center", gap: 5 }}>
|
||||
<i className="ti ti-clock-exclamation" aria-hidden="true"></i> Sin confirmar recientemente
|
||||
</span>
|
||||
<button type="button" onClick={handleConfirmar} disabled={confirmando}
|
||||
style={{ fontSize: 12, background: "#1E8449", color: "white", border: "none", borderRadius: "var(--border-radius-md)", padding: "4px 10px", cursor: "pointer", fontWeight: 500 }}>
|
||||
{confirmando ? "..." : "Sigue aquí ✅"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{confirmado && (
|
||||
<p style={{ fontSize: 12, color: "#1E8449", margin: 0 }}>✅ Gracias por confirmar que sigue activo.</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||
{fechaFormateada && (
|
||||
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: 0 }}>Publicado {fechaFormateada}</p>
|
||||
)}
|
||||
<ReportarLocal localId={local.id} />
|
||||
</div>
|
||||
|
||||
<Toast toast={toast} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Modal con la política de privacidad. Contenido genérico orientativo: como se
|
||||
// guardan datos de negocios (algunos de terceros, no del propio usuario) conviene
|
||||
// que un profesional lo revise antes de publicar la app.
|
||||
export default function ModalPrivacidad({ onCerrar }) {
|
||||
return (
|
||||
<div
|
||||
role="dialog" aria-modal="true" aria-label="Política de privacidad"
|
||||
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", zIndex: 1000, display: "flex", alignItems: "center", justifyContent: "center", padding: 16 }}
|
||||
onClick={onCerrar}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ background: "var(--color-background-primary)", borderRadius: "var(--border-radius-lg)", maxWidth: 560, width: "100%", maxHeight: "85vh", overflowY: "auto", padding: "1.5rem", fontFamily: "var(--font-sans)" }}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 12 }}>
|
||||
<p style={{ margin: 0, fontWeight: 600, fontSize: 17 }}>Privacidad y condiciones</p>
|
||||
<button onClick={onCerrar} aria-label="Cerrar" style={{ background: "none", border: "none", cursor: "pointer", fontSize: 18, color: "var(--color-text-tertiary)" }}>✕</button>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 13, lineHeight: 1.6, color: "var(--color-text-secondary)", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<p><strong>Qué datos guardamos.</strong> Cuando propones un local guardamos el nombre del negocio, su dirección o enlace de Google Maps, categoría, una descripción opcional y una puntuación. Estos datos suelen referirse a un negocio (a menudo de terceros), no a datos personales tuyos.</p>
|
||||
<p><strong>Revisión previa.</strong> Toda propuesta pasa por un administrador antes de publicarse, y puede ser rechazada si incumple las normas (contenido inapropiado, información falsa, etc.).</p>
|
||||
<p><strong>Ubicación.</strong> Si usas "cerca de mí", tu posición se usa solo en tu dispositivo para calcular distancias; no se envía a nuestro servidor ni se guarda.</p>
|
||||
<p><strong>Datos públicos de negocios.</strong> Los datos de los negocios listados (nombre, dirección) son públicos y equivalentes a los que aparecen en Google Maps u otros directorios similares. Cualquiera puede solicitar la corrección o eliminación de una ficha usando el botón "Reportar" en cada local.</p>
|
||||
<p><strong>Favoritos.</strong> Tu lista de favoritos se guarda únicamente en tu propio dispositivo (localStorage), no en nuestros servidores.</p>
|
||||
<p style={{ fontSize: 12, color: "var(--color-text-tertiary)" }}>Este texto es orientativo y no sustituye asesoría legal. Antes de publicar la app conviene adaptarlo con un profesional, especialmente en lo referente a datos de terceros (dueños de negocios) y RGPD.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from "react";
|
||||
import { reportarLocal } from "../firebase.js";
|
||||
|
||||
const MOTIVOS = [
|
||||
{ id: "cerrado", label: "Ha cerrado / ya no existe" },
|
||||
{ id: "datos_incorrectos", label: "Los datos son incorrectos" },
|
||||
{ id: "inapropiado", label: "Contenido inapropiado" },
|
||||
{ id: "otro", label: "Otro motivo" },
|
||||
];
|
||||
|
||||
export default function ReportarLocal({ localId }) {
|
||||
const [abierto, setAbierto] = useState(false);
|
||||
const [motivo, setMotivo] = useState("cerrado");
|
||||
const [comentario, setComentario] = useState("");
|
||||
const [enviando, setEnviando] = useState(false);
|
||||
const [enviado, setEnviado] = useState(false);
|
||||
|
||||
if (enviado) {
|
||||
return <p style={{ fontSize: 12, color: "#1E8449", margin: 0 }}>✅ Gracias, hemos recibido tu aviso.</p>;
|
||||
}
|
||||
|
||||
if (!abierto) {
|
||||
return (
|
||||
<button type="button" onClick={() => setAbierto(true)}
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 4, background: "none", border: "none", padding: 0, fontSize: 12, color: "var(--color-text-tertiary)", cursor: "pointer", width: "fit-content" }}>
|
||||
<i className="ti ti-flag" aria-hidden="true"></i> Reportar
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const enviar = async () => {
|
||||
setEnviando(true);
|
||||
try {
|
||||
await reportarLocal(localId, motivo, comentario);
|
||||
setEnviado(true);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
setEnviando(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ background: "var(--color-background-secondary)", borderRadius: "var(--border-radius-md)", padding: "8px 10px", display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<p style={{ margin: 0, fontSize: 12, fontWeight: 500 }}>¿Qué está mal con este local?</p>
|
||||
<select value={motivo} onChange={(e) => setMotivo(e.target.value)}
|
||||
style={{ fontSize: 12, padding: "5px 8px", borderRadius: "var(--border-radius-md)", border: "0.5px solid var(--color-border-secondary)", background: "var(--color-background-primary)", color: "var(--color-text-primary)" }}>
|
||||
{MOTIVOS.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||
</select>
|
||||
<textarea value={comentario} onChange={(e) => setComentario(e.target.value)} placeholder="Comentario opcional..."
|
||||
style={{ fontSize: 12, padding: "5px 8px", borderRadius: "var(--border-radius-md)", border: "0.5px solid var(--color-border-secondary)", background: "var(--color-background-primary)", color: "var(--color-text-primary)", resize: "vertical", minHeight: 40 }} />
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<button type="button" onClick={enviar} disabled={enviando}
|
||||
style={{ fontSize: 12, background: "#8B0000", color: "white", border: "none", borderRadius: "var(--border-radius-md)", padding: "5px 12px", cursor: "pointer" }}>
|
||||
{enviando ? "Enviando..." : "Enviar aviso"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setAbierto(false)}
|
||||
style={{ fontSize: 12, background: "none", border: "none", color: "var(--color-text-tertiary)", cursor: "pointer" }}>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from "react";
|
||||
|
||||
const ESTRELLAS = [1, 2, 3, 4, 5];
|
||||
|
||||
/**
|
||||
* Muestra siempre 5 estrellas (usando el font icon de Tabler ya cargado en
|
||||
* index.html, para no depender de una fuente/librería extra). Si no hay
|
||||
* valoración (0, null, undefined o un valor no numérico) se muestran las
|
||||
* 5 estrellas vacías en lugar de no renderizar nada.
|
||||
*
|
||||
* - Modo lectura (readOnly, por defecto): <div role="img"> con aria-label
|
||||
* describiendo la puntuación, para lectores de pantalla.
|
||||
* - Modo edición (onChange): botones reales con role="radio" para que sea
|
||||
* utilizable con teclado y tenga foco visible.
|
||||
*/
|
||||
export default function StarRating({ value = 0, onChange, readOnly = false, size = 20 }) {
|
||||
const [valorHover, setValorHover] = useState(0);
|
||||
const esInteractivo = !readOnly && typeof onChange === "function";
|
||||
|
||||
const valorSeguro = Math.min(5, Math.max(0, Math.round(Number(value)) || 0));
|
||||
const valorMostrado = esInteractivo && valorHover ? valorHover : valorSeguro;
|
||||
|
||||
const contenedorProps = esInteractivo
|
||||
? { role: "radiogroup", "aria-label": "Selecciona una puntuación de 1 a 5 estrellas" }
|
||||
: { role: "img", "aria-label": `Valoración: ${valorSeguro} de 5 estrellas` };
|
||||
|
||||
return (
|
||||
<div
|
||||
{...contenedorProps}
|
||||
style={{ display: "inline-flex", gap: 2 }}
|
||||
onMouseLeave={() => esInteractivo && setValorHover(0)}
|
||||
>
|
||||
{ESTRELLAS.map((estrella) => {
|
||||
const rellena = estrella <= valorMostrado;
|
||||
const iconStyle = {
|
||||
fontSize: size,
|
||||
color: rellena ? "#D4AF37" : "var(--color-border-secondary)",
|
||||
transition: "color 0.15s",
|
||||
lineHeight: 0,
|
||||
};
|
||||
|
||||
if (!esInteractivo) {
|
||||
return (
|
||||
<i key={estrella} className={`ti ${rellena ? "ti-star-filled" : "ti-star"}`} style={iconStyle} aria-hidden="true" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={estrella}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={valorSeguro === estrella}
|
||||
aria-label={`${estrella} estrella${estrella === 1 ? "" : "s"}`}
|
||||
onClick={() => onChange(estrella)}
|
||||
onMouseEnter={() => setValorHover(estrella)}
|
||||
onFocus={() => setValorHover(estrella)}
|
||||
onBlur={() => setValorHover(0)}
|
||||
style={{ background: "none", border: "none", padding: 2, margin: 0, cursor: "pointer", lineHeight: 0 }}
|
||||
>
|
||||
<i className={`ti ${rellena ? "ti-star-filled" : "ti-star"}`} style={iconStyle} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
const ESTILOS = {
|
||||
info: { bg: "#2C3E50", color: "white", icono: "ti-info-circle" },
|
||||
exito: { bg: "#1E8449", color: "white", icono: "ti-check" },
|
||||
error: { bg: "#C0392B", color: "white", icono: "ti-x" },
|
||||
};
|
||||
|
||||
// Aviso flotante breve, de solo lectura: se controla desde el hook useToast.
|
||||
export default function Toast({ toast }) {
|
||||
if (!toast) return null;
|
||||
const { bg, color, icono } = ESTILOS[toast.tipo] || ESTILOS.info;
|
||||
return (
|
||||
<div key={toast.key} role="status" aria-live="polite" style={{
|
||||
position: "fixed", left: "50%", bottom: 24, transform: "translateX(-50%)",
|
||||
background: bg, color, padding: "10px 18px", borderRadius: 999, fontSize: 13,
|
||||
fontWeight: 500, boxShadow: "0 6px 20px rgba(0,0,0,0.18)", zIndex: 2000,
|
||||
display: "flex", alignItems: "center", gap: 8, maxWidth: "90vw",
|
||||
animation: "localesp-toast-in 0.25s ease-out",
|
||||
}}>
|
||||
<i className={`ti ${icono}`} aria-hidden="true" style={{ fontSize: 15 }}></i>
|
||||
{toast.mensaje}
|
||||
<style>{"@keyframes localesp-toast-in{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}}"}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Categorías de negocio compartidas entre la web pública y el panel de administración.
|
||||
// Centralizar esta lista evita que App.jsx y AdminPanel.jsx queden desincronizados.
|
||||
|
||||
export const CATEGORIAS = {
|
||||
"Restauración": {
|
||||
emoji: "🍽️",
|
||||
color: "#8B0000",
|
||||
bgLight: "#FFF0F0",
|
||||
textColor: "#8B0000",
|
||||
subcategorias: [
|
||||
"Tapas y raciones", "Paella y arroces", "Pintxos", "Asador / Carne a la brasa",
|
||||
"Mariscos y pescados", "Bocadillos y montaditos", "Menú del día", "Cocina vasca",
|
||||
"Cocina catalana", "Cocina andaluza", "Cocina gallega", "Cocina madrileña",
|
||||
"Cocina mediterránea", "Pizzería", "Hamburguesería", "Comida rápida",
|
||||
"Cocina internacional", "Cafetería / Desayunos", "Heladería", "Pastelería",
|
||||
],
|
||||
},
|
||||
"Pequeño comercio": {
|
||||
emoji: "🛍️",
|
||||
color: "#1A5276",
|
||||
bgLight: "#EBF5FB",
|
||||
textColor: "#1A5276",
|
||||
subcategorias: [
|
||||
"Alimentación / Ultramarinos", "Frutería", "Carnicería", "Pescadería", "Panadería",
|
||||
"Farmacia", "Papelería / Librería", "Floristería", "Joyería / Relojería", "Zapatería",
|
||||
"Ropa y moda", "Juguetería", "Ferretería", "Bazar / Todo a 100", "Estanco", "Quiosco",
|
||||
"Óptica", "Ortopedia", "Tienda de mascotas", "Electrodomésticos",
|
||||
],
|
||||
},
|
||||
"Peluquería y estética": {
|
||||
emoji: "✂️",
|
||||
color: "#76448A",
|
||||
bgLight: "#F5EEF8",
|
||||
textColor: "#76448A",
|
||||
subcategorias: [
|
||||
"Peluquería señora", "Peluquería caballero", "Peluquería unisex", "Barbería",
|
||||
"Centro de estética", "Uñas / Manicura", "Depilación", "Masajes y spa",
|
||||
"Tatuajes y piercings", "Centro de bronceado", "Micropigmentación",
|
||||
],
|
||||
},
|
||||
"Servicios del hogar": {
|
||||
emoji: "🔧",
|
||||
color: "#1E8449",
|
||||
bgLight: "#EAFAF1",
|
||||
textColor: "#1E8449",
|
||||
subcategorias: [
|
||||
"Cerrajería", "Fontanería / Plomería", "Electricidad", "Reformas y construcción",
|
||||
"Pintura", "Carpintería", "Cristalería", "Climatización / Aire acondicionado",
|
||||
"Mudanzas", "Limpieza", "Jardinería", "Instalación solar", "Alarmas y seguridad",
|
||||
"Reparación electrodomésticos",
|
||||
],
|
||||
},
|
||||
"Otros": {
|
||||
emoji: "📌",
|
||||
color: "#784212",
|
||||
bgLight: "#FDF2E9",
|
||||
textColor: "#784212",
|
||||
subcategorias: [
|
||||
"Taller mecánico", "Lavado de coches", "Academia / Clases", "Gestoría / Asesoría",
|
||||
"Inmobiliaria", "Agencia de viajes", "Fotografía", "Informática / Reparación móviles",
|
||||
"Copistería / Imprenta", "Veterinaria", "Gimnasio / Fitness", "Centro médico / Clínica",
|
||||
"Fisioterapia", "Psicología", "Lavandería", "Tintorería", "Otro",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const NOMBRES_CATEGORIAS = Object.keys(CATEGORIAS);
|
||||
|
||||
export const COLORES_MAPA = NOMBRES_CATEGORIAS.reduce((acc, nombre) => {
|
||||
acc[nombre] = CATEGORIAS[nombre].color;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Categoría comodín usada cuando un local trae una categoría desconocida o vacía
|
||||
// (por ejemplo, datos antiguos que ya no coinciden con el catálogo actual).
|
||||
export const CATEGORIA_DESCONOCIDA = {
|
||||
emoji: "📍",
|
||||
color: "#6e6e73",
|
||||
bgLight: "var(--color-background-secondary)",
|
||||
textColor: "var(--color-text-secondary)",
|
||||
subcategorias: [],
|
||||
};
|
||||
|
||||
export function obtenerCategoria(nombre) {
|
||||
return CATEGORIAS[nombre] || CATEGORIA_DESCONOCIDA;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Provincias españolas y sus coordenadas aproximadas (capital de provincia),
|
||||
// usadas como centro de referencia en el mapa cuando un local no trae lat/lng propios.
|
||||
|
||||
export const PROVINCIAS = [
|
||||
"Álava", "Albacete", "Alicante", "Almería", "Asturias", "Ávila", "Badajoz", "Barcelona",
|
||||
"Burgos", "Cáceres", "Cádiz", "Cantabria", "Castellón", "Ciudad Real", "Córdoba", "Cuenca",
|
||||
"Gerona", "Granada", "Guadalajara", "Guipúzcoa", "Huelva", "Huesca", "Islas Baleares", "Jaén",
|
||||
"La Coruña", "La Rioja", "Las Palmas", "León", "Lérida", "Lugo", "Madrid", "Málaga", "Murcia",
|
||||
"Navarra", "Orense", "Palencia", "Pontevedra", "Salamanca", "Santa Cruz de Tenerife", "Segovia",
|
||||
"Sevilla", "Soria", "Tarragona", "Teruel", "Toledo", "Valencia", "Valladolid", "Vizcaya",
|
||||
"Zamora", "Zaragoza",
|
||||
];
|
||||
|
||||
export const COORDS_PROVINCIAS = {
|
||||
"Álava": [42.8467, -2.6726], "Albacete": [38.9942, -1.8585], "Alicante": [38.3452, -0.4815],
|
||||
"Almería": [36.834, -2.4637], "Asturias": [43.3614, -5.8593], "Ávila": [40.6566, -4.6814],
|
||||
"Badajoz": [38.8794, -6.9707], "Barcelona": [41.3851, 2.1734], "Burgos": [42.344, -3.6969],
|
||||
"Cáceres": [39.4753, -6.3724], "Cádiz": [36.5271, -6.2886], "Cantabria": [43.4623, -3.8099],
|
||||
"Castellón": [39.9864, -0.0513], "Ciudad Real": [38.9848, -3.9274], "Córdoba": [37.8882, -4.7794],
|
||||
"Cuenca": [40.0704, -2.1374], "Gerona": [41.9794, 2.8214], "Granada": [37.1773, -3.5986],
|
||||
"Guadalajara": [40.6328, -3.1614], "Guipúzcoa": [43.3183, -1.9812], "Huelva": [37.2614, -6.9447],
|
||||
"Huesca": [42.1401, -0.4089], "Islas Baleares": [39.5696, 2.6502], "Jaén": [37.7796, -3.7849],
|
||||
"La Coruña": [43.3623, -8.4115], "La Rioja": [42.4627, -2.4449], "Las Palmas": [28.1235, -15.4366],
|
||||
"León": [42.5987, -5.5671], "Lérida": [41.6178, 0.62], "Lugo": [43.0097, -7.5567],
|
||||
"Madrid": [40.4168, -3.7038], "Málaga": [36.7213, -4.4214], "Murcia": [37.9922, -1.1307],
|
||||
"Navarra": [42.8169, -1.6432], "Orense": [42.3362, -7.8639], "Palencia": [42.0097, -4.5288],
|
||||
"Pontevedra": [42.4333, -8.65], "Salamanca": [40.9701, -5.6635], "Santa Cruz de Tenerife": [28.4636, -16.2518],
|
||||
"Segovia": [40.9429, -4.1088], "Sevilla": [37.3891, -5.9845], "Soria": [41.764, -2.464],
|
||||
"Tarragona": [41.1189, 1.2445], "Teruel": [40.344, -1.1065], "Toledo": [39.8628, -4.0273],
|
||||
"Valencia": [39.4699, -0.3763], "Valladolid": [41.6523, -4.7245], "Vizcaya": [43.263, -2.935],
|
||||
"Zamora": [41.5034, -5.7446], "Zaragoza": [41.6488, -0.8891],
|
||||
};
|
||||
|
||||
export const CENTRO_ESPANA = [40.4, -3.7];
|
||||
@@ -0,0 +1,21 @@
|
||||
// firebase.js - Re-exports local API instead of Firebase
|
||||
export {
|
||||
escucharAuth,
|
||||
loginAdmin,
|
||||
registrarAdmin,
|
||||
cerrarSesion,
|
||||
suscribirLocales,
|
||||
deleteLocal,
|
||||
confirmarLocal,
|
||||
reportarLocal,
|
||||
obtenerReportes,
|
||||
descartarReporte,
|
||||
enviarPropuesta,
|
||||
comprobarDuplicado,
|
||||
suscribirPendientes,
|
||||
aprobarPropuesta,
|
||||
rechazarPropuesta,
|
||||
obtenerPalabrasFiltradas,
|
||||
guardarPalabrasFiltradas,
|
||||
contienepalabrasProhibidas,
|
||||
} from "./api.js";
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
const CLAVE = "localesp_favoritos";
|
||||
|
||||
function leerFavoritos() {
|
||||
try {
|
||||
const datos = JSON.parse(localStorage.getItem(CLAVE) || "[]");
|
||||
return Array.isArray(datos) ? datos : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Favoritos guardados en el propio dispositivo (localStorage), sin necesidad
|
||||
// de cuenta de usuario. Sincroniza entre pestañas mediante el evento "storage".
|
||||
export default function useFavoritos() {
|
||||
const [favoritos, setFavoritos] = useState(leerFavoritos);
|
||||
|
||||
useEffect(() => {
|
||||
const alCambiar = () => setFavoritos(leerFavoritos());
|
||||
window.addEventListener("storage", alCambiar);
|
||||
return () => window.removeEventListener("storage", alCambiar);
|
||||
}, []);
|
||||
|
||||
const esFavorito = useCallback((id) => favoritos.includes(id), [favoritos]);
|
||||
|
||||
const alternarFavorito = useCallback((id) => {
|
||||
setFavoritos((actual) => {
|
||||
const nuevo = actual.includes(id) ? actual.filter((x) => x !== id) : [...actual, id];
|
||||
localStorage.setItem(CLAVE, JSON.stringify(nuevo));
|
||||
return nuevo;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { favoritos, esFavorito, alternarFavorito };
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
// Detecta si la web se está ejecutando ya como app instalada (modo standalone),
|
||||
// tanto en Chrome/Android (display-mode: standalone) como en Safari/iOS
|
||||
// (navigator.standalone) o dentro de una TWA de Android (referrer android-app://).
|
||||
function calcularSiEsStandalone() {
|
||||
if (typeof window === "undefined") return false;
|
||||
const enModoStandalone = window.matchMedia?.("(display-mode: standalone)")?.matches ?? false;
|
||||
const enIosStandalone = window.navigator?.standalone === true;
|
||||
const enTwaAndroid = document.referrer?.startsWith("android-app://") ?? false;
|
||||
return enModoStandalone || enIosStandalone || enTwaAndroid;
|
||||
}
|
||||
|
||||
function detectarIos() {
|
||||
if (typeof window === "undefined") return false;
|
||||
return /iphone|ipad|ipod/i.test(window.navigator.userAgent) && !window.MSStream;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook que centraliza el estado de instalación de la PWA:
|
||||
* - isInstalled: la app ya se está ejecutando en modo standalone (o el
|
||||
* navegador confirma que está instalada mediante getInstalledRelatedApps).
|
||||
* - canPromptInstall: el navegador ha ofrecido el evento nativo `beforeinstallprompt`,
|
||||
* así que se puede lanzar el diálogo de instalación con promptInstall().
|
||||
* - isIos: Safari/iOS no soporta beforeinstallprompt, así que ahí solo cabe
|
||||
* mostrar instrucciones manuales ("Compartir → Añadir a pantalla de inicio").
|
||||
*/
|
||||
export default function usePwaInstall() {
|
||||
const [installPromptEvent, setInstallPromptEvent] = useState(null);
|
||||
const [isInstalled, setIsInstalled] = useState(calcularSiEsStandalone);
|
||||
const [isIos] = useState(detectarIos);
|
||||
|
||||
useEffect(() => {
|
||||
const alCapturarPrompt = (evento) => {
|
||||
evento.preventDefault();
|
||||
setInstallPromptEvent(evento);
|
||||
};
|
||||
const alInstalar = () => {
|
||||
setInstallPromptEvent(null);
|
||||
setIsInstalled(true);
|
||||
};
|
||||
|
||||
window.addEventListener("beforeinstallprompt", alCapturarPrompt);
|
||||
window.addEventListener("appinstalled", alInstalar);
|
||||
|
||||
const consultaDisplayMode = window.matchMedia?.("(display-mode: standalone)");
|
||||
const alCambiarDisplayMode = () => setIsInstalled(calcularSiEsStandalone());
|
||||
consultaDisplayMode?.addEventListener?.("change", alCambiarDisplayMode);
|
||||
|
||||
// Progressive enhancement: en navegadores compatibles (Chrome/Android),
|
||||
// confirma si la PWA ya está instalada aunque se esté viendo en una pestaña normal.
|
||||
navigator.getInstalledRelatedApps?.()
|
||||
.then((apps) => { if (apps.length > 0) setIsInstalled(true); })
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("beforeinstallprompt", alCapturarPrompt);
|
||||
window.removeEventListener("appinstalled", alInstalar);
|
||||
consultaDisplayMode?.removeEventListener?.("change", alCambiarDisplayMode);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const promptInstall = useCallback(async () => {
|
||||
if (!installPromptEvent) return "unavailable";
|
||||
installPromptEvent.prompt();
|
||||
const { outcome } = await installPromptEvent.userChoice;
|
||||
setInstallPromptEvent(null);
|
||||
return outcome; // "accepted" | "dismissed"
|
||||
}, [installPromptEvent]);
|
||||
|
||||
return { isInstalled, canPromptInstall: Boolean(installPromptEvent), promptInstall, isIos };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
// Aviso breve tipo "snackbar" que aparece y desaparece solo. Uso:
|
||||
// const { toast, mostrarToast } = useToast();
|
||||
// mostrarToast("Añadido a favoritos", "exito");
|
||||
// <Toast toast={toast} />
|
||||
export default function useToast() {
|
||||
const [toast, setToast] = useState(null); // { mensaje, tipo }
|
||||
const timerRef = useRef(null);
|
||||
|
||||
const mostrarToast = useCallback((mensaje, tipo = "info", duracion = 2200) => {
|
||||
clearTimeout(timerRef.current);
|
||||
setToast({ mensaje, tipo, key: Date.now() });
|
||||
timerRef.current = setTimeout(() => setToast(null), duracion);
|
||||
}, []);
|
||||
|
||||
return { toast, mostrarToast };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
// Pide la ubicación del navegador solo cuando el usuario la solicita explícitamente
|
||||
// (botón "Cerca de mí"), nunca automáticamente al cargar la página.
|
||||
export default function useUbicacion() {
|
||||
const [ubicacion, setUbicacion] = useState(null); // { lat, lng }
|
||||
const [buscando, setBuscando] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const pedirUbicacion = useCallback(() => {
|
||||
if (!navigator.geolocation) {
|
||||
setError("Tu navegador no permite compartir ubicación.");
|
||||
return;
|
||||
}
|
||||
setBuscando(true);
|
||||
setError("");
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setUbicacion({ lat: pos.coords.latitude, lng: pos.coords.longitude });
|
||||
setBuscando(false);
|
||||
},
|
||||
(err) => {
|
||||
setError(err.code === 1 ? "Has denegado el acceso a tu ubicación." : "No se pudo obtener tu ubicación.");
|
||||
setBuscando(false);
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000 },
|
||||
);
|
||||
}, []);
|
||||
|
||||
const limpiarUbicacion = useCallback(() => { setUbicacion(null); setError(""); }, []);
|
||||
|
||||
return { ubicacion, buscando, error, pedirUbicacion, limpiarUbicacion };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.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 />;
|
||||
return <App />;
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<StrictMode><Root /></StrictMode>
|
||||
);
|
||||
|
||||
// Registro del service worker: convierte la web en una PWA instalable
|
||||
// (icono en pantalla de inicio, apertura en modo standalone, shell offline).
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker.register("/sw.js").catch((err) => {
|
||||
console.error("Error registrando el service worker:", err);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Estilos inline reutilizados en varios componentes de la web pública.
|
||||
// Se mantiene el enfoque de estilos inline del proyecto original (sin CSS
|
||||
// modules/Tailwind), pero centralizado aquí para no repetir el mismo objeto
|
||||
// en cada archivo.
|
||||
|
||||
export const inputStyle = {
|
||||
width: "100%",
|
||||
boxSizing: "border-box",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "var(--border-radius-md)",
|
||||
border: "0.5px solid var(--color-border-secondary)",
|
||||
background: "var(--color-background-primary)",
|
||||
color: "var(--color-text-primary)",
|
||||
fontSize: 14,
|
||||
outline: "none",
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
// Utilidades de geolocalización: extraer coordenadas de un enlace de Google Maps
|
||||
// o geocodificar una dirección de texto mediante Nominatim (OpenStreetMap).
|
||||
|
||||
export function parsearEnlaceGoogleMaps(url) {
|
||||
try {
|
||||
let coincidencia = url.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (coincidencia) return { lat: parseFloat(coincidencia[1]), lng: parseFloat(coincidencia[2]) };
|
||||
|
||||
coincidencia = url.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/);
|
||||
if (coincidencia) return { lat: parseFloat(coincidencia[1]), lng: parseFloat(coincidencia[2]) };
|
||||
|
||||
coincidencia = url.match(/[?&]q=(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (coincidencia) return { lat: parseFloat(coincidencia[1]), lng: parseFloat(coincidencia[2]) };
|
||||
} catch {
|
||||
// URL malformada: se ignora y se devuelve null más abajo
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Enlaces para abrir la ubicación de un local directamente en Google Maps o Waze,
|
||||
// usando las coordenadas si existen (más preciso) o si no, el texto de la dirección.
|
||||
export function enlaceGoogleMapsDesdeLocal(local) {
|
||||
if (local?.lat && local?.lng) return `https://www.google.com/maps/search/?api=1&query=${local.lat},${local.lng}`;
|
||||
if (local?.direccion) return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(local.direccion)}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function enlaceWazeDesdeLocal(local) {
|
||||
if (local?.lat && local?.lng) return `https://waze.com/ul?ll=${local.lat},${local.lng}&navigate=yes`;
|
||||
if (local?.direccion) return `https://waze.com/ul?q=${encodeURIComponent(local.direccion)}&navigate=yes`;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Distancia en kilómetros entre dos coordenadas (fórmula de Haversine),
|
||||
// usada para "cerca de mí" y para mostrar la distancia en cada tarjeta.
|
||||
export function distanciaKm(lat1, lng1, lat2, lng2) {
|
||||
const R = 6371;
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
export function formatearDistancia(km) {
|
||||
if (km == null) return "";
|
||||
return km < 1 ? `${Math.round(km * 1000)} m` : `${km.toFixed(1)} km`;
|
||||
}
|
||||
|
||||
export async function geocodificarDireccion(direccion) {
|
||||
const query = encodeURIComponent(`${direccion}, España`);
|
||||
const url = `https://nominatim.openstreetmap.org/search?q=${query}&format=json&limit=1&countrycodes=es`;
|
||||
const respuesta = await fetch(url, { headers: { "Accept-Language": "es", "User-Agent": "LocalesEspanoles/1.0" } });
|
||||
const datos = await respuesta.json();
|
||||
if (datos?.length) {
|
||||
return { lat: parseFloat(datos[0].lat), lng: parseFloat(datos[0].lon), displayName: datos[0].display_name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Busca lugares/negocios por nombre (autocompletar). Usa Nominatim (OpenStreetMap),
|
||||
// que no requiere clave de API. Nota: no es el autocompletar "de Google" (eso exigiría
|
||||
// una clave de Google Places con facturación activada) pero da el mismo resultado
|
||||
// práctico: escribes un nombre y aparecen varias opciones para elegir.
|
||||
export async function buscarLugares(texto) {
|
||||
const q = (texto || "").trim();
|
||||
if (q.length < 3) return [];
|
||||
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(`${q}, España`)}&format=json&addressdetails=1&namedetails=1&limit=6&countrycodes=es`;
|
||||
const respuesta = await fetch(url, { headers: { "Accept-Language": "es", "User-Agent": "LocalesEspanoles/1.0" } });
|
||||
const datos = await respuesta.json();
|
||||
if (!datos?.length) return [];
|
||||
return datos.map((d) => ({
|
||||
id: d.place_id,
|
||||
nombre: d.namedetails?.name || d.display_name.split(",")[0],
|
||||
direccion: d.display_name,
|
||||
provincia: d.address?.county || d.address?.province || d.address?.state || d.address?.city || "",
|
||||
lat: parseFloat(d.lat),
|
||||
lng: parseFloat(d.lon),
|
||||
}));
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||