8 Commits
Author SHA1 Message Date
edgar.friendly d48b74cbd4 feat(ui): notificaciones toast y mapa que sobrevive al cambio de pestaña
deploy-branch / deploy (push) Successful in 2m41s
deploy-branch / teardown (push) Skipped
- Añade sistema de avisos tipo snackbar (Toast.jsx + useToast.js) con
  auto-cierre, animación de entrada y estilos info/éxito/error.
- LocalCard: feedback visual al añadir/quitar un local de favoritos.
- AdminPanel: sustituye los alert() por toasts al aprobar, rechazar,
  eliminar locales, descartar avisos y guardar el filtro de palabras;
  además saluda con un toast tras iniciar sesión o crear cuenta.
- App: el contenedor del mapa Leaflet queda siempre montado (oculto con
  CSS) y se llama a invalidateSize() al volver a la pestaña de mapa;
  así el mapa ya no se rompe al navegar entre vistas (antes obligaba
  a recargar la página).
2026-08-15 20:37:04 +02:00
edgar.friendly b36867225c feat: directorio más fiable y más fácil de usar
deploy-branch / deploy (push) Successful in 2m34s
deploy-branch / teardown (push) Skipped
El objetivo de estos cambios es, por un lado, mantener los datos
actualizados y, por otro, mejorar la experiencia para quienes lo usan:

- Fiabilidad: los locales se pueden confirmar ("Sigue aquí") y
  reportar (cerrado, datos incorrectos...), y se avisa de duplicados
  antes de publicar, para que el contenido no se quede obsoleto ni
  repetido.

- Usabilidad: autocompletado al proponer, favoritos, "cerca de mí",
  compartir fichas, enlaces a Waze y una política de privacidad
  visible, para que sea más sencillo aportar y encontrar locales.
2026-08-10 22:23:35 +02:00
edgar.friendly 257fb9c622 refactor(ui): modulariza App.jsx y unifica branding y caché PWA
deploy-branch / deploy (push) Successful in 2m36s
deploy-branch / teardown (push) Skipped
- Extrae App.jsx en módulos reutilizables: constants/ (categorias,
  provincias), utils/geo, styles/shared, hooks/usePwaInstall y
  components/ (StarRating, LocalCard, CampoUbicacion, BannerMovil,
  CategoriaBadge)
- AdminPanel reutiliza constantes y componentes compartidos y corrige
  la limpieza de suscripciones de Firebase al desmontar
- Añade logos de marca (logo-localesp.png + @2x) y regenera los iconos
  PWA con tamaños menores
- sw.js: sube la caché del shell a v2 e incluye los nuevos iconos y logos
2026-08-05 19:07:00 +02:00
edgar.friendly 28d066b5af feat(pwa): añade assets PWA que faltaban (manifest, sw.js, iconos)
deploy-branch / deploy (push) Successful in 2m49s
deploy-branch / teardown (push) Skipped
El commit anterior (a3fd928) referenciaba /manifest.webmanifest,
/sw.js, /favicon-32.png y /apple-touch-icon.png en index.html y
main.jsx, pero los archivos no existían: el fallback SPA los servía
como index.html (200 text/html), así que la PWA no era instalable y
parecía que el desplegue no había actualizado.

Se añaden en public/ (Vite los copia a dist/ raíz):
- manifest.webmanifest: nombre, theme_color #8B0000, iconos any/maskable.
- sw.js: cache del shell con stale-while-revalidate (sirve cache al
  instante y revalida en segundo plano) + network-first para /api/.
  Versión de caché localesp-shell-v1.
- Iconos: favicon-32, apple-touch-icon (180), icon-192, icon-512 y
  icon-512-maskable.
2026-08-02 12:11:40 +02:00
edgar.friendly a3fd928073 Migración de esquema DB y soporte PWA:
deploy-branch / deploy (push) Successful in 2m16s
deploy-branch / teardown (push) Skipped
- Mapeo campos en base de datos: ubicacion→provincia, telefono→subcategoria,
  email→direccion, web→enlaceGoogleMaps y añadido puntuacion.
- Migración suave (ensureColumn) para añadir las nuevas columnas sin perder
  datos existentes si la DB ya existía con el esquema antiguo.

- Actualización de rutas del servidor para guardar/leer los nuevos campos y
  convertir el NULL a valor nulo por defecto en puntuacion.

- Soporte PWA: añadido meta tags (manifest, theme-color, apple-touch-icon) y
  registro de service worker para instalación del app y shell offline.
2026-08-01 19:27:57 +02:00
edgar.friendly d1fec59dcb ci: despliegue automático por rama (workflow + scripts OOP)
deploy-branch / deploy (push) Successful in 2m15s
deploy-branch / teardown (push) Skipped
Añade la automatización de despliegue a esta rama (no la tenía):
- .gitea/workflows/deploy.yml: build + deploy on push, teardown on delete.
- scripts/lib/env.sh + deploy.sh + teardown.sh: lib OOP (Env + SystemdUnit/
  NginxVhost/TlsCert) y entrypoints finos.

El código de la app NO se modifica. Traído path-scoped desde master (6cc326e).
Cada push a prototipo-yb01 publicará la app en prototipo-yb01.localesp.es.
2026-08-01 18:30:00 +02:00
edgar.friendly bd4282c426 Add support for sqlite 2026-06-07 23:58:53 +02:00
edgar.friendly 1b67900d54 Primera versión 2026-05-20 13:55:15 +02:00
44 changed files with 6830 additions and 0 deletions
+54
View File
@@ -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/}"
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.env
.env.local
+54
View File
@@ -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>
+10
View File
@@ -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
+3303
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+16
View File
@@ -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" }
]
}
+56
View File
@@ -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;
})
);
});
+33
View File
@@ -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 "$@"
+450
View File
@@ -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]}"
}
+33
View File
@@ -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 "$@"
+87
View File
@@ -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;
+173
View File
@@ -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}`));
+351
View File
@@ -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. 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} />;
}
+515
View File
@@ -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>
);
}
+383
View File
@@ -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>
);
}
+176
View File
@@ -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;
}
+103
View File
@@ -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>
);
}
+102
View File
@@ -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>
);
}
+95
View File
@@ -0,0 +1,95 @@
import { useRef, useState } from "react";
import { geocodificarDireccion, parsearEnlaceGoogleMaps } from "../utils/geo.js";
const inputStyle = {
width: "100%", boxSizing: "border-box", padding: "8px 12px",
borderRadius: "var(--border-radius-md)", border: "0.5px solid var(--color-border-secondary)",
background: "var(--color-background-primary)", color: "var(--color-text-primary)", fontSize: 14, outline: "none",
};
export default function CampoUbicacion({ form, setForm }) {
const [modoInput, setModoInput] = useState("direccion");
const [geocodificando, setGeocodificando] = useState(false);
const [resultadoGeo, setResultadoGeo] = useState(null);
const debounceRef = useRef(null);
const handleDireccionChange = (val) => {
setForm((f) => ({ ...f, direccion: val, lat: null, lng: null, enlaceGoogleMaps: "" }));
setResultadoGeo(null);
clearTimeout(debounceRef.current);
if (val.trim().length < 8) return;
debounceRef.current = setTimeout(async () => {
setGeocodificando(true);
try {
const geo = await geocodificarDireccion(val);
if (geo) {
setForm((f) => ({ ...f, lat: geo.lat, lng: geo.lng }));
setResultadoGeo({ ok: true, texto: geo.displayName });
} else {
setResultadoGeo({ ok: false, texto: "No se encontró la dirección. Se usará el centro de la provincia." });
}
} catch {
setResultadoGeo({ ok: false, texto: "Error al geocodificar. Se usará el centro de la provincia." });
}
setGeocodificando(false);
}, 900);
};
const handleEnlaceChange = (val) => {
setForm((f) => ({ ...f, enlaceGoogleMaps: val, lat: null, lng: null, direccion: "" }));
setResultadoGeo(null);
if (!val.trim()) return;
const coords = parsearEnlaceGoogleMaps(val);
if (coords) {
setForm((f) => ({ ...f, lat: coords.lat, lng: coords.lng }));
setResultadoGeo({ ok: true, texto: `Coordenadas detectadas: ${coords.lat.toFixed(5)}, ${coords.lng.toFixed(5)}` });
} else {
setResultadoGeo({ ok: false, texto: "No se pudieron extraer coordenadas. Copia la URL completa desde Google Maps." });
}
};
return (
<div style={{ gridColumn: "1 / -1", marginTop: 4 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 6 }}>
<label style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
Ubicación <span style={{ color: "#8B0000" }}>*</span>
</label>
<div style={{ display: "flex", border: "0.5px solid var(--color-border-secondary)", borderRadius: "var(--border-radius-md)", overflow: "hidden" }}>
{[{ id: "direccion", label: "📍 Dirección" }, { id: "enlace", label: "🔗 Google Maps" }].map((opt) => (
<button key={opt.id} type="button"
onClick={() => { setModoInput(opt.id); setResultadoGeo(null); setForm((f) => ({ ...f, lat: null, lng: null, direccion: "", enlaceGoogleMaps: "" })); }}
style={{ background: modoInput === opt.id ? "var(--color-background-secondary)" : "none", border: "none", padding: "4px 10px", fontSize: 11, cursor: "pointer", fontWeight: modoInput === opt.id ? 500 : 400, color: modoInput === opt.id ? "var(--color-text-primary)" : "var(--color-text-secondary)" }}>
{opt.label}
</button>
))}
</div>
</div>
{modoInput === "direccion" ? (
<input style={inputStyle} value={form.direccion || ""} placeholder="Ej: Calle Mayor 5, Alcalá de Henares, Madrid"
onChange={(e) => handleDireccionChange(e.target.value)} />
) : (
<input style={inputStyle} value={form.enlaceGoogleMaps || ""} placeholder="Pega aquí el enlace de Google Maps..."
onChange={(e) => handleEnlaceChange(e.target.value)} />
)}
{geocodificando && (
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 6, fontSize: 12, color: "var(--color-text-secondary)" }}>
<i className="ti ti-loader" aria-hidden="true" style={{ fontSize: 14 }}></i> Buscando dirección...
</div>
)}
{resultadoGeo && !geocodificando && (
<div style={{
display: "flex", alignItems: "flex-start", gap: 6, marginTop: 6, fontSize: 12,
color: resultadoGeo.ok ? "#1E8449" : "#784212",
background: resultadoGeo.ok ? "#EAFAF1" : "#FDF2E9",
padding: "6px 10px", borderRadius: "var(--border-radius-md)",
}}>
<i className={`ti ${resultadoGeo.ok ? "ti-circle-check" : "ti-alert-triangle"}`} aria-hidden="true" style={{ fontSize: 14, flexShrink: 0, marginTop: 1 }}></i>
<span style={{ lineHeight: 1.4 }}>{resultadoGeo.texto}</span>
</div>
)}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { obtenerCategoria } from "../constants/categorias.js";
export default function CategoriaBadge({ categoria, subcategoria }) {
if (!categoria) return null;
const cat = obtenerCategoria(categoria);
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>
<span style={{ background: cat.bgLight, color: cat.textColor, fontSize: 12, padding: "3px 10px", borderRadius: "var(--border-radius-md)", fontWeight: 500 }}>
{cat.emoji} {categoria}
</span>
{subcategoria && (
<span style={{ background: "var(--color-background-secondary)", color: "var(--color-text-secondary)", fontSize: 12, padding: "3px 10px", borderRadius: "var(--border-radius-md)" }}>
{subcategoria}
</span>
)}
</div>
);
}
+168
View File
@@ -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>
);
}
+31
View File
@@ -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>
);
}
+63
View File
@@ -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>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { useState } from "react";
const ESTRELLAS = [1, 2, 3, 4, 5];
/**
* Muestra siempre 5 estrellas (usando el font icon de Tabler ya cargado en
* index.html, para no depender de una fuente/librería extra). Si no hay
* valoración (0, null, undefined o un valor no numérico) se muestran las
* 5 estrellas vacías en lugar de no renderizar nada.
*
* - Modo lectura (readOnly, por defecto): <div role="img"> con aria-label
* describiendo la puntuación, para lectores de pantalla.
* - Modo edición (onChange): botones reales con role="radio" para que sea
* utilizable con teclado y tenga foco visible.
*/
export default function StarRating({ value = 0, onChange, readOnly = false, size = 20 }) {
const [valorHover, setValorHover] = useState(0);
const esInteractivo = !readOnly && typeof onChange === "function";
const valorSeguro = Math.min(5, Math.max(0, Math.round(Number(value)) || 0));
const valorMostrado = esInteractivo && valorHover ? valorHover : valorSeguro;
const contenedorProps = esInteractivo
? { role: "radiogroup", "aria-label": "Selecciona una puntuación de 1 a 5 estrellas" }
: { role: "img", "aria-label": `Valoración: ${valorSeguro} de 5 estrellas` };
return (
<div
{...contenedorProps}
style={{ display: "inline-flex", gap: 2 }}
onMouseLeave={() => esInteractivo && setValorHover(0)}
>
{ESTRELLAS.map((estrella) => {
const rellena = estrella <= valorMostrado;
const iconStyle = {
fontSize: size,
color: rellena ? "#D4AF37" : "var(--color-border-secondary)",
transition: "color 0.15s",
lineHeight: 0,
};
if (!esInteractivo) {
return (
<i key={estrella} className={`ti ${rellena ? "ti-star-filled" : "ti-star"}`} style={iconStyle} aria-hidden="true" />
);
}
return (
<button
key={estrella}
type="button"
role="radio"
aria-checked={valorSeguro === estrella}
aria-label={`${estrella} estrella${estrella === 1 ? "" : "s"}`}
onClick={() => onChange(estrella)}
onMouseEnter={() => setValorHover(estrella)}
onFocus={() => setValorHover(estrella)}
onBlur={() => setValorHover(0)}
style={{ background: "none", border: "none", padding: 2, margin: 0, cursor: "pointer", lineHeight: 0 }}
>
<i className={`ti ${rellena ? "ti-star-filled" : "ti-star"}`} style={iconStyle} aria-hidden="true" />
</button>
);
})}
</div>
);
}
+24
View File
@@ -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>
);
}
+86
View File
@@ -0,0 +1,86 @@
// Categorías de negocio compartidas entre la web pública y el panel de administración.
// Centralizar esta lista evita que App.jsx y AdminPanel.jsx queden desincronizados.
export const CATEGORIAS = {
"Restauración": {
emoji: "🍽️",
color: "#8B0000",
bgLight: "#FFF0F0",
textColor: "#8B0000",
subcategorias: [
"Tapas y raciones", "Paella y arroces", "Pintxos", "Asador / Carne a la brasa",
"Mariscos y pescados", "Bocadillos y montaditos", "Menú del día", "Cocina vasca",
"Cocina catalana", "Cocina andaluza", "Cocina gallega", "Cocina madrileña",
"Cocina mediterránea", "Pizzería", "Hamburguesería", "Comida rápida",
"Cocina internacional", "Cafetería / Desayunos", "Heladería", "Pastelería",
],
},
"Pequeño comercio": {
emoji: "🛍️",
color: "#1A5276",
bgLight: "#EBF5FB",
textColor: "#1A5276",
subcategorias: [
"Alimentación / Ultramarinos", "Frutería", "Carnicería", "Pescadería", "Panadería",
"Farmacia", "Papelería / Librería", "Floristería", "Joyería / Relojería", "Zapatería",
"Ropa y moda", "Juguetería", "Ferretería", "Bazar / Todo a 100", "Estanco", "Quiosco",
"Óptica", "Ortopedia", "Tienda de mascotas", "Electrodomésticos",
],
},
"Peluquería y estética": {
emoji: "✂️",
color: "#76448A",
bgLight: "#F5EEF8",
textColor: "#76448A",
subcategorias: [
"Peluquería señora", "Peluquería caballero", "Peluquería unisex", "Barbería",
"Centro de estética", "Uñas / Manicura", "Depilación", "Masajes y spa",
"Tatuajes y piercings", "Centro de bronceado", "Micropigmentación",
],
},
"Servicios del hogar": {
emoji: "🔧",
color: "#1E8449",
bgLight: "#EAFAF1",
textColor: "#1E8449",
subcategorias: [
"Cerrajería", "Fontanería / Plomería", "Electricidad", "Reformas y construcción",
"Pintura", "Carpintería", "Cristalería", "Climatización / Aire acondicionado",
"Mudanzas", "Limpieza", "Jardinería", "Instalación solar", "Alarmas y seguridad",
"Reparación electrodomésticos",
],
},
"Otros": {
emoji: "📌",
color: "#784212",
bgLight: "#FDF2E9",
textColor: "#784212",
subcategorias: [
"Taller mecánico", "Lavado de coches", "Academia / Clases", "Gestoría / Asesoría",
"Inmobiliaria", "Agencia de viajes", "Fotografía", "Informática / Reparación móviles",
"Copistería / Imprenta", "Veterinaria", "Gimnasio / Fitness", "Centro médico / Clínica",
"Fisioterapia", "Psicología", "Lavandería", "Tintorería", "Otro",
],
},
};
export const NOMBRES_CATEGORIAS = Object.keys(CATEGORIAS);
export const COLORES_MAPA = NOMBRES_CATEGORIAS.reduce((acc, nombre) => {
acc[nombre] = CATEGORIAS[nombre].color;
return acc;
}, {});
// Categoría comodín usada cuando un local trae una categoría desconocida o vacía
// (por ejemplo, datos antiguos que ya no coinciden con el catálogo actual).
export const CATEGORIA_DESCONOCIDA = {
emoji: "📍",
color: "#6e6e73",
bgLight: "var(--color-background-secondary)",
textColor: "var(--color-text-secondary)",
subcategorias: [],
};
export function obtenerCategoria(nombre) {
return CATEGORIAS[nombre] || CATEGORIA_DESCONOCIDA;
}
+34
View File
@@ -0,0 +1,34 @@
// Provincias españolas y sus coordenadas aproximadas (capital de provincia),
// usadas como centro de referencia en el mapa cuando un local no trae lat/lng propios.
export const PROVINCIAS = [
"Álava", "Albacete", "Alicante", "Almería", "Asturias", "Ávila", "Badajoz", "Barcelona",
"Burgos", "Cáceres", "Cádiz", "Cantabria", "Castellón", "Ciudad Real", "Córdoba", "Cuenca",
"Gerona", "Granada", "Guadalajara", "Guipúzcoa", "Huelva", "Huesca", "Islas Baleares", "Jaén",
"La Coruña", "La Rioja", "Las Palmas", "León", "Lérida", "Lugo", "Madrid", "Málaga", "Murcia",
"Navarra", "Orense", "Palencia", "Pontevedra", "Salamanca", "Santa Cruz de Tenerife", "Segovia",
"Sevilla", "Soria", "Tarragona", "Teruel", "Toledo", "Valencia", "Valladolid", "Vizcaya",
"Zamora", "Zaragoza",
];
export const COORDS_PROVINCIAS = {
"Álava": [42.8467, -2.6726], "Albacete": [38.9942, -1.8585], "Alicante": [38.3452, -0.4815],
"Almería": [36.834, -2.4637], "Asturias": [43.3614, -5.8593], "Ávila": [40.6566, -4.6814],
"Badajoz": [38.8794, -6.9707], "Barcelona": [41.3851, 2.1734], "Burgos": [42.344, -3.6969],
"Cáceres": [39.4753, -6.3724], "Cádiz": [36.5271, -6.2886], "Cantabria": [43.4623, -3.8099],
"Castellón": [39.9864, -0.0513], "Ciudad Real": [38.9848, -3.9274], "Córdoba": [37.8882, -4.7794],
"Cuenca": [40.0704, -2.1374], "Gerona": [41.9794, 2.8214], "Granada": [37.1773, -3.5986],
"Guadalajara": [40.6328, -3.1614], "Guipúzcoa": [43.3183, -1.9812], "Huelva": [37.2614, -6.9447],
"Huesca": [42.1401, -0.4089], "Islas Baleares": [39.5696, 2.6502], "Jaén": [37.7796, -3.7849],
"La Coruña": [43.3623, -8.4115], "La Rioja": [42.4627, -2.4449], "Las Palmas": [28.1235, -15.4366],
"León": [42.5987, -5.5671], "Lérida": [41.6178, 0.62], "Lugo": [43.0097, -7.5567],
"Madrid": [40.4168, -3.7038], "Málaga": [36.7213, -4.4214], "Murcia": [37.9922, -1.1307],
"Navarra": [42.8169, -1.6432], "Orense": [42.3362, -7.8639], "Palencia": [42.0097, -4.5288],
"Pontevedra": [42.4333, -8.65], "Salamanca": [40.9701, -5.6635], "Santa Cruz de Tenerife": [28.4636, -16.2518],
"Segovia": [40.9429, -4.1088], "Sevilla": [37.3891, -5.9845], "Soria": [41.764, -2.464],
"Tarragona": [41.1189, 1.2445], "Teruel": [40.344, -1.1065], "Toledo": [39.8628, -4.0273],
"Valencia": [39.4699, -0.3763], "Valladolid": [41.6523, -4.7245], "Vizcaya": [43.263, -2.935],
"Zamora": [41.5034, -5.7446], "Zaragoza": [41.6488, -0.8891],
};
export const CENTRO_ESPANA = [40.4, -3.7];
+21
View File
@@ -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";
+36
View File
@@ -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 };
}
+72
View File
@@ -0,0 +1,72 @@
import { useCallback, useEffect, useState } from "react";
// Detecta si la web se está ejecutando ya como app instalada (modo standalone),
// tanto en Chrome/Android (display-mode: standalone) como en Safari/iOS
// (navigator.standalone) o dentro de una TWA de Android (referrer android-app://).
function calcularSiEsStandalone() {
if (typeof window === "undefined") return false;
const enModoStandalone = window.matchMedia?.("(display-mode: standalone)")?.matches ?? false;
const enIosStandalone = window.navigator?.standalone === true;
const enTwaAndroid = document.referrer?.startsWith("android-app://") ?? false;
return enModoStandalone || enIosStandalone || enTwaAndroid;
}
function detectarIos() {
if (typeof window === "undefined") return false;
return /iphone|ipad|ipod/i.test(window.navigator.userAgent) && !window.MSStream;
}
/**
* Hook que centraliza el estado de instalación de la PWA:
* - isInstalled: la app ya se está ejecutando en modo standalone (o el
* navegador confirma que está instalada mediante getInstalledRelatedApps).
* - canPromptInstall: el navegador ha ofrecido el evento nativo `beforeinstallprompt`,
* así que se puede lanzar el diálogo de instalación con promptInstall().
* - isIos: Safari/iOS no soporta beforeinstallprompt, así que ahí solo cabe
* mostrar instrucciones manuales ("Compartir → Añadir a pantalla de inicio").
*/
export default function usePwaInstall() {
const [installPromptEvent, setInstallPromptEvent] = useState(null);
const [isInstalled, setIsInstalled] = useState(calcularSiEsStandalone);
const [isIos] = useState(detectarIos);
useEffect(() => {
const alCapturarPrompt = (evento) => {
evento.preventDefault();
setInstallPromptEvent(evento);
};
const alInstalar = () => {
setInstallPromptEvent(null);
setIsInstalled(true);
};
window.addEventListener("beforeinstallprompt", alCapturarPrompt);
window.addEventListener("appinstalled", alInstalar);
const consultaDisplayMode = window.matchMedia?.("(display-mode: standalone)");
const alCambiarDisplayMode = () => setIsInstalled(calcularSiEsStandalone());
consultaDisplayMode?.addEventListener?.("change", alCambiarDisplayMode);
// Progressive enhancement: en navegadores compatibles (Chrome/Android),
// confirma si la PWA ya está instalada aunque se esté viendo en una pestaña normal.
navigator.getInstalledRelatedApps?.()
.then((apps) => { if (apps.length > 0) setIsInstalled(true); })
.catch(() => {});
return () => {
window.removeEventListener("beforeinstallprompt", alCapturarPrompt);
window.removeEventListener("appinstalled", alInstalar);
consultaDisplayMode?.removeEventListener?.("change", alCambiarDisplayMode);
};
}, []);
const promptInstall = useCallback(async () => {
if (!installPromptEvent) return "unavailable";
installPromptEvent.prompt();
const { outcome } = await installPromptEvent.userChoice;
setInstallPromptEvent(null);
return outcome; // "accepted" | "dismissed"
}, [installPromptEvent]);
return { isInstalled, canPromptInstall: Boolean(installPromptEvent), promptInstall, isIos };
}
+18
View File
@@ -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 };
}
+33
View File
@@ -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 };
}
+26
View File
@@ -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);
});
});
}
+16
View File
@@ -0,0 +1,16 @@
// Estilos inline reutilizados en varios componentes de la web pública.
// Se mantiene el enfoque de estilos inline del proyecto original (sin CSS
// modules/Tailwind), pero centralizado aquí para no repetir el mismo objeto
// en cada archivo.
export const inputStyle = {
width: "100%",
boxSizing: "border-box",
padding: "8px 12px",
borderRadius: "var(--border-radius-md)",
border: "0.5px solid var(--color-border-secondary)",
background: "var(--color-background-primary)",
color: "var(--color-text-primary)",
fontSize: 14,
outline: "none",
};
+80
View File
@@ -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),
}));
}
+14
View File
@@ -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,
}
}
}
})