feat: AutoBooking initial commit — PHP WordPress Plugin (REST API, wpdb, WP_User_Query)

This commit is contained in:
2026-07-03 12:15:26 -04:00
commit c2b493117b
21 changed files with 10438 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
# ── WordPress / PHP ────────────────────────────────────────────
__pycache__/
*.pyc
.venv/
env/
# Secrets and local config — NEVER commit
.env
.env.*
wp-config.php
wp-config-local.php
# WordPress core (not part of this plugin repo)
wp-admin/
wp-includes/
wp-content/uploads/
wp-content/cache/
# Build / output artefacts
dist/
build/
output/
# Databases and logs
*.db
*.sqlite
*.log
# PHP tooling caches
.mypy_cache/
.phpunit.result.cache
vendor/
# Node (if any tooling is added later)
node_modules/
package-lock.json
yarn.lock
# Editor and OS files
.DS_Store
Thumbs.db
*.bak
*.swp
*.swo
.idea/
.vscode/
*.code-workspace
+186
View File
@@ -0,0 +1,186 @@
# CHANGES.md — AutoBooking Admin Dashboard
**Fecha:** 2026-05-29
**Autor:** Claude Code (claude-sonnet-4-6)
**Tarea:** PARTE A: fix rol corporate_admin + PARTE B: nuevo plugin autobooking-admin-dashboard
---
## ESTADO INICIAL (antes de tocar nada)
### Plugins existentes
| Plugin | Versión | Archivo principal |
|--------|---------|-------------------|
| autobooking-driver-dashboard | 2.0.0 | autobooking-driver-dashboard.php |
| autobooking-passenger-dashboard | 1.0.0 | autobooking-passenger-dashboard.php |
| autobooking-corporate-dashboard | (sin versión definida) | autobooking-corporate-dashboard.php |
| autobooking-roles | 1.0.0 | autobooking-roles.php |
### wp_snippets
VACÍO. No hay snippets activos. El plugin Code Snippets está instalado pero sin snippets.
### wp_options con prefijo fare/tariff/autobooking
NINGUNA. No existe configuración de tarifas en wp_options.
### Tablas de tarifas dedicadas
NO EXISTEN. Búsqueda de tablas con LIKE '%rate%', '%fare%', '%tarif%', '%price%' devolvió vacío.
### Sistema de tarifas actual (HALLAZGO CRITICO)
Las tarifas están hardcodeadas en cada plugin con inconsistencias:
- autobooking-passenger-dashboard.php linea 317:
$base_fare = 2.50 + $per_mile (usa MILLAS)
- autobooking-corporate-dashboard.php lineas 514-515:
$base = 3.00 + $per_km = 1.80 (usa KILOMETROS)
Conclusion: No existe un sistema centralizado de tarifas. Los valores son diferentes
entre plugins y usan unidades distintas (millas vs km). Ver TODOs al final.
### Bug critico identificado
- autobooking-roles v1.0.0 crea roles 'driver' y 'driver_pending' pero NO 'corporate_admin'
- El corporate dashboard verifica in_array('corporate_admin', $user->roles) -> acceso denegado siempre
### Esquema de tablas verificado
Todas las tablas inspeccionadas. Tabla wp_ab_trips incluye columna country_code char(2).
Tabla wp_ab_company_users: columna user_id bigint(20) NULL (puede o no tener WP user ligado).
---
## CAMBIOS REALIZADOS
### PARTE A -- autobooking-roles.php
[MODIFICADO] -- /wp-content/plugins/autobooking-roles/autobooking-roles.php -- v1.0.0 -> v1.1.0
Que cambio:
- Agregado rol 'corporate_admin' con caps read + read_corporate_dashboard
- Agregada cap custom 'manage_autobooking' asignada a 'administrator'
- Agregada funcion migracion retroactiva abr_migrate_existing_corporate_users() en activation hook
- Version bumped a 1.1.0
CODIGO ORIGINAL COMPLETO (para revertir):
--- INICIO ORIGINAL ---
<?php
/**
* Plugin Name: AutoBooking Roles (Drivers)
* Description: Crea los roles "Driver" y "Pending Driver" y capacidades para controlar acceso a dashboards.
* Version: 1.0.0
* Author: AutoBooking
*/
if (!defined('ABSPATH')) { exit; }
define('ABR_ROLE_DRIVER', 'driver');
define('ABR_ROLE_DRIVER_PENDING', 'driver_pending');
define('ABR_CAP_READ_DRIVER_DASH', 'read_driver_dashboard');
define('ABR_CAP_READ_CUST_DASH', 'read_customer_dashboard');
function abr_register_roles_caps() {
$driver_caps = [ 'read' => true, ABR_CAP_READ_DRIVER_DASH => true ];
if (!get_role(ABR_ROLE_DRIVER)) {
add_role(ABR_ROLE_DRIVER, 'Driver', $driver_caps);
} else {
$r = get_role(ABR_ROLE_DRIVER);
foreach ($driver_caps as $cap => $grant) {
if (!$r->has_cap($cap)) { $r->add_cap($cap, $grant); }
}
}
$pending_caps = [ 'read' => true ];
if (!get_role(ABR_ROLE_DRIVER_PENDING)) {
add_role(ABR_ROLE_DRIVER_PENDING, 'Pending Driver', $pending_caps);
} else {
$r = get_role(ABR_ROLE_DRIVER_PENDING);
foreach ($pending_caps as $cap => $grant) {
if (!$r->has_cap($cap)) { $r->add_cap($cap, $grant); }
}
}
if ($admin = get_role('administrator')) {
if (!$admin->has_cap(ABR_CAP_READ_DRIVER_DASH)) { $admin->add_cap(ABR_CAP_READ_DRIVER_DASH); }
if (!$admin->has_cap(ABR_CAP_READ_CUST_DASH)) { $admin->add_cap(ABR_CAP_READ_CUST_DASH); }
}
}
register_activation_hook(__FILE__, 'abr_register_roles_caps');
add_action('init', 'abr_register_roles_caps');
--- FIN ORIGINAL ---
### PARTE B -- autobooking-admin-dashboard (nuevo plugin)
[ANADIDO] -- /wp-content/plugins/autobooking-admin-dashboard/autobooking-admin-dashboard.php -- v1.0.0
[ANADIDO] -- /wp-content/plugins/autobooking-admin-dashboard/assets/admin-dashboard.css
[ANADIDO] -- /wp-content/plugins/autobooking-admin-dashboard/assets/admin-dashboard.js
Tablas creadas en activacion (via dbDelta):
wp_ab_admin_audit:
id BIGINT UNSIGNED AUTO_INCREMENT PK
admin_user_id BIGINT UNSIGNED
action VARCHAR(60)
target_type VARCHAR(40)
target_id BIGINT UNSIGNED
meta LONGTEXT NULL
created_at DATETIME
Keys: admin_user_id, (target_type,target_id), created_at
wp_ab_fare_config:
id BIGINT UNSIGNED AUTO_INCREMENT PK
country_code CHAR(2) UNIQUE
currency CHAR(3) DEFAULT 'USD'
base_fare DECIMAL(10,2) DEFAULT 3.00
per_km DECIMAL(10,4) DEFAULT 1.80
per_minute DECIMAL(10,4) DEFAULT 0.30
platform_fee_pct DECIMAL(5,4) DEFAULT 0.2000
minimum_fare DECIMAL(10,2) DEFAULT 5.00
active TINYINT(1) DEFAULT 1
updated_at DATETIME
---
## COMO REVERTIR
### Revertir PARTE A:
1. WP Admin -> Plugins -> AutoBooking Roles -> Desactivar
2. Reemplazar contenido del PHP con el CODIGO ORIGINAL de arriba
3. Reactivar
4. Los roles ya creados en DB no se borran (comportamiento seguro de WordPress)
### Revertir PARTE B:
1. WP Admin -> Plugins -> AutoBooking Admin Dashboard -> Desactivar
2. Borrar directorio /wp-content/plugins/autobooking-admin-dashboard/
3. Tablas wp_ab_admin_audit y wp_ab_fare_config quedan en DB (por seguridad)
4. Para borrar tablas manualmente:
DROP TABLE IF EXISTS wp_ab_admin_audit;
DROP TABLE IF EXISTS wp_ab_fare_config;
---
## TODOs / DECISIONES PENDIENTES PARA EL DUENO
1. [DECISION URGENTE] Inconsistencia de unidades en tarifas:
- passenger-dashboard calcula en MILLAS con base $2.50
- corporate-dashboard calcula en KILOMETROS con base $3.00
- El admin dashboard usa km (consistente con la columna fare_distance_m en metros de wp_ab_trips)
- Accion requerida: migrar passenger dashboard a km y unificar valores base
2. [TODO] Verificacion de documentos de conductores:
- La cola de aprobacion muestra datos basicos pero NO documentos (licencia, seguro, foto vehiculo)
- No existe tabla ni columnas para almacenar URLs de documentos en la DB actual
- Accion: crear wp_ab_driver_documents o usar wp_usermeta
3. [TODO] Motor de despacho (matching conductor-pasajero):
- Fuera del alcance de este prompt.
4. [TODO] Stripe Connect / payouts:
- Fuera del alcance. La config de comision existe en el admin pero no ejecuta pagos.
5. [CANDIDATO A ELIMINAR] Tablas duplicadas de rides:
- wp_ab_rides y wp_autobooking_rides parecen versiones antiguas (no usadas por plugins v2)
- NO se borraron. Decision pendiente del dueno.
6. [CANDIDATO A ELIMINAR] wp_ab_scheduled_trips:
- Driver dashboard v2 usa wp_ab_trips con status='assigned' para viajes programados
- wp_ab_scheduled_trips parece una version anterior sin uso activo
- NO se borró. Decision pendiente del dueno.
File diff suppressed because one or more lines are too long
+211
View File
@@ -0,0 +1,211 @@
# Tarea: Crear plugin "autobooking-admin-dashboard" + arreglar bug crítico de rol
Eres un desarrollador senior de WordPress. Trabajas dentro de una plataforma tipo Uber
llamada **AutoBooking**, compuesta por varios plugins. Tu trabajo tiene DOS partes:
(A) arreglar un bug crítico de roles, y (B) crear el panel de administrador del dueño.
**Lee este documento COMPLETO antes de escribir una sola línea de código.** Hay reglas
de auditoría y de reutilización de código existente que son obligatorias y que cambian
la forma en que debes trabajar.
---
## Contexto del sistema existente (NO reinventar)
Plugins actuales que debes tomar como referencia de estilo y convenciones:
- **autobooking-driver-dashboard v2.0** (el más completo)
- **autobooking-passenger-dashboard v1.0**
- **autobooking-corporate-dashboard** ← USA ESTE como referencia de UI:
ya tiene íconos SVG profesionales en tabs (sin emojis), idioma ES/EN, y layout limpio.
El admin dashboard debe verse y comportarse consistente con este.
- **autobooking-roles** (tiene el bug a corregir)
Tablas de base de datos ya existentes (prefijo `wp_`, usar SIEMPRE `$wpdb->prefix`):
- `wp_ab_trips` — viajes
- `wp_ab_driver_status` — online/offline + telemetría GPS
- `wp_ab_driver_sessions` — sesiones de conductor
- `wp_ab_incidents` — incidentes SOS/pánico, incluye audio
- `wp_ab_zone_alerts` — hotspots/bonus
- `wp_ab_zone_alert_responses`
- `wp_ab_companies` — empresas corporativas
- `wp_ab_company_users` — empleados de empresas
- `wp_ab_invoices` — facturas
- `wp_ab_reviews` — ratings
- `wp_ab_scheduled_trips` — viajes programados
- `wp_ab_trip_positions` — posiciones GPS por viaje (**tabla de alto volumen**)
- `wp_autobooking_chat` — chat pasajero-conductor
- `wp_ab_driver_daily` — agregados diarios por conductor
**Antes de escribir código:** inspecciona los plugins existentes para deducir el esquema
EXACTO de cada tabla (nombres de columnas, tipos), las convenciones de naming de
funciones/hooks, y cómo registran sus endpoints AJAX. NO asumas columnas; verifícalas.
---
## ⚠️ REGLA #0 — Registro de cambios y verificación (LEER PRIMERO)
Este sistema ya tiene trabajo previo hecho (snippets, tablas, lógica de tarifas). El mayor
riesgo de esta tarea es **pisar o duplicar algo que ya existe**. Por eso, ANTES de tocar
nada y A MEDIDA que avanzas, mantén un archivo **`CHANGES.md`** en la raíz del trabajo con
TODO lo que toques. No basta con hacer los cambios; se necesita un registro legible por una
persona. Debe incluir:
- **ESTADO INICIAL:** antes de tocar tarifas/roles/cualquier cosa existente, anota qué
encontraste y dónde (qué snippets, en qué tabla, qué columnas, qué versión de plugin).
Pega los nombres exactos de tablas/columnas/funciones/snippets que vas a tocar.
- **POR CADA CAMBIO,** una línea con:
`[AÑADIDO|MODIFICADO|ELIMINADO|EXTENDIDO] — archivo o tabla afectada — qué cambió — por qué`.
- **ELIMINACIONES:** evita borrar. Si algo te parece obsoleto o duplicado, NO lo borres;
márcalo como "candidato a eliminar" en `CHANGES.md` y explica por qué, para que el
dueño decida. Solo borra si es estrictamente necesario y déjalo registrado.
- **SNIPPETS:** si desactivas o modificas un snippet existente, guarda una COPIA del
código original del snippet dentro de `CHANGES.md` antes de tocarlo, para poder revertir.
- **BASE DE DATOS:** por cada `ALTER`/`CREATE TABLE`, registra la sentencia exacta y si es
reversible. Toda creación/alteración debe ir vía `dbDelta` en el hook de activación.
- **AL FINAL:** una sección **"Cómo revertir"** con los pasos para deshacer cada cambio
mayor, y una lista de TODOs/decisiones pendientes que dejaste para el dueño.
> Trata `CHANGES.md` como parte del entregable, no como algo opcional.
> Si un cambio no está en `CHANGES.md`, es como si no hubiera pasado.
---
## PARTE A — Bug crítico en autobooking-roles
**Problema:** el plugin crea los roles `driver` y `driver_pending`, pero NO crea
`corporate_admin`, por lo que las empresas no pueden entrar a su dashboard.
Requisitos del fix:
1. Registrar el rol `corporate_admin` con capabilities mínimas necesarias
(`read` + las custom caps que use el corporate-dashboard; dedúcelas del código).
2. Crear también una capability custom `manage_autobooking` para el admin del dueño,
y asignarla al rol `administrator` de WordPress.
3. **MIGRACIÓN RETROACTIVA:** al activar, recorrer `wp_ab_companies` / `wp_ab_company_users`
y asignar el rol `corporate_admin` a los usuarios corporativos que YA existen y
que no lo tengan. Esto debe ser **idempotente** (correrlo dos veces no duplica nada).
*(Las empresas ya creadas no reciben el rol automáticamente solo con cambiar el
activation hook; por eso esta migración es obligatoria.)*
4. En desactivación NO borrar roles ni datos (solo limpieza de caps temporales si aplica).
5. Subir la versión del plugin y dejar un comentario de changelog.
---
## PARTE B — Plugin nuevo: autobooking-admin-dashboard
Estructura estándar de plugin WP: archivo principal con cabecera, `/includes`, `/assets/css`,
`/assets/js`, carga condicional de scripts solo en la página del dashboard.
**Acceso:** SOLO usuarios con la capability `manage_autobooking`. Verificar en CADA carga de
página y en CADA endpoint AJAX con `current_user_can('manage_autobooking')`; si no, abortar.
El dashboard se renderiza vía shortcode `[autobooking_admin]` en una página, con tabs
(íconos SVG, mismo estilo que corporate-dashboard, soporte ES/EN). Tabs:
### 1. RESUMEN (overview)
- KPIs en (casi) tiempo real: viajes hoy/semana, conductores online ahora,
viajes activos, ingresos del día, incidentes SOS abiertos, conductores pendientes.
- Gráfico de viajes por día (últimos 30 días).
### 2. CONDUCTORES
- Lista de conductores con búsqueda y filtros (online/offline, rating, estado).
- **Cola de APROBACIÓN:** usuarios con rol `driver_pending`. Para cada uno mostrar sus
datos y documentos. Acciones: **Aprobar** (cambia `driver_pending``driver`),
**Rechazar** (con motivo), **Suspender**. Cada acción debe quedar registrada en un
**LOG DE AUDITORÍA** (crear tabla `wp_ab_admin_audit`: `id`, `admin_user_id`, `action`,
`target_type`, `target_id`, `meta` JSON, `created_at`).
- Nota: si el flujo de verificación de documentos (licencia, seguro, tarjeta de
circulación, foto del vehículo) aún no existe, deja la UI preparada con placeholders
claros y un `TODO` marcado; **no inventes columnas**.
### 3. EMPRESAS (corporativo)
- Lista de `wp_ab_companies`. Acción: **Activar cuenta corporativa**, que debe asignar
el rol `corporate_admin` al/los usuario(s) de esa empresa (reusa la lógica de Parte A).
- Ver facturas (`wp_ab_invoices`) por empresa.
### 4. VIAJES
- Tabla de TODOS los viajes (`wp_ab_trips`) con filtros por fecha, estado, conductor,
pasajero/empresa. **Paginación server-side.** Exportar CSV.
- Detalle de viaje: ruta en mapa (`wp_ab_trip_positions`), estados, chat, rating.
### 5. INCIDENTES / SOS
- Lista de `wp_ab_incidents` priorizando los abiertos. Reproducir el audio del SOS.
Mostrar ubicación en mapa, conductor, viaje asociado. Acciones: marcar como
atendido / resuelto, con nota. Registrar en audit log.
### 6. ZONAS
- Crear/editar/desactivar zone alerts (`wp_ab_zone_alerts`) tipo hotspot/bonus,
dibujando el área en el mapa. Ver respuestas (`wp_ab_zone_alert_responses`).
### 7. CONFIGURACIÓN
- **IMPORTANTE — TARIFAS POR PAÍS YA EXISTEN:** el sistema ya tiene un mecanismo de
tarifas por país, implementado como **SNIPPETS** (revisa el plugin "Code Snippets"
si está instalado —tabla `wp_snippets`— y también `functions.php` del tema activo y
del child theme) y como una o más **TABLAS** de base de datos. **NO crees un sistema
nuevo.** Tu trabajo es:
1. **LOCALIZAR** ese sistema: busca en `wp_snippets`, en archivos `functions.php`,
y en `wp_options`/`wp_postmeta` cualquier cosa que contenga
`tarifa`, `fare`, `rate`, `country`, `pais`, `price`, `km`, `base`, `minute`.
2. **IDENTIFICAR** la(s) tabla(s) reales que guardan tarifa por país (estructura
exacta: columnas, tipos, qué país, base, por km, por minuto, moneda, etc.).
3. La UI de configuración debe **LEER Y ESCRIBIR sobre esa estructura existente**,
NO sobre una nueva. Si la estructura está incompleta, EXTIÉNDELA con cuidado
(`dbDelta`, columnas nuevas) en vez de reemplazarla.
4. **Documentar en `CHANGES.md`** qué encontraste y dónde vive cada cosa.
- **Comisión de la plataforma** (% — global o por país si el sistema actual ya lo soporta;
síguelo del mismo modelo que las tarifas por país).
- API key de Google Maps (reutilizar la existente; no duplicar).
- Datos de la plataforma (nombre, logo, email de notificaciones SOS).
- Si encuentras lógica de tarifas DUPLICADA o conflictiva entre snippets y tablas,
**NO borres nada:** documéntalo en `CHANGES.md` y propón cuál debería ser la fuente
única de verdad, dejándolo marcado como decisión pendiente para el dueño.
---
## Requisitos técnicos NO negociables
- **Seguridad:** TODOS los endpoints AJAX con `check_ajax_referer` (nonce) +
`current_user_can`. TODAS las consultas con `$wpdb->prepare`. TODA salida con
`esc_html`/`esc_attr`/`esc_url`/`wp_kses`. Sanitizar TODA entrada
(`sanitize_text_field`, `absint`, etc.).
- La tabla `wp_ab_trip_positions` es de **alto volumen**: usa `LIMIT` y rangos; nunca
`SELECT *` sin acotar. Si notas falta de índices relevantes para las queries del admin,
créalos en activación (con `dbDelta`).
- Crea la tabla `wp_ab_admin_audit` en activación con `dbDelta`.
- Idioma ES/EN igual que corporate-dashboard (mismo mecanismo, no inventes otro).
- Mapa con Google Maps, reusando el patrón de carga de los plugins existentes.
- Íconos SVG inline en tabs (sin emojis). Estética consistente con corporate-dashboard.
- Código comentado, versión del plugin definida, sin warnings de PHP.
---
## Lo que NO debes hacer (fuera de alcance de este prompt)
- NO implementar el motor de matching/despacho automático (irá en otro prompt).
- NO implementar el cálculo de tarifas ni Stripe Connect/payouts (otro prompt).
Solo deja la configuración de comisión/tarifa leyendo y escribiendo sobre el sistema
de tarifas por país que YA existe.
- NO tocar la lógica interna de los otros plugins salvo el fix de roles de la Parte A.
- NO borrar snippets, tablas ni columnas. Marca candidatos a eliminar en `CHANGES.md`.
---
## Entregables
1. Plugin **autobooking-roles** actualizado (Parte A) con changelog.
2. Plugin nuevo **autobooking-admin-dashboard** completo y funcional (Parte B).
3. **`CHANGES.md`** completo (estado inicial, cada cambio, copias de snippets tocados,
sentencias SQL, sección "Cómo revertir", TODOs pendientes).
4. Un breve **`README.md`** con: cómo instalar, qué capability se requiere, qué tablas crea,
y una lista de TODOs/placeholders que dejaste pendientes (verificación de documentos,
integración del motor de despacho, payouts, fuente única de verdad de tarifas si había
conflicto).
---
**Empieza** inspeccionando los plugins, los snippets y el esquema real de las tablas antes
de escribir nada, y abre `CHANGES.md` con el ESTADO INICIAL. Si encuentras una ambigüedad
de esquema que te bloquee, documéntala y elige la opción más segura en vez de adivinar
columnas.
+130
View File
@@ -0,0 +1,130 @@
# Sesión AutoBooking — 2026-05-29 / 2026-05-30
## Qué se hizo en esta sesión
### PARTE A — Fix: autobooking-roles v1.0 → v1.1.0
- Agregado rol `corporate_admin` con cap `read_corporate_dashboard`
- Agregada cap `manage_autobooking` al rol `administrator`
- Migración retroactiva idempotente: asigna `corporate_admin` a usuarios con `ab_company_id` en usermeta
- Backup: `autobooking-roles.php.bak` en el servidor
**Archivo:** `/wp-content/plugins/autobooking-roles/autobooking-roles.php`
---
### PARTE B — Plugin nuevo: autobooking-admin-dashboard v1.0.0
**Shortcode:** `[autobooking_admin]`
**Acceso:** solo capability `manage_autobooking`
**7 Tabs (SVG icons, sin emojis, ES/EN):**
1. RESUMEN — KPIs en tiempo real + gráfico 30 días
2. CONDUCTORES — lista + cola de aprobación (approve/reject/suspend)
3. EMPRESAS — activar/desactivar + ver facturas
4. VIAJES — filtros + exportar CSV + detalle con mapa + chat
5. INCIDENTES — SOS activos + audio + resolver
6. ZONAS — crear/editar/desactivar zone alerts en mapa
7. CONFIG — settings plataforma + tarifas por país
**Tablas creadas:**
- `wp_ab_admin_audit` (id, admin_user_id, action, target_type, target_id, meta JSON, created_at)
- `wp_ab_fare_config` (id, country_code CHAR2 UNIQUE, currency, base_fare, per_km, per_minute, platform_fee_pct, minimum_fare, active, updated_at)
**Índice agregado:** `wp_ab_trip_positions``trip_ts(trip_id, ts)`
**Archivos:**
```
/wp-content/plugins/autobooking-admin-dashboard/
autobooking-admin-dashboard.php
assets/admin-dashboard.css ← glassmorphism = mismo estilo que driver/passenger
assets/admin-dashboard.js
```
**Diseño:** mismas variables CSS que el sistema unificado AutoBooking (`--ab-orange #FF6F00`, `--ab-dark #0b0b0b`, `--ab-glass rgba(17,17,17,.55)`, `--ab-glass-border`). Imagen `car-bg.webp` del driver dashboard como fondo al 6% opacidad.
---
### PARTE C — Plugin nuevo: autobooking-geo-restrict v1.1.0
**Función:** países permitidos configurables desde el admin. Cada usuario solo opera en el país donde está físicamente (bounding boxes GPS, sin API externa).
**Endpoints REST:**
- `GET /autobooking/v1/geo/check?lat=&lng=` — público, retorna `{allowed, country, message}`
- `GET /autobooking/v1/admin/geo-settings` — lee config (requiere `manage_autobooking`)
- `POST /autobooking/v1/admin/geo-settings/save` — guarda `{allowed_countries:[], blocked_message:""}`
**Bloquea (HTTP 403):** conductor al ponerse online, pasajero al reservar, corporate al reservar
**Config en wp_options:**
- `ab_allowed_countries``["US"]` por defecto
- `ab_geo_blocked_msg` → mensaje al usuario bloqueado
**Países con bounding boxes:** US (contiguous+Alaska+Hawaii), CO, MX, CA, GB, ES, AR, BR, CL, PE, EC, VE
**Archivo:**
```
/wp-content/plugins/autobooking-geo-restrict/
autobooking-geo-restrict.php
```
---
## Estado de plugins en servidor
| Plugin | Versión | Estado |
|---|---|---|
| autobooking-roles | 1.1.0 | Activo |
| autobooking-driver-dashboard | 2.0.0 | Activo |
| autobooking-passenger-dashboard | 1.0.0 | Activo |
| autobooking-corporate-dashboard | — | Activo |
| autobooking-admin-dashboard | 1.0.0 | Activo |
| **autobooking-geo-restrict** | 1.1.0 | **PENDIENTE ACTIVAR** |
---
## Hallazgos importantes
**Inconsistencia de tarifas (decisión pendiente del dueño):**
- `passenger-dashboard` calcula en MILLAS con base $2.50
- `corporate-dashboard` calcula en KILÓMETROS con base $3.00
- Nueva tabla `wp_ab_fare_config` usa km — acción requerida: migrar ambos plugins para leer de ahí
**Tablas posiblemente obsoletas (NO borradas, decisión pendiente):**
- `wp_ab_rides` y `wp_autobooking_rides` — versiones antiguas
- `wp_ab_scheduled_trips` — driver v2 usa `wp_ab_trips` con `status='assigned'`
---
## Pendientes próxima sesión
1. Activar `autobooking-geo-restrict` en WP Admin
2. Agregar panel geo-restrict al tab CONFIG del admin dashboard
3. Unificar tarifas: passenger y corporate leyendo de `wp_ab_fare_config`
4. Crear `wp_ab_driver_documents` para documentos de conductores en cola de aprobación
5. Motor de despacho automático (matching conductor-pasajero)
6. Stripe Connect para pagos y payouts
7. Push notifications para conductores al asignar viaje
8. Sistema de rating del pasajero por conductor
---
## Conexión servidor
```
SSH: pi@autobooking.online puerto 2230
Container WP: autobookingonline-wordpress-autobooking-1
Container DB: mariadb | DB: AutoBookingDB
```
## Archivos locales
```
D:\Proyectos Software\AutoBooking\
CLAUDE.md <- contexto permanente
CHANGES.md <- log de cambios detallado
autobooking-roles.php
autobooking-admin-dashboard.php
admin-dashboard.css
admin-dashboard.js
autobooking-geo-restrict.php
Customer-dashboard-original.html <- diseño original pasajero
SESSION-2026-05-30.md <- este archivo
```
+570
View File
@@ -0,0 +1,570 @@
/* ================================================================
AutoBooking Admin Dashboard — CSS v1.2.0
Mismo lenguaje visual que Driver Dashboard y Passenger Dashboard:
glassmorphism, --ab-orange, --ab-dark, fondo oscuro con imagen.
================================================================ */
/* ── Variables — IDÉNTICAS al sistema unificado AutoBooking ─── */
:root {
--ab-orange : #FF6F00;
--ab-orange-600 : #e86500;
--ab-dark : #0b0b0b;
--ab-glass : rgba(17,17,17,.55);
--ab-glass-border : rgba(255,255,255,.13);
--ab-text : #fff;
--ab-green : #22C55E;
--ab-red : #ef4444;
--ab-yellow : #f59e0b;
--ab-blue : #3b82f6;
--ab-radius : 14px;
--ab-radius-sm : 8px;
--ab-font : system-ui,-apple-system,'Segoe UI',Arial,sans-serif;
--ab-mono : 'SF Mono','Fira Code',monospace;
}
/* ── Reset ─────────────────────────────────────────────────── */
#abad-app *,
#abad-app *::before,
#abad-app *::after { box-sizing:border-box; margin:0; padding:0; }
/* ── Raíz — fondo oscuro con imagen de vehículo ────────────── */
#abad-app {
background : var(--ab-dark);
color : var(--ab-text);
font-family : var(--ab-font);
font-size : 14px;
line-height : 1.5;
min-height : 100vh;
position : relative;
-webkit-font-smoothing: antialiased;
}
/* Overlay de fondo — imagen de vehículo sutil */
#abad-app::before {
content : '';
position : fixed;
inset : 0;
background-image : url('/wp-content/plugins/autobooking-driver-dashboard/assets/car-bg.webp');
background-size : cover;
background-position : center 30%;
background-repeat : no-repeat;
opacity : 0.06;
z-index : 0;
pointer-events: none;
}
#abad-app > * { position: relative; z-index: 1; }
/* ── Header ─────────────────────────────────────────────────── */
.abad-header {
display : flex;
align-items : center;
justify-content : space-between;
padding : 0 22px;
height : 58px;
background : rgba(10,10,10,.92);
border-bottom : 1px solid var(--ab-glass-border);
backdrop-filter : blur(12px);
position : sticky;
top : 0;
z-index : 200;
gap : 16px;
}
.abad-header__brand {
display : flex;
align-items : center;
gap : 12px;
flex-shrink : 0;
}
.abad-header__logo { height:30px; border-radius:6px; }
.abad-header__icon {
width : 36px;
height : 36px;
background : rgba(255,111,0,.15);
border : 1px solid rgba(255,111,0,.30);
border-radius : 10px;
display : grid;
place-items : center;
flex-shrink : 0;
}
.abad-header__wordmark { display:flex; flex-direction:column; gap:2px; }
.abad-header__name { font-size:15px; font-weight:800; color:var(--ab-text); letter-spacing:.01em; line-height:1; }
.abad-header__role { font-size:9.5px; color:var(--ab-orange); text-transform:uppercase; letter-spacing:.10em; font-weight:700; line-height:1; }
.abad-header__right { display:flex; align-items:center; gap:12px; }
.abad-header__user {
font-size : 12px;
color : rgba(255,255,255,.55);
background : rgba(255,255,255,.06);
border : 1px solid var(--ab-glass-border);
border-radius: 20px;
padding : 4px 12px;
}
.abad-sos-indicator {
display : flex;
align-items : center;
gap : 6px;
background : rgba(239,68,68,.15);
border : 1px solid rgba(239,68,68,.35);
border-radius : 20px;
padding : 5px 13px;
font-size : 11.5px;
font-weight : 800;
color : var(--ab-red);
cursor : pointer;
animation : ab-alert 1.6s ease-in-out infinite;
}
@keyframes ab-alert {
0%,100% { box-shadow: 0 0 0 0 rgba(239,68,68,0); }
50% { box-shadow: 0 0 0 5px rgba(239,68,68,.15); }
}
.abad-lang-btn {
background : rgba(255,255,255,.07);
border : 1px solid var(--ab-glass-border);
color : rgba(255,255,255,.55);
font-size : 11px;
font-weight : 800;
letter-spacing:.06em;
border-radius: var(--ab-radius-sm);
padding : 5px 11px;
cursor : pointer;
transition : all .15s;
}
.abad-lang-btn:hover { border-color:var(--ab-orange); color:var(--ab-orange); }
/* ── Tab Navigation ─────────────────────────────────────────── */
.abad-tabs {
display : flex;
background : rgba(8,8,8,.85);
border-bottom : 1px solid var(--ab-glass-border);
backdrop-filter : blur(10px);
overflow-x : auto;
scrollbar-width : none;
padding : 0 18px;
gap : 2px;
}
.abad-tabs::-webkit-scrollbar { display:none; }
.abad-tab {
display : flex;
align-items : center;
gap : 7px;
padding : 15px 16px;
background : none;
border : none;
border-bottom : 2px solid transparent;
color : rgba(255,255,255,.35);
font-size : 11px;
font-weight : 700;
letter-spacing : .07em;
cursor : pointer;
white-space : nowrap;
transition : color .15s, border-color .15s;
text-transform : uppercase;
font-family : var(--ab-font);
}
.abad-tab svg { opacity:.5; transition:opacity .15s; }
.abad-tab:hover { color:rgba(255,255,255,.7); }
.abad-tab:hover svg{ opacity:.8; }
.abad-tab--active { color:var(--ab-orange); border-bottom-color:var(--ab-orange); }
.abad-tab--active svg { opacity:1; }
.abad-badge {
display : inline-flex;
align-items : center;
justify-content: center;
min-width : 18px;
height : 18px;
padding : 0 5px;
background : var(--ab-orange);
color : #fff;
font-size : 10px;
font-weight : 800;
border-radius : 9px;
line-height : 1;
}
.abad-badge--red { background: var(--ab-red); }
.abad-badge--inline { margin-left:2px; }
/* ── Panels ─────────────────────────────────────────────────── */
.abad-panel { padding:22px; max-width:1360px; margin:0 auto; }
/* ── KPI Grid ───────────────────────────────────────────────── */
.abad-kpi-grid {
display : grid;
grid-template-columns : repeat(auto-fill,minmax(175px,1fr));
gap : 14px;
margin-bottom : 20px;
}
.abad-kpi {
background : var(--ab-glass);
border : 1px solid var(--ab-glass-border);
border-radius : var(--ab-radius);
padding : 18px 20px 16px;
backdrop-filter : blur(12px);
position : relative;
overflow : hidden;
transition : transform .18s, border-color .18s;
cursor : default;
}
.abad-kpi::before {
content : '';
position : absolute;
top:0; left:0; right:0;
height : 2px;
background: var(--ab-glass-border);
}
.abad-kpi:hover { transform:translateY(-2px); border-color:rgba(255,255,255,.22); }
/* Colored accent per type */
.abad-kpi--or::before { background: var(--ab-orange); }
.abad-kpi--gr::before { background: var(--ab-green); }
.abad-kpi--re::before { background: var(--ab-red); }
.abad-kpi--ye::before { background: var(--ab-yellow); }
.abad-kpi--bl::before { background: var(--ab-blue); }
.abad-kpi--pu::before { background: #a78bfa; }
.abad-kpi--re { border-color:rgba(239,68,68,.25); }
.abad-kpi--ye { border-color:rgba(245,158,11,.22); }
.abad-kpi__icon {
width : 36px;
height : 36px;
border-radius : 10px;
display : grid;
place-items : center;
margin-bottom : 14px;
background : rgba(255,255,255,.07);
border : 1px solid var(--ab-glass-border);
}
.abad-kpi--or .abad-kpi__icon { background:rgba(255,111,0,.15); color:var(--ab-orange); border-color:rgba(255,111,0,.25); }
.abad-kpi--gr .abad-kpi__icon { background:rgba(34,197,94,.12); color:var(--ab-green); border-color:rgba(34,197,94,.22); }
.abad-kpi--re .abad-kpi__icon { background:rgba(239,68,68,.12); color:var(--ab-red); border-color:rgba(239,68,68,.22); }
.abad-kpi--ye .abad-kpi__icon { background:rgba(245,158,11,.12);color:var(--ab-yellow); border-color:rgba(245,158,11,.22); }
.abad-kpi--bl .abad-kpi__icon { background:rgba(59,130,246,.12);color:var(--ab-blue); border-color:rgba(59,130,246,.22); }
.abad-kpi--pu .abad-kpi__icon { background:rgba(167,139,250,.12);color:#a78bfa; border-color:rgba(167,139,250,.22); }
.abad-kpi__val {
font-size : 2.5rem;
font-weight : 800;
color : var(--ab-text);
line-height : 1;
margin-bottom : 6px;
font-feature-settings: "tnum";
letter-spacing : -.02em;
}
.abad-kpi--or .abad-kpi__val { color:var(--ab-orange); }
.abad-kpi--gr .abad-kpi__val { color:var(--ab-green); }
.abad-kpi--re .abad-kpi__val { color:var(--ab-red); }
.abad-kpi--ye .abad-kpi__val { color:var(--ab-yellow); }
.abad-kpi--bl .abad-kpi__val { color:var(--ab-blue); }
.abad-kpi__label {
font-size : 10px;
font-weight : 700;
text-transform : uppercase;
letter-spacing : .08em;
color : rgba(255,255,255,.45);
}
/* ── Glass Card ─────────────────────────────────────────────── */
.abad-card {
background : var(--ab-glass);
border : 1px solid var(--ab-glass-border);
border-radius : var(--ab-radius);
backdrop-filter : blur(10px);
overflow : hidden;
}
.abad-card__header {
display : flex;
align-items : center;
justify-content : space-between;
padding : 13px 18px;
border-bottom : 1px solid var(--ab-glass-border);
}
.abad-card__title {
font-size : 10.5px;
font-weight : 700;
text-transform : uppercase;
letter-spacing : .08em;
color : rgba(255,255,255,.5);
}
.abad-card__body { padding:16px 18px; }
/* Bar chart */
.abad-bar-chart { display:flex; align-items:flex-end; gap:3px; height:72px; }
.abad-bar-chart__bar {
flex : 1;
background : linear-gradient(to top, var(--ab-orange), rgba(255,111,0,.4));
border-radius : 3px 3px 0 0;
min-width : 3px;
opacity : 0.6;
transition : opacity .15s;
cursor : default;
}
.abad-bar-chart__bar:hover { opacity:1; }
.abad-section-title {
font-size : 14px;
font-weight : 800;
color : var(--ab-text);
margin-bottom: 14px;
padding-top : 6px;
}
/* ── Toolbar ─────────────────────────────────────────────────── */
.abad-toolbar { display:flex; align-items:center; gap:8px; margin-bottom:16px; flex-wrap:wrap; }
.abad-spacer { flex:1; }
/* ── Table ───────────────────────────────────────────────────── */
.abad-table-wrap {
background : var(--ab-glass);
border : 1px solid var(--ab-glass-border);
border-radius : var(--ab-radius);
backdrop-filter : blur(8px);
overflow : auto;
margin-bottom : 12px;
}
.abad-table { width:100%; border-collapse:collapse; font-size:13.5px; }
.abad-table thead { background:rgba(0,0,0,.3); }
.abad-table th {
padding : 11px 16px;
text-align : left;
font-size : 10px;
font-weight : 700;
text-transform : uppercase;
letter-spacing : .08em;
color : rgba(255,255,255,.4);
border-bottom : 1px solid var(--ab-glass-border);
white-space : nowrap;
}
.abad-table td {
padding : 13px 16px;
border-bottom : 1px solid rgba(255,255,255,.05);
vertical-align: middle;
color : var(--ab-text);
}
.abad-table tr:last-child td { border-bottom:none; }
.abad-table tbody tr { transition:background .1s; }
.abad-table tbody tr:hover td { background:rgba(255,255,255,.04); }
/* Avatar in table */
.abad-av { display:inline-flex; align-items:center; gap:10px; }
.abad-av__img { width:34px;height:34px;border-radius:50%;object-fit:cover;border:2px solid var(--ab-glass-border);flex-shrink:0; }
.abad-av__init { width:34px;height:34px;border-radius:50%;background:rgba(255,255,255,.08);border:1px solid var(--ab-glass-border);display:grid;place-items:center;font-size:12px;font-weight:700;color:rgba(255,255,255,.6);flex-shrink:0; }
.abad-av__name { font-weight:700;font-size:13.5px;display:block;line-height:1.3; }
.abad-av__sub { font-size:11px;color:rgba(255,255,255,.45);display:block; }
/* Pill badges */
.abad-pill { display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border-radius:20px;font-size:11px;font-weight:700;white-space:nowrap; }
.abad-pill__dot { width:5px;height:5px;border-radius:50%;flex-shrink:0; }
.abad-pill--on { background:rgba(34,197,94,.14); color:var(--ab-green); border:1px solid rgba(34,197,94,.25); }
.abad-pill--on .abad-pill__dot { background:var(--ab-green); box-shadow:0 0 6px var(--ab-green); }
.abad-pill--off { background:rgba(255,255,255,.06); color:rgba(255,255,255,.4); border:1px solid var(--ab-glass-border); }
.abad-pill--off .abad-pill__dot { background:rgba(255,255,255,.3); }
.abad-pill--re { background:rgba(239,68,68,.14); color:var(--ab-red); border:1px solid rgba(239,68,68,.28); }
.abad-pill--ye { background:rgba(245,158,11,.12);color:var(--ab-yellow); border:1px solid rgba(245,158,11,.25); }
.abad-pill--gr { background:rgba(34,197,94,.12); color:var(--ab-green); border:1px solid rgba(34,197,94,.22); }
/* Status chips */
.abad-chip { display:inline-block;padding:3px 9px;border-radius:6px;font-size:11px;font-weight:700;font-family:var(--ab-mono);background:rgba(255,255,255,.08);color:rgba(255,255,255,.6);border:1px solid var(--ab-glass-border); }
.abad-chip--fin { background:rgba(34,197,94,.12); color:var(--ab-green); border-color:rgba(34,197,94,.25); }
.abad-chip--act { background:rgba(255,111,0,.14); color:var(--ab-orange); border-color:rgba(255,111,0,.30); }
.abad-chip--asgn { background:rgba(59,130,246,.12);color:var(--ab-blue); border-color:rgba(59,130,246,.25); }
.abad-chip--can { background:rgba(239,68,68,.12); color:var(--ab-red); border-color:rgba(239,68,68,.28); }
/* ── Item Cards ──────────────────────────────────────────────── */
.abad-cards-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:14px; }
.abad-item-card {
background : var(--ab-glass);
border : 1px solid var(--ab-glass-border);
border-radius : var(--ab-radius);
padding : 18px;
backdrop-filter : blur(8px);
transition : border-color .15s, transform .15s;
}
.abad-item-card:hover { border-color:rgba(255,255,255,.22); transform:translateY(-1px); }
.abad-item-card--re { border-color:rgba(239,68,68,.30); }
.abad-item-card--ye { border-color:rgba(245,158,11,.25); }
.abad-icard-head { display:flex;align-items:flex-start;gap:12px;margin-bottom:12px; }
.abad-icard-av { width:44px;height:44px;border-radius:12px;background:rgba(255,255,255,.08);border:1px solid var(--ab-glass-border);display:grid;place-items:center;font-size:15px;font-weight:800;color:rgba(255,255,255,.6);flex-shrink:0;overflow:hidden; }
.abad-icard-av img { width:100%;height:100%;object-fit:cover; }
.abad-icard-name { font-weight:800;font-size:14px;color:var(--ab-text);line-height:1.3;margin-bottom:2px;display:block; }
.abad-icard-sub { font-size:11px;color:rgba(255,255,255,.45);display:block; }
.abad-icard-meta { font-size:12px;color:rgba(255,255,255,.6);display:flex;flex-direction:column;gap:5px;margin-bottom:14px; }
.abad-icard-meta-r { display:flex;align-items:center;gap:6px; }
.abad-icard-meta-r svg { color:rgba(255,255,255,.35);flex-shrink:0; }
.abad-icard-actions { display:flex;gap:8px;flex-wrap:wrap;padding-top:13px;border-top:1px solid var(--ab-glass-border); }
/* ── Pagination ──────────────────────────────────────────────── */
.abad-pagination { display:flex;gap:4px;margin-top:10px;flex-wrap:wrap;align-items:center; }
.abad-page-btn {
padding : 6px 12px;
background : var(--ab-glass);
border : 1px solid var(--ab-glass-border);
border-radius: var(--ab-radius-sm);
color : rgba(255,255,255,.5);
font-size : 12px;
font-weight : 600;
cursor : pointer;
transition : all .1s;
font-family : var(--ab-mono);
backdrop-filter: blur(6px);
}
.abad-page-btn--active { background:rgba(255,111,0,.18);border-color:rgba(255,111,0,.45);color:var(--ab-orange);font-weight:800; }
.abad-page-btn:hover:not(:disabled) { border-color:rgba(255,255,255,.25);color:var(--ab-text); }
.abad-page-btn:disabled { opacity:.35;cursor:default; }
/* ── Map ─────────────────────────────────────────────────────── */
.abad-map { width:100%;height:300px;border-radius:var(--ab-radius);overflow:hidden;background:rgba(0,0,0,.4);border:1px solid var(--ab-glass-border);margin-bottom:16px; }
.abad-map--modal { height:230px;border-radius:var(--ab-radius) var(--ab-radius) 0 0;margin-bottom:0;border:none;border-bottom:1px solid var(--ab-glass-border); }
/* ── Buttons ─────────────────────────────────────────────────── */
.abad-btn {
display : inline-flex;
align-items : center;
gap : 7px;
padding : 9px 18px;
border-radius : var(--ab-radius-sm);
font-size : 13px;
font-weight : 800;
font-family : var(--ab-font);
cursor : pointer;
border : 2px solid transparent;
transition : all .15s;
white-space : nowrap;
line-height : 1;
letter-spacing : .2px;
text-transform : uppercase;
}
.abad-btn:disabled { opacity:.5;cursor:default;pointer-events:none; }
.abad-btn:active { transform:translateY(1px); }
.abad-btn--brand { background:var(--ab-orange);color:#fff;box-shadow:0 2px 12px rgba(255,111,0,.35); }
.abad-btn--brand:hover { background:var(--ab-orange-600); }
.abad-btn--outline { background:transparent;border-color:var(--ab-glass-border);color:rgba(255,255,255,.7); }
.abad-btn--outline:hover { border-color:rgba(255,255,255,.3);color:var(--ab-text);background:rgba(255,255,255,.05); }
.abad-btn--ghost { background:rgba(255,255,255,.07);color:rgba(255,255,255,.7);border-color:var(--ab-glass-border); }
.abad-btn--ghost:hover { background:rgba(255,255,255,.12);color:var(--ab-text); }
.abad-btn--danger { background:rgba(239,68,68,.15);color:var(--ab-red);border-color:rgba(239,68,68,.30); }
.abad-btn--danger:hover { background:rgba(239,68,68,.25); }
.abad-btn--approve { background:rgba(34,197,94,.14);color:var(--ab-green);border-color:rgba(34,197,94,.28); }
.abad-btn--approve:hover { background:rgba(34,197,94,.24); }
.abad-btn--sm { padding:6px 13px;font-size:11.5px; }
.abad-btn--wide { width:100%;justify-content:center;padding:11px 18px; }
/* ── Forms ───────────────────────────────────────────────────── */
.abad-input,.abad-select,.abad-textarea {
width : 100%;
background : rgba(255,255,255,.07);
border : 1px solid var(--ab-glass-border);
border-radius : var(--ab-radius-sm);
color : var(--ab-text);
padding : 9px 13px;
font-size : 13px;
font-family : var(--ab-font);
outline : none;
transition : border-color .15s, box-shadow .15s;
backdrop-filter : blur(4px);
}
.abad-input:focus,.abad-select:focus,.abad-textarea:focus {
border-color: rgba(255,111,0,.6);
box-shadow : 0 0 0 3px rgba(255,111,0,.10);
}
.abad-input::placeholder,.abad-textarea::placeholder { color:rgba(255,255,255,.25); }
.abad-input--sm,.abad-select--sm { padding:7px 10px;font-size:12px; }
.abad-select { appearance:none;cursor:pointer; }
.abad-textarea { resize:vertical;line-height:1.55; }
.abad-form-group { margin-bottom:14px; }
.abad-label { display:block;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:rgba(255,255,255,.4);margin-bottom:6px; }
.abad-form-row { display:flex;gap:10px;flex-wrap:wrap; }
.abad-form-row .abad-form-group { flex:1;min-width:100px; }
/* ── Alerts ──────────────────────────────────────────────────── */
.abad-alert { padding:10px 14px;border-radius:var(--ab-radius-sm);font-size:12.5px;margin-bottom:12px;backdrop-filter:blur(4px); }
.abad-alert--success { background:rgba(34,197,94,.12);border:1px solid rgba(34,197,94,.25);color:var(--ab-green); }
.abad-alert--info { background:rgba(59,130,246,.10);border:1px solid rgba(59,130,246,.22);color:var(--ab-blue); }
.abad-note { font-size:12px;color:rgba(255,255,255,.4);margin-bottom:14px;line-height:1.6;padding:10px 13px;background:rgba(255,255,255,.05);border-radius:var(--ab-radius-sm);border-left:3px solid var(--ab-glass-border); }
/* Toast */
.abad-toast { position:fixed;bottom:24px;right:24px;z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none; }
.abad-toast__item { padding:13px 18px;border-radius:var(--ab-radius);font-size:13px;font-weight:600;background:rgba(17,17,17,.95);border:1px solid var(--ab-glass-border);color:var(--ab-text);box-shadow:0 8px 32px rgba(0,0,0,.8);pointer-events:auto;animation:ab-toastin .25s ease-out;max-width:320px;backdrop-filter:blur(12px); }
.abad-toast__item--ok { border-color:rgba(34,197,94,.35); }
.abad-toast__item--err { border-color:rgba(239,68,68,.35); }
@keyframes ab-toastin { from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)} }
.abad-empty-state { text-align:center;color:rgba(255,255,255,.35);padding:40px 0;font-size:13px; }
/* ── Settings ────────────────────────────────────────────────── */
.abad-settings-grid { display:grid;grid-template-columns:repeat(auto-fill,minmax(370px,1fr));gap:20px; }
.abad-settings-card { background:var(--ab-glass);border:1px solid var(--ab-glass-border);border-radius:var(--ab-radius);overflow:hidden;backdrop-filter:blur(10px); }
.abad-settings-card__head { padding:14px 18px;border-bottom:1px solid var(--ab-glass-border);background:rgba(0,0,0,.2);font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:rgba(255,255,255,.45); }
.abad-settings-card__body { padding:20px; }
.abad-fare-table { width:100%;border-collapse:collapse;font-size:12px;margin-top:12px; }
.abad-fare-table th { padding:8px 10px;text-align:left;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:rgba(255,255,255,.35);border-bottom:1px solid var(--ab-glass-border); }
.abad-fare-table td { padding:10px 10px;border-bottom:1px solid rgba(255,255,255,.05);color:rgba(255,255,255,.6);font-family:var(--ab-mono); }
.abad-fare-table tr:last-child td { border-bottom:none; }
.abad-fare-table td:first-child { font-weight:800;color:var(--ab-text);font-family:var(--ab-font); }
/* ── Modals ──────────────────────────────────────────────────── */
.abad-overlay {
position : fixed;
inset : 0;
background : rgba(0,0,0,.75);
backdrop-filter : blur(6px);
display : flex;
align-items : center;
justify-content : center;
z-index : 1000;
padding : 20px;
}
.abad-modal {
background : rgba(14,14,14,.97);
border : 1px solid var(--ab-glass-border);
border-radius : 18px;
max-width : 520px;
width : 100%;
max-height : 90vh;
overflow-y : auto;
position : relative;
box-shadow : 0 24px 80px rgba(0,0,0,.9);
backdrop-filter : blur(20px);
}
.abad-modal--wide { max-width:780px; }
.abad-modal__header { display:flex;align-items:center;justify-content:space-between;padding:18px 22px;border-bottom:1px solid var(--ab-glass-border); }
.abad-modal__title { font-size:16px;font-weight:800;color:var(--ab-text); }
.abad-modal__close { width:32px;height:32px;background:rgba(255,255,255,.07);border:1px solid var(--ab-glass-border);border-radius:8px;display:grid;place-items:center;cursor:pointer;color:rgba(255,255,255,.5);transition:all .15s; }
.abad-modal__close:hover { color:var(--ab-text);border-color:rgba(255,255,255,.25); }
.abad-modal__body { padding:22px; }
.abad-modal__footer { display:flex;gap:10px;padding:16px 22px;border-top:1px solid var(--ab-glass-border);background:rgba(0,0,0,.2); }
/* ── Scrollbar ───────────────────────────────────────────────── */
#abad-app ::-webkit-scrollbar { width:5px;height:5px; }
#abad-app ::-webkit-scrollbar-track { background:transparent; }
#abad-app ::-webkit-scrollbar-thumb { background:rgba(255,255,255,.14);border-radius:3px; }
/* ── Responsive ──────────────────────────────────────────────── */
@media(max-width:768px){
.abad-kpi-grid { grid-template-columns:repeat(2,1fr); }
.abad-kpi__val { font-size:1.9rem; }
.abad-panel { padding:14px; }
.abad-header { padding:0 14px; }
.abad-header__user { display:none; }
.abad-settings-grid { grid-template-columns:1fr; }
.abad-cards-grid { grid-template-columns:1fr; }
}
+675
View File
@@ -0,0 +1,675 @@
/* AutoBooking Admin Dashboard — admin-dashboard.js v1.0.0 */
(function () {
'use strict';
const CFG = window.AB_ADMIN_CFG || {};
const API = CFG.api_root + 'autobooking/v1/admin/';
const NONCE = CFG.nonce;
let lang = 'es';
let currentTab = 'overview';
let driversPage = 1, companiesPage = 1, tripsPage = 1, incidentsPage = 1;
let reasonCallback = null;
let currentIncidentId = null;
let zoneMap = null, zoneMarker = null, zoneCircle = null;
let tripMap = null, incidentMap = null, zonesOverviewMap = null;
/* ── API ── */
function apiFetch(path, opts) {
opts = opts || {};
return fetch(API + path, Object.assign({ headers: { 'X-WP-Nonce': NONCE, 'Content-Type': 'application/json' } }, opts)).then(function(r){ return r.json(); });
}
/* ── DOM ── */
function qs(sel, ctx) { return (ctx || document).querySelector(sel); }
function setText(id, val) { var el = qs('#' + id); if (el) el.textContent = val; }
function show(id) { var el = qs('#' + id); if (el) el.style.display = ''; }
function hide(id) { var el = qs('#' + id); if (el) el.style.display = 'none'; }
function applyLang() {
document.querySelectorAll('[data-es]').forEach(function(el) {
el.textContent = lang === 'en' ? (el.dataset.en || el.dataset.es) : el.dataset.es;
});
var btn = qs('#abad-lang-btn');
if (btn) btn.textContent = lang === 'en' ? 'EN' : 'ES';
}
function fmtMoney(v, currency) { return (currency || 'USD') + ' ' + parseFloat(v || 0).toFixed(2); }
function fmtDate(dt) {
if (!dt) return '—';
return new Date(dt).toLocaleString('es-CO', { dateStyle: 'short', timeStyle: 'short' });
}
function escHtml(str) {
if (str == null) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function statusDot(online) {
return '<span class="abad-dot ' + (online ? 'abad-dot--green' : 'abad-dot--gray') + '"></span>';
}
function renderPagination(containerId, currentPage, total, perPage, onClick) {
var container = qs('#' + containerId);
if (!container) return;
var totalPages = Math.ceil(total / perPage) || 1;
container.innerHTML = '';
if (totalPages <= 1) return;
function mkBtn(label, page, active) {
var btn = document.createElement('button');
btn.className = 'abad-page-btn' + (active ? ' abad-page-btn--active' : '');
btn.textContent = label;
btn.disabled = (page < 1 || page > totalPages);
btn.onclick = function() { onClick(page); };
container.appendChild(btn);
}
mkBtn(lang === 'en' ? 'Prev' : 'Ant', currentPage - 1, false);
for (var i = 1; i <= totalPages; i++) {
if (totalPages > 7 && Math.abs(i - currentPage) > 2 && i !== 1 && i !== totalPages) continue;
mkBtn(i, i, i === currentPage);
}
mkBtn(lang === 'en' ? 'Next' : 'Sig', currentPage + 1, false);
}
function darkMapStyle() {
return [
{ elementType: 'geometry', stylers: [{ color: '#212121' }] },
{ elementType: 'labels.text.fill', stylers: [{ color: '#757575' }] },
{ featureType: 'road', elementType: 'geometry', stylers: [{ color: '#2c2c2c' }] },
{ featureType: 'water', elementType: 'geometry', stylers: [{ color: '#000000' }] },
];
}
function downloadCSV(csv, filename) {
var blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url; a.download = filename || 'export.csv';
document.body.appendChild(a); a.click();
document.body.removeChild(a); URL.revokeObjectURL(url);
}
/* ── Tabs ── */
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.abad-tab').forEach(function(b) {
b.classList.toggle('abad-tab--active', b.dataset.tab === tab);
});
document.querySelectorAll('.abad-panel').forEach(function(p) { p.style.display = 'none'; });
var panel = qs('#panel-' + tab);
if (panel) panel.style.display = '';
loadTab(tab);
}
function loadTab(tab) {
if (tab === 'overview') loadOverview();
if (tab === 'drivers') { loadDrivers(); loadPending(); }
if (tab === 'companies') loadCompanies();
if (tab === 'trips') loadTrips();
if (tab === 'incidents') loadIncidents();
if (tab === 'zones') loadZones();
if (tab === 'settings') loadSettings();
}
/* ── Overview ── */
function loadOverview() {
apiFetch('overview').then(function(data) {
setText('kpi-trips-today', data.trips_today != null ? data.trips_today : '—');
setText('kpi-trips-week', data.trips_week != null ? data.trips_week : '—');
setText('kpi-active', data.active_trips != null ? data.active_trips : '—');
setText('kpi-online', data.drivers_online != null ? data.drivers_online : '—');
setText('kpi-revenue', '$' + parseFloat(data.revenue_today || 0).toFixed(2));
setText('kpi-sos', data.sos_open != null ? data.sos_open : 0);
setText('kpi-pending', data.pending_drivers != null ? data.pending_drivers : 0);
var sosWrap = qs('#kpi-sos-wrap');
var pendWrap = qs('#kpi-pending-wrap');
if (sosWrap) sosWrap.style.display = data.sos_open > 0 ? '' : 'none';
if (pendWrap) pendWrap.style.display = data.pending_drivers > 0 ? '' : 'none';
var sosInd = qs('#abad-sos-indicator');
if (sosInd) { sosInd.style.display = data.sos_open > 0 ? 'flex' : 'none'; setText('abad-sos-count', data.sos_open); }
var sosBadge = qs('#tab-sos-badge');
var pendBadge = qs('#tab-pending-badge');
if (sosBadge) { sosBadge.style.display = data.sos_open > 0 ? '' : 'none'; sosBadge.textContent = data.sos_open; }
if (pendBadge) { pendBadge.style.display = data.pending_drivers > 0 ? '' : 'none'; pendBadge.textContent = data.pending_drivers; }
var inline = qs('#pending-count-inline');
if (inline) inline.textContent = data.pending_drivers > 0 ? data.pending_drivers : '';
renderChart(data.chart || []);
});
}
function renderChart(chart) {
var container = qs('#abad-overview-chart');
if (!container) return;
container.innerHTML = '';
if (!chart.length) { container.innerHTML = '<p class="abad-empty-state">Sin datos.</p>'; return; }
var max = Math.max.apply(null, chart.map(function(d){ return parseInt(d.total) || 0; })) || 1;
chart.forEach(function(day) {
var bar = document.createElement('div');
bar.className = 'abad-bar-chart__bar';
var pct = Math.max(4, Math.round((parseInt(day.total || 0) / max) * 100));
bar.style.height = pct + '%';
bar.title = day.the_date + ': ' + day.total + ' viajes | $' + parseFloat(day.revenue || 0).toFixed(2);
container.appendChild(bar);
});
}
/* ── Drivers ── */
function loadDrivers(page) {
if (page) driversPage = page;
var search = (qs('#driver-search') || {}).value || '';
var filter = (qs('#driver-filter') || {}).value || '';
apiFetch('drivers?page=' + driversPage + '&search=' + encodeURIComponent(search) + '&filter=' + filter).then(function(data) {
var tbody = qs('#drivers-tbody');
if (!tbody) return;
if (!data.drivers || !data.drivers.length) {
tbody.innerHTML = '<tr><td colspan="6" class="abad-empty-state">Sin conductores</td></tr>';
return;
}
tbody.innerHTML = data.drivers.map(function(d) {
return '<tr>' +
'<td>' + (d.photo_url ? '<img src="' + escHtml(d.photo_url) + '" style="width:30px;height:30px;border-radius:50%;object-fit:cover;vertical-align:middle;margin-right:8px;">' : '') +
escHtml(d.name) + '<br><span style="font-size:11px;color:var(--ab-muted)">' + escHtml(d.email) + '</span></td>' +
'<td>' + escHtml(d.vehicle_type || '—') + ' ' + escHtml(d.vehicle_plate || '') + '</td>' +
'<td>' + statusDot(d.online) + (d.online ? 'Online' : 'Offline') + '</td>' +
'<td>' + (d.avg_rating > 0 ? '&#9733; ' + d.avg_rating : '—') + '</td>' +
'<td>' + d.trips_total + '</td>' +
'<td><button class="abad-btn abad-btn--outline abad-btn--sm" onclick="abadSuspendDriver(' + d.id + ',\'' + escHtml(d.name) + '\')">Suspender</button></td>' +
'</tr>';
}).join('');
applyLang();
renderPagination('drivers-pagination', driversPage, data.total, data.per_page, loadDrivers);
});
}
function loadPending() {
apiFetch('drivers/pending').then(function(data) {
var list = qs('#abad-pending-list');
var count = (data.drivers || []).length;
var badge = qs('#tab-pending-badge');
var inline = qs('#pending-count-inline');
if (badge) { badge.style.display = count > 0 ? '' : 'none'; badge.textContent = count; }
if (inline) { inline.textContent = count > 0 ? count : ''; }
if (!list) return;
if (!count) { list.innerHTML = '<p class="abad-empty-state">No hay conductores pendientes.</p>'; return; }
list.innerHTML = data.drivers.map(function(d) {
return '<div class="abad-item-card abad-item-card--yellow">' +
'<div class="abad-item-card__name">' + escHtml(d.name) + '</div>' +
'<div class="abad-item-card__meta">' + escHtml(d.email) + ' | ' + escHtml(d.phone || '—') + '<br>' +
'Vehiculo: ' + escHtml(d.vehicle_type || '—') + ' ' + escHtml(d.vehicle_plate || '') + '<br>' +
'Registro: ' + fmtDate(d.registered) + '<br>' +
'<span style="color:var(--ab-blue);font-size:11px">TODO: documentos (ver CHANGES.md)</span>' +
'</div>' +
'<div class="abad-item-card__actions">' +
'<button class="abad-btn abad-btn--brand abad-btn--sm" onclick="abadApproveDriver(' + d.id + ')">Aprobar</button>' +
'<button class="abad-btn abad-btn--outline abad-btn--sm" onclick="abadRejectDriver(' + d.id + ',\'' + escHtml(d.name) + '\')">Rechazar</button>' +
'</div>' +
'</div>';
}).join('');
});
}
window.abadApproveDriver = function(uid) {
if (!confirm(lang === 'en' ? 'Approve driver?' : '¿Aprobar conductor?')) return;
apiFetch('drivers/approve', { method: 'POST', body: JSON.stringify({ user_id: uid }) }).then(function(r) {
if (r.ok) { loadDrivers(); loadPending(); loadOverview(); } else alert(r.message || 'Error');
});
};
window.abadRejectDriver = function(uid, name) {
showReasonModal((lang === 'en' ? 'Reject: ' : 'Rechazar: ') + name, function(reason) {
apiFetch('drivers/reject', { method: 'POST', body: JSON.stringify({ user_id: uid, reason: reason }) }).then(function(r) {
if (r.ok) { loadDrivers(); loadPending(); loadOverview(); } else alert(r.message || 'Error');
});
});
};
window.abadSuspendDriver = function(uid, name) {
showReasonModal((lang === 'en' ? 'Suspend: ' : 'Suspender: ') + name, function(reason) {
apiFetch('drivers/suspend', { method: 'POST', body: JSON.stringify({ user_id: uid, reason: reason }) }).then(function(r) {
if (r.ok) loadDrivers(); else alert(r.message || 'Error');
});
});
};
/* ── Companies ── */
function loadCompanies(page) {
if (page) companiesPage = page;
var search = (qs('#company-search') || {}).value || '';
apiFetch('companies?page=' + companiesPage + '&search=' + encodeURIComponent(search)).then(function(data) {
var tbody = qs('#companies-tbody');
if (!tbody) return;
if (!data.companies || !data.companies.length) {
tbody.innerHTML = '<tr><td colspan="5" class="abad-empty-state">Sin empresas</td></tr>';
return;
}
tbody.innerHTML = data.companies.map(function(c) {
var isActive = c.active == 1;
return '<tr>' +
'<td>' + escHtml(c.name) + '<br><span style="font-size:11px;color:var(--ab-muted)">' + escHtml(c.email) + '</span></td>' +
'<td>' + escHtml(c.contact_name || '—') + '<br><span style="font-size:11px;color:var(--ab-muted)">' + escHtml(c.phone || '') + '</span></td>' +
'<td>' + (c.trips_count || 0) + '</td>' +
'<td><span class="abad-dot ' + (isActive ? 'abad-dot--green' : 'abad-dot--gray') + '"></span>' + (isActive ? 'Activa' : 'Inactiva') + '</td>' +
'<td>' +
(isActive
? '<button class="abad-btn abad-btn--outline abad-btn--sm" onclick="abadDeactivateCompany(' + c.id + ')">Desactivar</button>'
: '<button class="abad-btn abad-btn--brand abad-btn--sm" onclick="abadActivateCompany(' + c.id + ')">Activar</button>') +
' <button class="abad-btn abad-btn--outline abad-btn--sm" onclick="abadCompanyInvoices(' + c.id + ',\'' + escHtml(c.name) + '\')">Facturas</button>' +
'</td>' +
'</tr>';
}).join('');
renderPagination('companies-pagination', companiesPage, data.total, data.per_page, loadCompanies);
});
}
window.abadActivateCompany = function(cid) {
apiFetch('companies/activate', { method: 'POST', body: JSON.stringify({ company_id: cid }) }).then(function(r) {
if (r.ok) loadCompanies(); else alert(r.message || 'Error');
});
};
window.abadDeactivateCompany = function(cid) {
if (!confirm(lang === 'en' ? 'Deactivate company?' : '¿Desactivar empresa?')) return;
apiFetch('companies/deactivate', { method: 'POST', body: JSON.stringify({ company_id: cid }) }).then(function(r) {
if (r.ok) loadCompanies(); else alert(r.message || 'Error');
});
};
window.abadCompanyInvoices = function(cid) {
apiFetch(cid + '/invoices').then(function(data) {
console.log('Facturas empresa ' + cid + ':', data.invoices);
alert(lang === 'en' ? 'Invoices logged to console (' + (data.invoices || []).length + ' records).' : 'Facturas en consola (' + (data.invoices || []).length + ' registros).');
});
};
/* ── Trips ── */
function loadTrips(page) {
if (page) tripsPage = page;
var search = (qs('#trip-search') || {}).value || '';
var from = (qs('#trip-date-from') || {}).value || '';
var to = (qs('#trip-date-to') || {}).value || '';
var status = (qs('#trip-status-filter') || {}).value || '';
var params = 'page=' + tripsPage + '&search=' + encodeURIComponent(search) + '&date_from=' + from + '&date_to=' + to + '&status=' + status;
apiFetch('trips?' + params).then(function(data) {
var tbody = qs('#trips-tbody');
if (!tbody) return;
if (!data.trips || !data.trips.length) { tbody.innerHTML = '<tr><td colspan="8" class="abad-empty-state">Sin viajes</td></tr>'; return; }
tbody.innerHTML = data.trips.map(function(t) {
return '<tr>' +
'<td style="white-space:nowrap">' + fmtDate(t.created_at) + '</td>' +
'<td style="font-family:monospace;font-size:11px">' + escHtml((t.trip_uuid || '').slice(0,10)) + '…</td>' +
'<td>' + escHtml(t.driver_name || '—') + '</td>' +
'<td>' + escHtml(t.passenger_name || '—') + '</td>' +
'<td>' + fmtMoney(t.fare_total_amount, t.currency) + '</td>' +
'<td><span style="font-size:11px;padding:2px 7px;border-radius:20px;background:rgba(255,255,255,.08)">' + escHtml(t.status) + '</span></td>' +
'<td>' + (t.driver_rating ? '&#9733; ' + t.driver_rating : '—') + '</td>' +
'<td><button class="abad-btn abad-btn--outline abad-btn--sm" onclick="abadTripDetail(' + t.id + ')">Ver</button></td>' +
'</tr>';
}).join('');
renderPagination('trips-pagination', tripsPage, data.total, data.per_page, loadTrips);
});
}
window.abadTripDetail = function(tid) {
apiFetch('trips/' + tid).then(function(data) {
var trip = data.trip || {};
show('modal-trip');
var content = qs('#modal-trip-content');
if (content) {
content.innerHTML = '<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:14px;font-size:13px">' +
'<div><strong>Conductor</strong><br>' + escHtml(trip.driver_name || '—') + '</div>' +
'<div><strong>Pasajero</strong><br>' + escHtml(trip.passenger_name || '—') + '</div>' +
'<div><strong>Estado</strong><br>' + escHtml(trip.status || '—') + '</div>' +
'<div><strong>Total</strong><br>' + fmtMoney(trip.fare_total_amount, trip.currency) + '</div>' +
'<div><strong>Comision</strong><br>' + fmtMoney(trip.platform_fee_amount, trip.currency) + '</div>' +
'<div><strong>Neto conductor</strong><br>'+ fmtMoney(trip.driver_payout_amount, trip.currency) + '</div>' +
'<div><strong>Rating</strong><br>' + (trip.driver_rating ? '&#9733; ' + trip.driver_rating : '—') + '</div>' +
'<div><strong>Fecha</strong><br>' + fmtDate(trip.created_at) + '</div>' +
'</div>' +
(data.chat && data.chat.length ? '<div style="margin-top:14px"><strong>Chat</strong><div style="max-height:100px;overflow-y:auto;margin-top:6px">' +
data.chat.map(function(m) { return '<div style="margin-bottom:4px;font-size:12px"><b>' + escHtml(m.sender) + '</b>: ' + escHtml(m.message) + '</div>'; }).join('') +
'</div></div>' : '');
}
setTimeout(function() {
var mapEl = qs('#modal-trip-map');
if (!mapEl || !window.google || !data.positions || !data.positions.length) return;
var mid = data.positions[Math.floor(data.positions.length / 2)];
tripMap = new google.maps.Map(mapEl, { zoom: 13, center: { lat: parseFloat(mid.lat), lng: parseFloat(mid.lng) }, styles: darkMapStyle() });
new google.maps.Polyline({ path: data.positions.map(function(p){ return { lat: parseFloat(p.lat), lng: parseFloat(p.lng) }; }), geodesic: true, strokeColor: '#FF6F00', strokeOpacity: 0.9, strokeWeight: 3, map: tripMap });
}, 150);
});
};
/* ── Incidents ── */
function loadIncidents(page) {
if (page) incidentsPage = page;
var filter = (qs('#incident-filter') || {}).value || 'active';
apiFetch('incidents?filter=' + filter + '&page=' + incidentsPage).then(function(data) {
var list = qs('#abad-incidents-list');
if (!list) return;
if (!data.incidents || !data.incidents.length) { list.innerHTML = '<p class="abad-empty-state">No hay incidentes.</p>'; return; }
list.innerHTML = data.incidents.map(function(inc) {
return '<div class="abad-item-card ' + (inc.status === 'active' ? 'abad-item-card--red' : '') + '">' +
'<div class="abad-item-card__name"><span class="abad-dot ' + (inc.status === 'active' ? 'abad-dot--red' : 'abad-dot--green') + '"></span>' +
escHtml(inc.driver_name || 'Conductor #' + inc.driver_id) + '</div>' +
'<div class="abad-item-card__meta">' + fmtDate(inc.incident_at) + ' | Estado: <strong>' + escHtml(inc.status) + '</strong>' +
(inc.has_audio ? ' | <span style="color:var(--ab-blue)">Audio</span>' : '') +
(inc.notes ? '<br>Nota: ' + escHtml(inc.notes) : '') + '</div>' +
'<div class="abad-item-card__actions">' +
'<button class="abad-btn abad-btn--outline abad-btn--sm" onclick="abadOpenIncident(' + inc.id + ',' + (inc.lat||0) + ',' + (inc.lng||0) + ')">Ver mapa</button>' +
(inc.status === 'active' ?
'<button class="abad-btn abad-btn--brand abad-btn--sm" onclick="abadResolveIncident(' + inc.id + ',\'attended\')">Atendido</button>' +
'<button class="abad-btn abad-btn--outline abad-btn--sm" onclick="abadResolveIncident(' + inc.id + ',\'resolved\')">Resuelto</button>' : '') +
'</div></div>';
}).join('');
renderPagination('incidents-pagination', incidentsPage, data.total, 20, loadIncidents);
});
}
window.abadOpenIncident = function(id, lat, lng) {
currentIncidentId = id;
show('modal-incident');
var mapEl = qs('#modal-incident-map');
if (mapEl && window.google && lat && lng) {
setTimeout(function() {
incidentMap = new google.maps.Map(mapEl, { zoom: 15, center: { lat: parseFloat(lat), lng: parseFloat(lng) }, styles: darkMapStyle() });
new google.maps.Marker({ position: { lat: parseFloat(lat), lng: parseFloat(lng) }, map: incidentMap, icon: { path: google.maps.SymbolPath.CIRCLE, scale: 10, fillColor: '#ef4444', fillOpacity: 1, strokeColor: '#fff', strokeWeight: 2 } });
}, 150);
}
};
window.abadResolveIncident = function(id, status) {
var note = (qs('#incident-note') || {}).value || '';
apiFetch('incidents/' + id + '/resolve', { method: 'POST', body: JSON.stringify({ status: status, note: note }) }).then(function(r) {
if (r.ok) { hide('modal-incident'); loadIncidents(); loadOverview(); } else alert(r.message || 'Error');
});
};
/* ── Zones ── */
function loadZones() {
apiFetch('zones').then(function(zones) {
var list = qs('#abad-zones-list');
if (list) {
list.innerHTML = zones.length ? zones.map(function(z) {
return '<div class="abad-item-card">' +
'<div class="abad-item-card__name"><span class="abad-dot ' + (z.active ? 'abad-dot--green' : 'abad-dot--gray') + '"></span>' +
escHtml(z.title) + ' <span style="font-size:11px;color:var(--ab-muted)">[' + escHtml(z.type) + ']</span></div>' +
'<div class="abad-item-card__meta">Radio: ' + z.radius_km + ' km | Bono: ' + (z.bonus_amount > 0 ? '$'+z.bonus_amount+' '+z.bonus_currency : 'ninguno') +
(z.expires_at ? '<br>Expira: ' + fmtDate(z.expires_at) : '') + '</div>' +
'<div class="abad-item-card__actions">' +
'<button class="abad-btn abad-btn--outline abad-btn--sm" onclick="abadEditZone(' + z.id + ')">Editar</button>' +
(z.active ? '<button class="abad-btn abad-btn--danger abad-btn--sm" onclick="abadDeactivateZone(' + z.id + ')">Desactivar</button>' : '') +
'</div></div>';
}).join('') : '<p class="abad-empty-state">No hay zonas configuradas.</p>';
}
initZonesMap(zones);
});
}
function initZonesMap(zones) {
var mapEl = qs('#abad-zones-map');
if (!mapEl || !window.google) return;
setTimeout(function() {
zonesOverviewMap = new google.maps.Map(mapEl, { zoom: 11, center: { lat: 4.7110, lng: -74.0721 }, styles: darkMapStyle() });
zones.filter(function(z){ return z.active && z.lat && z.lng; }).forEach(function(z) {
new google.maps.Circle({ center: { lat: parseFloat(z.lat), lng: parseFloat(z.lng) }, radius: parseFloat(z.radius_km)*1000, map: zonesOverviewMap, strokeColor: '#FF6F00', strokeOpacity: 0.7, strokeWeight: 2, fillColor: '#FF6F00', fillOpacity: 0.12 });
});
}, 200);
}
window.abadEditZone = function(id) {
apiFetch('zones').then(function(zones) {
var z = null;
for (var i = 0; i < zones.length; i++) { if (zones[i].id == id) { z = zones[i]; break; } }
if (!z) return;
qs('#zone-id').value = z.id;
qs('#zone-title').value = z.title;
qs('#zone-type').value = z.type;
qs('#zone-description').value = z.description || '';
qs('#zone-radius').value = z.radius_km;
qs('#zone-bonus').value = z.bonus_amount;
qs('#zone-countdown').value = z.countdown_seconds;
qs('#zone-expires').value = z.expires_at ? z.expires_at.replace(' ','T').slice(0,16) : '';
qs('#zone-lat').value = z.lat;
qs('#zone-lng').value = z.lng;
show('modal-zone');
initZoneModal(parseFloat(z.lat), parseFloat(z.lng), parseFloat(z.radius_km));
});
};
window.abadDeactivateZone = function(id) {
if (!confirm(lang === 'en' ? 'Deactivate zone?' : '¿Desactivar zona?')) return;
apiFetch('zones/' + id + '/deactivate', { method: 'POST' }).then(function(r) { if (r.ok) loadZones(); });
};
function initZoneModal(lat, lng, radiusKm) {
var mapEl = qs('#zone-modal-map');
if (!mapEl || !window.google) return;
setTimeout(function() {
var center = (lat && lng) ? { lat: lat, lng: lng } : { lat: 4.7110, lng: -74.0721 };
zoneMap = new google.maps.Map(mapEl, { zoom: 12, center: center, styles: darkMapStyle() });
if (lat && lng) {
zoneMarker = new google.maps.Marker({ position: center, map: zoneMap });
zoneCircle = new google.maps.Circle({ center: center, radius: (radiusKm||2)*1000, map: zoneMap, strokeColor: '#FF6F00', strokeOpacity: 0.8, strokeWeight: 2, fillColor: '#FF6F00', fillOpacity: 0.15 });
}
zoneMap.addListener('click', function(e) {
var pos = { lat: e.latLng.lat(), lng: e.latLng.lng() };
qs('#zone-lat').value = pos.lat;
qs('#zone-lng').value = pos.lng;
if (zoneMarker) zoneMarker.setMap(null);
if (zoneCircle) zoneCircle.setMap(null);
zoneMarker = new google.maps.Marker({ position: pos, map: zoneMap });
var r = parseFloat((qs('#zone-radius')||{}).value || 2) * 1000;
zoneCircle = new google.maps.Circle({ center: pos, radius: r, map: zoneMap, strokeColor: '#FF6F00', strokeOpacity: 0.8, strokeWeight: 2, fillColor: '#FF6F00', fillOpacity: 0.15 });
});
}, 200);
}
/* ── Settings ── */
function loadSettings() {
apiFetch('settings').then(function(s) {
if (qs('#cfg-name')) qs('#cfg-name').value = s.platform_name || '';
if (qs('#cfg-logo')) qs('#cfg-logo').value = s.platform_logo || '';
if (qs('#cfg-sos-email')) qs('#cfg-sos-email').value = s.sos_email || '';
if (qs('#cfg-fee-pct')) qs('#cfg-fee-pct').value = (parseFloat(s.platform_fee_pct||0.20)*100).toFixed(1);
});
loadFareConfig();
}
function loadFareConfig() {
apiFetch('fare-config').then(function(rows) {
var list = qs('#abad-fare-list');
if (!list) return;
if (!rows.length) { list.innerHTML = '<p class="abad-empty-state">Sin tarifas.</p>'; return; }
list.innerHTML = '<table class="abad-table" style="margin-top:8px"><thead><tr><th>Pais</th><th>Moneda</th><th>Base</th><th>km</th><th>min</th><th>Comision</th><th>Min.</th></tr></thead><tbody>' +
rows.map(function(r) {
return '<tr><td><strong>' + escHtml(r.country_code) + '</strong></td><td>' + escHtml(r.currency) + '</td>' +
'<td>$' + parseFloat(r.base_fare).toFixed(2) + '</td>' +
'<td>$' + parseFloat(r.per_km).toFixed(3) + '</td>' +
'<td>$' + parseFloat(r.per_minute).toFixed(3) + '</td>' +
'<td>' + (parseFloat(r.platform_fee_pct)*100).toFixed(1) + '%</td>' +
'<td>$' + parseFloat(r.minimum_fare).toFixed(2) + '</td></tr>';
}).join('') + '</tbody></table>';
});
}
/* ── Reason modal ── */
function showReasonModal(title, callback) {
reasonCallback = callback;
var el = qs('#modal-reason-title');
if (el) el.textContent = title;
var txt = qs('#modal-reason-text');
if (txt) txt.value = '';
show('modal-reason');
}
/* ── Init ── */
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.abad-tab').forEach(function(btn) {
btn.addEventListener('click', function(){ switchTab(btn.dataset.tab); });
});
var langBtn = qs('#abad-lang-btn');
if (langBtn) langBtn.addEventListener('click', function() { lang = lang === 'es' ? 'en' : 'es'; applyLang(); });
var driverSearch = qs('#driver-search');
if (driverSearch) driverSearch.addEventListener('keydown', function(e){ if(e.key==='Enter'){ driversPage=1; loadDrivers(); } });
var driverFilter = qs('#driver-filter');
if (driverFilter) driverFilter.addEventListener('change', function(){ driversPage=1; loadDrivers(); });
var btnPending = qs('#btn-show-pending');
if (btnPending) btnPending.addEventListener('click', function() {
var sec = qs('#abad-pending-section');
if (sec) sec.style.display = sec.style.display === 'none' ? '' : 'none';
});
var companySearch = qs('#company-search');
if (companySearch) companySearch.addEventListener('keydown', function(e){ if(e.key==='Enter'){ companiesPage=1; loadCompanies(); } });
var btnTripSearch = qs('#btn-trip-search');
if (btnTripSearch) btnTripSearch.addEventListener('click', function(){ tripsPage=1; loadTrips(); });
var btnExportCSV = qs('#btn-export-trips-csv');
if (btnExportCSV) btnExportCSV.addEventListener('click', function() {
var from = (qs('#trip-date-from')||{}).value||'';
var to = (qs('#trip-date-to')||{}).value||'';
var status = (qs('#trip-status-filter')||{}).value||'';
apiFetch('trips/export-csv?date_from=' + from + '&date_to=' + to + '&status=' + status).then(function(r){ if(r.csv) downloadCSV(r.csv, r.filename||'viajes.csv'); });
});
var incidentFilter = qs('#incident-filter');
if (incidentFilter) incidentFilter.addEventListener('change', function(){ incidentsPage=1; loadIncidents(); });
var btnRefreshInc = qs('#btn-refresh-incidents');
if (btnRefreshInc) btnRefreshInc.addEventListener('click', loadIncidents);
var incClose = qs('#modal-incident-close');
if (incClose) incClose.addEventListener('click', function(){ hide('modal-incident'); });
var btnAttended = qs('#btn-incident-attended');
if (btnAttended) btnAttended.addEventListener('click', function(){ if(currentIncidentId) window.abadResolveIncident(currentIncidentId,'attended'); });
var btnResolved = qs('#btn-incident-resolved');
if (btnResolved) btnResolved.addEventListener('click', function(){ if(currentIncidentId) window.abadResolveIncident(currentIncidentId,'resolved'); });
var tripClose = qs('#modal-trip-close');
if (tripClose) tripClose.addEventListener('click', function(){ hide('modal-trip'); });
var reasonConfirm = qs('#modal-reason-confirm');
if (reasonConfirm) reasonConfirm.addEventListener('click', function() {
var reason = (qs('#modal-reason-text')||{}).value || '';
hide('modal-reason');
if (reasonCallback) { reasonCallback(reason); reasonCallback = null; }
});
var reasonCancel = qs('#modal-reason-cancel');
if (reasonCancel) reasonCancel.addEventListener('click', function(){ hide('modal-reason'); reasonCallback=null; });
var btnNewZone = qs('#btn-new-zone');
if (btnNewZone) btnNewZone.addEventListener('click', function() {
['#zone-id','#zone-title','#zone-description','#zone-expires','#zone-lat','#zone-lng'].forEach(function(s){ var el=qs(s); if(el) el.value=''; });
qs('#zone-radius').value=2; qs('#zone-bonus').value=0; qs('#zone-countdown').value=180;
show('modal-zone');
initZoneModal(0,0,2);
});
var zoneClose = qs('#modal-zone-close');
var zonCancel = qs('#btn-zone-cancel');
if (zoneClose) zoneClose.addEventListener('click', function(){ hide('modal-zone'); });
if (zonCancel) zonCancel.addEventListener('click', function(){ hide('modal-zone'); });
var btnZoneSave = qs('#btn-zone-save');
if (btnZoneSave) btnZoneSave.addEventListener('click', function() {
var lat = parseFloat((qs('#zone-lat')||{}).value||0);
var lng = parseFloat((qs('#zone-lng')||{}).value||0);
var title = (qs('#zone-title')||{}).value||'';
if (!title || !lat || !lng) { alert(lang==='en'?'Click the map and enter a title.':'Haz clic en el mapa y escribe un titulo.'); return; }
var expires = (qs('#zone-expires')||{}).value||'';
var payload = {
id: parseInt((qs('#zone-id')||{}).value||0),
title: title,
description: (qs('#zone-description')||{}).value||'',
type: (qs('#zone-type')||{}).value||'hotspot',
lat: lat, lng: lng,
radius_km: parseFloat((qs('#zone-radius')||{}).value||2),
bonus_amount: parseFloat((qs('#zone-bonus')||{}).value||0),
countdown_seconds: parseInt((qs('#zone-countdown')||{}).value||180),
expires_at: expires ? expires.replace('T',' ') : '',
};
apiFetch('zones/save', { method: 'POST', body: JSON.stringify(payload) }).then(function(r){ if(r.ok){ hide('modal-zone'); loadZones(); } else alert(r.message||'Error'); });
});
var btnSaveSettings = qs('#btn-save-settings');
if (btnSaveSettings) btnSaveSettings.addEventListener('click', function() {
var payload = {
platform_name: (qs('#cfg-name')||{}).value||'',
platform_logo: (qs('#cfg-logo')||{}).value||'',
sos_email: (qs('#cfg-sos-email')||{}).value||'',
platform_fee_pct: parseFloat((qs('#cfg-fee-pct')||{}).value||20)/100,
};
apiFetch('settings/save', { method:'POST', body: JSON.stringify(payload) }).then(function(r){
if(r.ok){ var el=qs('#cfg-success'); if(el){ el.style.display=''; setTimeout(function(){ el.style.display='none'; }, 2500); } }
});
});
var btnSaveFare = qs('#btn-save-fare');
if (btnSaveFare) btnSaveFare.addEventListener('click', function() {
var payload = {
country_code: ((qs('#fare-country')||{}).value||'').toUpperCase(),
currency: ((qs('#fare-currency')||{}).value||'USD').toUpperCase(),
base_fare: parseFloat((qs('#fare-base')||{}).value||3),
per_km: parseFloat((qs('#fare-per-km')||{}).value||1.80),
per_minute: parseFloat((qs('#fare-per-min')||{}).value||0.30),
minimum_fare: parseFloat((qs('#fare-minimum')||{}).value||5),
platform_fee_pct: parseFloat((qs('#cfg-fee-pct')||{}).value||20)/100,
};
apiFetch('fare-config/save', { method:'POST', body: JSON.stringify(payload) }).then(function(r){
if(r.ok){ loadFareConfig(); var el=qs('#fare-success'); if(el){ el.style.display=''; setTimeout(function(){ el.style.display='none'; }, 2500); } }
else alert(r.message||'Error');
});
});
applyLang();
switchTab('overview');
setInterval(function(){ if(currentTab==='overview') loadOverview(); }, 60000);
});
// ── SECURITY TAB ──────────────────────────────────────────
async function loadSecuritySettings() {
try {
const r = await apiFetch('blocked-ips');
const countEl = document.getElementById('sec-blocked-count');
if (countEl) countEl.textContent = r.blocked_24h + ' IPs bloqueadas en las últimas 24h';
const el = document.getElementById('sec-blocked-table');
if (!el) return;
if (!r.blocked.length) { el.innerHTML = '<p style="color:rgba(255,255,255,.4);font-size:13px">Sin IPs bloqueadas.</p>'; return; }
el.innerHTML = '<table class="abad-table"><thead><tr><th>IP</th><th>Motivo</th><th>Expira</th><th></th></tr></thead><tbody>'
+ r.blocked.map(b => '<tr><td>'+b.ip+'</td><td style="font-size:12px">'+b.reason+'</td><td style="font-size:12px">'+b.expires_at+'</td><td><button class="abad-btn abad-btn--outline abad-btn--sm" onclick="absUnblock('+b.id+')">Desbloquear</button></td></tr>').join('')
+ '</tbody></table>';
} catch(e) {}
}
window.absUnblock = async function(id) { await apiFetch('blocked-ips/'+id+'/unblock',{method:'POST'}); loadSecuritySettings(); };
document.getElementById('btn-save-sec') && document.getElementById('btn-save-sec').addEventListener('click', async function() {
await apiFetch('security-settings/save', {method:'POST', body: JSON.stringify({
whitelist: document.getElementById('sec-whitelist').value,
hcaptcha_site_key: document.getElementById('sec-hcaptcha-site').value,
hcaptcha_secret: document.getElementById('sec-hcaptcha-secret').value,
})});
var el = document.getElementById('sec-saved'); el.style.display='block'; setTimeout(function(){el.style.display='none';},3000);
});
document.querySelectorAll('.abad-tab').forEach(function(btn) {
if (btn.dataset.tab === 'settings') btn.addEventListener('click', loadSecuritySettings);
});
})();
+928
View File
@@ -0,0 +1,928 @@
<?php
/**
* Plugin Name: AutoBooking Admin Dashboard
* Description: Panel de control del propietario de la plataforma AutoBooking.
* Gestiona conductores, empresas, viajes, incidentes SOS, zonas y configuracion.
* Version: 1.0.0
* Author: AutoBooking
* Requires at least: 5.9
*/
if ( ! defined( 'ABSPATH' ) ) exit;
class ABAdminDashboard {
const VERSION = '1.0.0';
const NS = 'autobooking/v1';
// API key se lee de wp_options → campo 'ab_gmaps_key' en tab CONFIG
private static $instance = null;
private $plugin_url;
public static function get_instance() {
if ( null === self::$instance ) self::$instance = new self();
return self::$instance;
}
private function __construct() {
$this->plugin_url = plugin_dir_url( __FILE__ );
add_action( 'wp_enqueue_scripts', [ $this, 'enqueue' ] );
add_action( 'rest_api_init', [ $this, 'register_routes' ] );
add_shortcode( 'autobooking_admin', [ $this, 'render' ] );
register_activation_hook( __FILE__, [ $this, 'activate' ] );
}
/* ============================================================
ACTIVACION
============================================================ */
public function activate() {
global $wpdb;
$charset = $wpdb->get_charset_collate();
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}ab_admin_audit (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
admin_user_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(60) NOT NULL DEFAULT '',
target_type VARCHAR(40) NOT NULL DEFAULT '',
target_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
meta LONGTEXT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY admin_user_id (admin_user_id),
KEY target (target_type, target_id),
KEY created_at (created_at)
) $charset;" );
dbDelta( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}ab_fare_config (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
country_code CHAR(2) NOT NULL DEFAULT '',
currency CHAR(3) NOT NULL DEFAULT 'USD',
base_fare DECIMAL(10,2) NOT NULL DEFAULT 3.00,
per_km DECIMAL(10,4) NOT NULL DEFAULT 1.80,
per_minute DECIMAL(10,4) NOT NULL DEFAULT 0.30,
platform_fee_pct DECIMAL(5,4) NOT NULL DEFAULT 0.2000,
minimum_fare DECIMAL(10,2) NOT NULL DEFAULT 5.00,
active TINYINT(1) NOT NULL DEFAULT 1,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY country_code (country_code)
) $charset;" );
// Indice en wp_ab_trip_positions (tabla de alto volumen) si no existe
$idx = $wpdb->get_var( "SHOW INDEX FROM {$wpdb->prefix}ab_trip_positions WHERE Key_name = 'trip_ts'" );
if ( ! $idx ) {
$wpdb->query( "ALTER TABLE {$wpdb->prefix}ab_trip_positions ADD INDEX trip_ts (trip_id, ts)" );
}
}
/* ============================================================
PERMISSION
============================================================ */
private function is_admin() {
return is_user_logged_in() && current_user_can( 'manage_autobooking' );
}
private function perm() {
if ( ! $this->is_admin() ) {
return new WP_Error( 'forbidden', 'Acceso denegado.', [ 'status' => 403 ] );
}
return true;
}
private function audit( $action, $target_type, $target_id, $meta = [] ) {
global $wpdb;
$wpdb->insert( "{$wpdb->prefix}ab_admin_audit", [
'admin_user_id' => get_current_user_id(),
'action' => sanitize_text_field( $action ),
'target_type' => sanitize_text_field( $target_type ),
'target_id' => absint( $target_id ),
'meta' => $meta ? wp_json_encode( $meta ) : null,
'created_at' => current_time( 'mysql' ),
] );
}
/* ============================================================
ENQUEUE
============================================================ */
public function enqueue() {
global $post;
if ( ! is_a( $post, 'WP_Post' ) || ! has_shortcode( $post->post_content, 'autobooking_admin' ) ) return;
if ( ! $this->is_admin() ) return;
wp_enqueue_style( 'abad-style', $this->plugin_url . 'assets/admin-dashboard.css', [], self::VERSION );
wp_enqueue_script( 'google-maps-abad',
'https://maps.googleapis.com/maps/api/js?key=' . esc_attr( get_option( 'ab_gmaps_key', '' ) ) . '&libraries=places,drawing',
[], null, true
);
wp_enqueue_script( 'abad-script', $this->plugin_url . 'assets/admin-dashboard.js', [], self::VERSION, true );
$user = wp_get_current_user();
wp_localize_script( 'abad-script', 'AB_ADMIN_CFG', [
'nonce' => wp_create_nonce( 'wp_rest' ),
'api_root' => esc_url_raw( get_rest_url() ),
'gmaps_key' => esc_attr( get_option( 'ab_gmaps_key', '' ) ),
'user_id' => $user->ID,
'user_name' => esc_html( $user->display_name ),
] );
}
/* ============================================================
REST ROUTES
============================================================ */
public function register_routes() {
$ns = self::NS;
$perm = [ $this, 'perm' ];
register_rest_route( $ns, '/admin/overview', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_overview' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/drivers', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_drivers' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/drivers/pending', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_drivers_pending' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/drivers/approve', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_driver_approve' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/drivers/reject', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_driver_reject' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/drivers/suspend', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_driver_suspend' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/companies', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_companies' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/companies/activate', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_company_activate' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/companies/deactivate',[ 'methods' => 'POST', 'callback' => [ $this, 'rest_company_deactivate' ],'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/companies/(?P<id>\d+)/invoices', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_company_invoices' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/trips', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_trips' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/trips/(?P<id>\d+)', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_trip_detail' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/trips/export-csv', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_trips_csv' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/incidents', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_incidents' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/incidents/(?P<id>\d+)/resolve', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_incident_resolve' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/zones', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_zones' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/zones/save', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_zone_save' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/zones/(?P<id>\d+)/deactivate', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_zone_deactivate' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/settings', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_settings_get' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/settings/save', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_settings_save' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/fare-config', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_fare_get' ], 'permission_callback' => $perm ] );
register_rest_route( $ns, '/admin/fare-config/save', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_fare_save' ], 'permission_callback' => $perm ] );
}
/* ============================================================
OVERVIEW
============================================================ */
public function rest_overview( $req ) {
global $wpdb;
$today_start = date( 'Y-m-d 00:00:00' );
$today_end = date( 'Y-m-d 23:59:59' );
$week_start = date( 'Y-m-d 00:00:00', strtotime( '-6 days' ) );
$trips_today = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_trips WHERE created_at BETWEEN %s AND %s", $today_start, $today_end ) );
$trips_week = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_trips WHERE created_at >= %s", $week_start ) );
$active_trips = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_trips WHERE status IN ('assigned','en_route','waiting','in_progress')" );
$drivers_online = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_driver_status WHERE online = 1" );
$revenue_today = (float) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM(fare_total_amount),0) FROM {$wpdb->prefix}ab_trips WHERE status='finished' AND created_at BETWEEN %s AND %s", $today_start, $today_end ) );
$sos_open = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_incidents WHERE status='active'" );
$pending_drivers= (int) ( new WP_User_Query( [ 'role' => 'driver_pending', 'count_total' => true, 'number' => 0 ] ) )->get_total();
$chart = $wpdb->get_results(
"SELECT DATE(created_at) AS the_date, COUNT(*) AS total, COALESCE(SUM(fare_total_amount),0) AS revenue
FROM {$wpdb->prefix}ab_trips
WHERE created_at >= NOW() - INTERVAL 30 DAY
GROUP BY DATE(created_at) ORDER BY the_date ASC",
ARRAY_A
);
return rest_ensure_response( compact( 'trips_today','trips_week','active_trips','drivers_online','revenue_today','sos_open','pending_drivers' ) + [ 'chart' => $chart ?: [] ] );
}
/* ============================================================
DRIVERS
============================================================ */
public function rest_drivers( $req ) {
$search = sanitize_text_field( $req->get_param( 'search' ) ?: '' );
$filter = sanitize_text_field( $req->get_param( 'filter' ) ?: '' );
$page = max( 1, (int) ( $req->get_param( 'page' ) ?: 1 ) );
$per = 20;
$args = [ 'role' => 'driver', 'number' => $per, 'offset' => ( $page - 1 ) * $per, 'orderby' => 'display_name', 'order' => 'ASC' ];
if ( $search ) { $args['search'] = '*' . $search . '*'; $args['search_columns'] = [ 'display_name', 'user_email' ]; }
$query = new WP_User_Query( $args );
$total = $query->get_total();
$drivers = [];
global $wpdb;
foreach ( $query->get_results() as $user ) {
$uid = $user->ID;
$status = $wpdb->get_row( $wpdb->prepare( "SELECT online, last_seen, last_lat, last_lng FROM {$wpdb->prefix}ab_driver_status WHERE driver_id = %d", $uid ) );
if ( $filter === 'online' && ( ! $status || ! $status->online ) ) continue;
if ( $filter === 'offline' && $status && $status->online ) continue;
$rating = (float) $wpdb->get_var( $wpdb->prepare( "SELECT AVG(driver_rating) FROM {$wpdb->prefix}ab_trips WHERE driver_id = %d AND driver_rating > 0", $uid ) );
$trips_total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_trips WHERE driver_id = %d AND status='finished'", $uid ) );
$drivers[] = [
'id' => $uid,
'name' => $user->display_name,
'email' => $user->user_email,
'phone' => get_user_meta( $uid, 'phone', true ),
'vehicle_type' => get_user_meta( $uid, 'vehicle_type', true ),
'vehicle_plate' => get_user_meta( $uid, 'vehicle_plate', true ) ?: get_user_meta( $uid, 'license_plate', true ),
'photo_url' => get_user_meta( $uid, 'photo_url', true ),
'online' => $status ? (bool) $status->online : false,
'last_seen' => $status ? $status->last_seen : null,
'avg_rating' => round( $rating, 2 ),
'trips_total' => $trips_total,
'registered' => $user->user_registered,
];
}
return rest_ensure_response( compact( 'drivers', 'total', 'page' ) + [ 'per_page' => $per ] );
}
public function rest_drivers_pending( $req ) {
$query = new WP_User_Query( [ 'role' => 'driver_pending', 'number' => 100, 'orderby' => 'registered', 'order' => 'ASC' ] );
$list = [];
foreach ( $query->get_results() as $user ) {
$uid = $user->ID;
$list[] = [
'id' => $uid,
'name' => $user->display_name,
'email' => $user->user_email,
'phone' => get_user_meta( $uid, 'phone', true ),
'vehicle_type' => get_user_meta( $uid, 'vehicle_type', true ),
'vehicle_plate' => get_user_meta( $uid, 'vehicle_plate', true ) ?: get_user_meta( $uid, 'license_plate', true ),
'license_expiry' => get_user_meta( $uid, 'license_expiry', true ),
'insurance_expiry' => get_user_meta( $uid, 'insurance_expiry', true ),
'photo_url' => get_user_meta( $uid, 'photo_url', true ),
'registered' => $user->user_registered,
// TODO: documentos (wp_ab_driver_documents) pendiente de crear - ver CHANGES.md
];
}
return rest_ensure_response( [ 'drivers' => $list, 'total' => count( $list ) ] );
}
public function rest_driver_approve( $req ) {
$uid = absint( $req->get_param( 'user_id' ) );
$user = $uid ? get_userdata( $uid ) : null;
if ( ! $user ) return new WP_Error( 'not_found', 'Usuario no encontrado.', [ 'status' => 404 ] );
if ( ! in_array( 'driver_pending', (array) $user->roles, true ) ) {
return new WP_Error( 'bad_state', 'El usuario no es driver_pending.', [ 'status' => 400 ] );
}
$user->remove_role( 'driver_pending' );
$user->add_role( 'driver' );
$this->audit( 'driver_approve', 'user', $uid );
return rest_ensure_response( [ 'ok' => true, 'msg' => 'Conductor aprobado.' ] );
}
public function rest_driver_reject( $req ) {
$uid = absint( $req->get_param( 'user_id' ) );
$reason = sanitize_textarea_field( $req->get_param( 'reason' ) ?: '' );
$user = $uid ? get_userdata( $uid ) : null;
if ( ! $user ) return new WP_Error( 'not_found', 'Usuario no encontrado.', [ 'status' => 404 ] );
$user->set_role( 'subscriber' );
if ( $reason ) update_user_meta( $uid, 'ab_rejection_reason', $reason );
$this->audit( 'driver_reject', 'user', $uid, [ 'reason' => $reason ] );
return rest_ensure_response( [ 'ok' => true, 'msg' => 'Conductor rechazado.' ] );
}
public function rest_driver_suspend( $req ) {
global $wpdb;
$uid = absint( $req->get_param( 'user_id' ) );
$reason = sanitize_textarea_field( $req->get_param( 'reason' ) ?: '' );
$user = $uid ? get_userdata( $uid ) : null;
if ( ! $user ) return new WP_Error( 'not_found', 'Usuario no encontrado.', [ 'status' => 404 ] );
$user->set_role( 'subscriber' );
update_user_meta( $uid, 'ab_suspended', 1 );
if ( $reason ) update_user_meta( $uid, 'ab_suspension_reason', $reason );
$wpdb->update( "{$wpdb->prefix}ab_driver_status", [ 'online' => 0 ], [ 'driver_id' => $uid ] );
$this->audit( 'driver_suspend', 'user', $uid, [ 'reason' => $reason ] );
return rest_ensure_response( [ 'ok' => true, 'msg' => 'Conductor suspendido.' ] );
}
/* ============================================================
COMPANIES
============================================================ */
public function rest_companies( $req ) {
global $wpdb;
$search = sanitize_text_field( $req->get_param( 'search' ) ?: '' );
$page = max( 1, (int) ( $req->get_param( 'page' ) ?: 1 ) );
$per = 20;
$where = '1=1';
if ( $search ) $where .= $wpdb->prepare( ' AND (name LIKE %s OR email LIKE %s)', '%' . $wpdb->esc_like( $search ) . '%', '%' . $wpdb->esc_like( $search ) . '%' );
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_companies WHERE $where" );
$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}ab_companies WHERE $where ORDER BY created_at DESC LIMIT %d OFFSET %d", $per, ( $page - 1 ) * $per ), ARRAY_A );
foreach ( $rows as &$row ) {
$cid = (int) $row['id'];
$row['trips_count'] = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_trips WHERE company_id = %d", $cid ) );
$wp_uid = $wpdb->get_var( $wpdb->prepare( "SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key='ab_company_id' AND meta_value=%s LIMIT 1", $cid ) );
$row['wp_user_id'] = $wp_uid ? (int) $wp_uid : null;
}
unset( $row );
return rest_ensure_response( [ 'companies' => $rows ?: [], 'total' => $total, 'page' => $page, 'per_page' => $per ] );
}
public function rest_company_activate( $req ) {
global $wpdb;
$cid = absint( $req->get_param( 'company_id' ) );
if ( ! $cid ) return new WP_Error( 'bad_request', 'company_id requerido.', [ 'status' => 400 ] );
$wpdb->update( "{$wpdb->prefix}ab_companies", [ 'active' => 1 ], [ 'id' => $cid ] );
$user_ids = $wpdb->get_col( $wpdb->prepare( "SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key='ab_company_id' AND meta_value=%s", $cid ) );
foreach ( $user_ids as $uid ) {
$u = get_userdata( (int) $uid );
if ( $u && ! in_array( 'corporate_admin', (array) $u->roles, true ) ) $u->add_role( 'corporate_admin' );
}
$this->audit( 'company_activate', 'company', $cid );
return rest_ensure_response( [ 'ok' => true, 'msg' => 'Empresa activada.' ] );
}
public function rest_company_deactivate( $req ) {
global $wpdb;
$cid = absint( $req->get_param( 'company_id' ) );
if ( ! $cid ) return new WP_Error( 'bad_request', 'company_id requerido.', [ 'status' => 400 ] );
$wpdb->update( "{$wpdb->prefix}ab_companies", [ 'active' => 0 ], [ 'id' => $cid ] );
$user_ids = $wpdb->get_col( $wpdb->prepare( "SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key='ab_company_id' AND meta_value=%s", $cid ) );
foreach ( $user_ids as $uid ) { $u = get_userdata( (int) $uid ); if ( $u ) $u->remove_role( 'corporate_admin' ); }
$this->audit( 'company_deactivate', 'company', $cid );
return rest_ensure_response( [ 'ok' => true, 'msg' => 'Empresa desactivada.' ] );
}
public function rest_company_invoices( $req ) {
global $wpdb;
$cid = absint( $req['id'] );
$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}ab_invoices WHERE company_id = %d ORDER BY created_at DESC LIMIT 24", $cid ), ARRAY_A );
return rest_ensure_response( [ 'invoices' => $rows ?: [] ] );
}
/* ============================================================
TRIPS
============================================================ */
public function rest_trips( $req ) {
global $wpdb;
$page = max( 1, (int) ( $req->get_param( 'page' ) ?: 1 ) );
$per = 25;
$status = sanitize_text_field( $req->get_param( 'status' ) ?: '' );
$driver_id = absint( $req->get_param( 'driver_id' ) ?: 0 );
$date_from = sanitize_text_field( $req->get_param( 'date_from' ) ?: '' );
$date_to = sanitize_text_field( $req->get_param( 'date_to' ) ?: '' );
$search = sanitize_text_field( $req->get_param( 'search' ) ?: '' );
$where = '1=1';
if ( $status ) $where .= $wpdb->prepare( ' AND status = %s', $status );
if ( $driver_id ) $where .= $wpdb->prepare( ' AND driver_id = %d', $driver_id );
if ( $date_from ) $where .= $wpdb->prepare( ' AND DATE(created_at) >= %s', $date_from );
if ( $date_to ) $where .= $wpdb->prepare( ' AND DATE(created_at) <= %s', $date_to );
if ( $search ) $where .= $wpdb->prepare( ' AND (passenger_name LIKE %s OR driver_name LIKE %s OR trip_uuid LIKE %s)',
'%' . $wpdb->esc_like( $search ) . '%', '%' . $wpdb->esc_like( $search ) . '%', '%' . $wpdb->esc_like( $search ) . '%'
);
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_trips WHERE $where" );
$rows = $wpdb->get_results( $wpdb->prepare(
"SELECT id, trip_uuid, status, driver_id, driver_name, passenger_name,
fare_total_amount, platform_fee_amount, driver_payout_amount,
currency, driver_rating, created_at, updated_at
FROM {$wpdb->prefix}ab_trips WHERE $where ORDER BY created_at DESC LIMIT %d OFFSET %d",
$per, ( $page - 1 ) * $per
), ARRAY_A );
return rest_ensure_response( [ 'trips' => $rows ?: [], 'total' => $total, 'page' => $page, 'per_page' => $per ] );
}
public function rest_trip_detail( $req ) {
global $wpdb;
$id = absint( $req['id'] );
$trip = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}ab_trips WHERE id = %d", $id ), ARRAY_A );
if ( ! $trip ) return new WP_Error( 'not_found', 'Viaje no encontrado.', [ 'status' => 404 ] );
// Posiciones acotadas a 500 para rendimiento (tabla de alto volumen)
$positions = $wpdb->get_results( $wpdb->prepare(
"SELECT lat, lng, ts FROM {$wpdb->prefix}ab_trip_positions WHERE trip_id = %d ORDER BY ts ASC LIMIT 500",
$id
), ARRAY_A );
$chat = $wpdb->get_results( $wpdb->prepare(
"SELECT sender, message, created_at FROM {$wpdb->prefix}autobooking_chat WHERE ride_id = %d ORDER BY created_at ASC LIMIT 200",
$id
), ARRAY_A );
return rest_ensure_response( [ 'trip' => $trip, 'positions' => $positions ?: [], 'chat' => $chat ?: [] ] );
}
public function rest_trips_csv( $req ) {
global $wpdb;
$date_from = sanitize_text_field( $req->get_param( 'date_from' ) ?: '' );
$date_to = sanitize_text_field( $req->get_param( 'date_to' ) ?: '' );
$status = sanitize_text_field( $req->get_param( 'status' ) ?: '' );
$where = '1=1';
if ( $status ) $where .= $wpdb->prepare( ' AND status = %s', $status );
if ( $date_from ) $where .= $wpdb->prepare( ' AND DATE(created_at) >= %s', $date_from );
if ( $date_to ) $where .= $wpdb->prepare( ' AND DATE(created_at) <= %s', $date_to );
$rows = $wpdb->get_results(
"SELECT id, trip_uuid, created_at, driver_name, passenger_name, fare_total_amount, platform_fee_amount, driver_payout_amount, currency, status, driver_rating
FROM {$wpdb->prefix}ab_trips WHERE $where ORDER BY created_at DESC LIMIT 10000",
ARRAY_A
);
$csv = '"ID","UUID","Fecha","Conductor","Pasajero","Total","Comision","Neto","Moneda","Estado","Rating"' . "\n";
foreach ( $rows as $r ) {
$csv .= implode( ',', array_map( fn($c) => '"' . str_replace( '"', '""', (string) $c ) . '"', array_values( $r ) ) ) . "\n";
}
return rest_ensure_response( [ 'csv' => $csv, 'filename' => 'viajes-' . date( 'Y-m-d' ) . '.csv' ] );
}
/* ============================================================
INCIDENTS
============================================================ */
public function rest_incidents( $req ) {
global $wpdb;
$filter = sanitize_text_field( $req->get_param( 'filter' ) ?: 'active' );
$page = max( 1, (int) ( $req->get_param( 'page' ) ?: 1 ) );
$per = 20;
$where = '1=1';
if ( $filter && $filter !== 'all' ) $where .= $wpdb->prepare( ' AND i.status = %s', $filter );
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}ab_incidents i WHERE $where" );
$rows = $wpdb->get_results( $wpdb->prepare(
"SELECT i.*, u.display_name AS driver_name
FROM {$wpdb->prefix}ab_incidents i
LEFT JOIN {$wpdb->users} u ON u.ID = i.driver_id
WHERE $where ORDER BY (i.status = 'active') DESC, i.incident_at DESC
LIMIT %d OFFSET %d",
$per, ( $page - 1 ) * $per
), ARRAY_A );
$upload_dir = wp_upload_dir();
foreach ( $rows as &$row ) {
$dir = $upload_dir['basedir'] . '/ab-incidents/' . $row['id'];
$row['has_audio'] = is_dir( $dir ) && ! empty( glob( $dir . '/*.webm' ) );
}
unset( $row );
return rest_ensure_response( [ 'incidents' => $rows ?: [], 'total' => $total, 'page' => $page ] );
}
public function rest_incident_resolve( $req ) {
global $wpdb;
$id = absint( $req['id'] );
$note = sanitize_textarea_field( $req->get_param( 'note' ) ?: '' );
$status = in_array( $req->get_param( 'status' ), [ 'resolved', 'attended' ], true ) ? $req->get_param( 'status' ) : 'resolved';
$inc = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}ab_incidents WHERE id = %d", $id ) );
if ( ! $inc ) return new WP_Error( 'not_found', 'Incidente no encontrado.', [ 'status' => 404 ] );
$wpdb->update( "{$wpdb->prefix}ab_incidents", [ 'status' => $status, 'resolved_at' => current_time( 'mysql' ), 'notes' => $note ], [ 'id' => $id ] );
$this->audit( 'incident_' . $status, 'incident', $id, [ 'note' => $note ] );
return rest_ensure_response( [ 'ok' => true ] );
}
/* ============================================================
ZONES
============================================================ */
public function rest_zones( $req ) {
global $wpdb;
return rest_ensure_response( $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}ab_zone_alerts ORDER BY active DESC, created_at DESC", ARRAY_A ) ?: [] );
}
public function rest_zone_save( $req ) {
global $wpdb;
$body = $req->get_json_params();
$id = absint( $body['id'] ?? 0 );
$title = sanitize_text_field( $body['title'] ?? '' );
$lat = (float) ( $body['lat'] ?? 0 );
$lng = (float) ( $body['lng'] ?? 0 );
$type = in_array( $body['type'] ?? '', [ 'hotspot', 'bonus', 'warning' ], true ) ? $body['type'] : 'hotspot';
if ( ! $title || ! $lat || ! $lng ) return new WP_Error( 'missing', 'Titulo, lat y lng son requeridos.', [ 'status' => 400 ] );
$data = [
'title' => $title,
'description' => sanitize_textarea_field( $body['description'] ?? '' ),
'lat' => $lat,
'lng' => $lng,
'radius_km' => max( 0.1, (float) ( $body['radius_km'] ?? 2 ) ),
'bonus_amount' => max( 0, (float) ( $body['bonus_amount'] ?? 0 ) ),
'bonus_currency' => sanitize_text_field( $body['bonus_currency'] ?? 'USD' ),
'countdown_seconds' => max( 60, (int) ( $body['countdown_seconds'] ?? 180 ) ),
'type' => $type,
'active' => 1,
'expires_at' => sanitize_text_field( $body['expires_at'] ?? '' ) ?: null,
];
if ( $id ) {
$wpdb->update( "{$wpdb->prefix}ab_zone_alerts", $data, [ 'id' => $id ] );
$this->audit( 'zone_update', 'zone', $id );
} else {
$data['created_at'] = current_time( 'mysql' );
$wpdb->insert( "{$wpdb->prefix}ab_zone_alerts", $data );
$id = $wpdb->insert_id;
$this->audit( 'zone_create', 'zone', $id );
}
return rest_ensure_response( [ 'ok' => true, 'id' => $id ] );
}
public function rest_zone_deactivate( $req ) {
global $wpdb;
$id = absint( $req['id'] );
$wpdb->update( "{$wpdb->prefix}ab_zone_alerts", [ 'active' => 0 ], [ 'id' => $id ] );
$this->audit( 'zone_deactivate', 'zone', $id );
return rest_ensure_response( [ 'ok' => true ] );
}
/* ============================================================
SETTINGS & FARE CONFIG
============================================================ */
public function rest_settings_get( $req ) {
return rest_ensure_response( [
'platform_name' => get_option( 'ab_platform_name', 'AutoBooking' ),
'platform_logo' => get_option( 'ab_platform_logo', '' ),
'sos_email' => get_option( 'ab_sos_email', get_option( 'admin_email' ) ),
'gmaps_key' => get_option( 'ab_gmaps_key', '' ),
'platform_fee_pct' => (float) get_option( 'ab_platform_fee_pct', 0.20 ),
] );
}
public function rest_settings_save( $req ) {
$body = $req->get_json_params();
if ( isset( $body['platform_name'] ) ) update_option( 'ab_platform_name', sanitize_text_field( $body['platform_name'] ) );
if ( isset( $body['platform_logo'] ) ) update_option( 'ab_platform_logo', esc_url_raw( $body['platform_logo'] ) );
if ( isset( $body['sos_email'] ) ) update_option( 'ab_sos_email', sanitize_email( $body['sos_email'] ) );
if ( isset( $body['gmaps_key'] ) ) update_option( 'ab_gmaps_key', sanitize_text_field( $body['gmaps_key'] ) );
if ( isset( $body['platform_fee_pct'] ) ) update_option( 'ab_platform_fee_pct', max( 0, min( 1, (float) $body['platform_fee_pct'] ) ) );
$this->audit( 'settings_save', 'settings', 0 );
return rest_ensure_response( [ 'ok' => true ] );
}
public function rest_fare_get( $req ) {
global $wpdb;
return rest_ensure_response( $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}ab_fare_config ORDER BY country_code ASC", ARRAY_A ) ?: [] );
}
public function rest_fare_save( $req ) {
global $wpdb;
$body = $req->get_json_params();
$country_code = strtoupper( sanitize_text_field( $body['country_code'] ?? '' ) );
if ( strlen( $country_code ) !== 2 ) return new WP_Error( 'bad_request', 'country_code debe ser 2 letras ISO 3166.', [ 'status' => 400 ] );
$data = [
'country_code' => $country_code,
'currency' => strtoupper( sanitize_text_field( $body['currency'] ?? 'USD' ) ),
'base_fare' => max( 0, (float) ( $body['base_fare'] ?? 3 ) ),
'per_km' => max( 0, (float) ( $body['per_km'] ?? 1.80 ) ),
'per_minute' => max( 0, (float) ( $body['per_minute'] ?? 0.30 ) ),
'platform_fee_pct' => max( 0, min( 1, (float) ( $body['platform_fee_pct'] ?? 0.20 ) ) ),
'minimum_fare' => max( 0, (float) ( $body['minimum_fare'] ?? 5 ) ),
'active' => 1,
'updated_at' => current_time( 'mysql' ),
];
$existing = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}ab_fare_config WHERE country_code = %s", $country_code ) );
if ( $existing ) $wpdb->update( "{$wpdb->prefix}ab_fare_config", $data, [ 'country_code' => $country_code ] );
else $wpdb->insert( "{$wpdb->prefix}ab_fare_config", $data );
$this->audit( 'fare_save', 'fare_config', 0, [ 'country' => $country_code ] );
return rest_ensure_response( [ 'ok' => true ] );
}
/* ============================================================
SHORTCODE RENDER
============================================================ */
public function render( $atts ) {
if ( ! is_user_logged_in() ) { wp_redirect( wp_login_url( get_permalink() ) ); exit; }
if ( ! current_user_can( 'manage_autobooking' ) ) {
return '<div style="background:#0b0b0b;color:#fff;padding:40px;text-align:center;font-family:system-ui;">
<p style="font-size:18px;font-weight:700;color:#FF6F00;">Acceso Restringido</p>
<p style="color:rgba(255,255,255,.6);">Esta seccion es exclusiva para administradores de la plataforma.</p>
<a href="' . esc_url( wp_logout_url( home_url() ) ) . '" style="color:#FF6F00;">Cerrar sesion</a>
</div>';
}
$user = wp_get_current_user();
$logo = get_option( 'ab_platform_logo', '' );
$plat_name = esc_html( get_option( 'ab_platform_name', 'AutoBooking' ) );
ob_start();
?>
<div id="abad-app">
<header class="abad-header">
<div class="abad-header__brand">
<?php if ( $logo ) : ?>
<img src="<?php echo esc_url( $logo ); ?>" alt="logo" class="abad-header__logo">
<?php else : ?>
<div class="abad-header__icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none"><path d="M12 2L2 7l10 5 10-5-10-5z" stroke="#FF6F00" stroke-width="2" stroke-linejoin="round"/><path d="M2 17l10 5 10-5M2 12l10 5 10-5" stroke="#FF6F00" stroke-width="2" stroke-linejoin="round"/></svg>
</div>
<?php endif; ?>
<div class="abad-header__wordmark">
<span class="abad-header__name"><?php echo $plat_name; ?></span>
<span class="abad-header__role">Admin Panel</span>
</div>
</div>
<div class="abad-header__right">
<div class="abad-sos-indicator" id="abad-sos-indicator" style="display:none">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"><path d="M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" stroke="#ef4444" stroke-width="2" stroke-linecap="round"/></svg>
<span id="abad-sos-count">0</span> SOS
</div>
<button class="abad-lang-btn" id="abad-lang-btn">ES</button>
<span class="abad-header__user"><?php echo esc_html( $user->display_name ); ?></span>
</div>
</header>
<nav class="abad-tabs" role="tablist">
<button class="abad-tab abad-tab--active" data-tab="overview" role="tab">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/><rect x="14" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/><rect x="3" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/><rect x="14" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/></svg>
<span data-es="RESUMEN" data-en="OVERVIEW">RESUMEN</span>
</button>
<button class="abad-tab" data-tab="drivers" role="tab">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="8" r="4" stroke="currentColor" stroke-width="2"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span data-es="CONDUCTORES" data-en="DRIVERS">CONDUCTORES</span>
<span class="abad-badge" id="tab-pending-badge" style="display:none"></span>
</button>
<button class="abad-tab" data-tab="companies" role="tab">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none"><path d="M3 21h18M5 21V7l7-4 7 4v14M9 21v-4h6v4M9 11h.01M15 11h.01M12 11h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span data-es="EMPRESAS" data-en="COMPANIES">EMPRESAS</span>
</button>
<button class="abad-tab" data-tab="trips" role="tab">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none"><path d="M5 17H3a2 2 0 01-2-2V5a2 2 0 012-2h11a2 2 0 012 2v3M9 17h8a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 002 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="7.5" cy="17.5" r="2.5" stroke="currentColor" stroke-width="2"/><circle cx="17.5" cy="17.5" r="2.5" stroke="currentColor" stroke-width="2"/></svg>
<span data-es="VIAJES" data-en="TRIPS">VIAJES</span>
</button>
<button class="abad-tab" data-tab="incidents" role="tab">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none"><path d="M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span data-es="INCIDENTES" data-en="INCIDENTS">INCIDENTES</span>
<span class="abad-badge abad-badge--red" id="tab-sos-badge" style="display:none"></span>
</button>
<button class="abad-tab" data-tab="zones" role="tab">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" stroke-dasharray="4 2"/></svg>
<span data-es="ZONAS" data-en="ZONES">ZONAS</span>
</button>
<button class="abad-tab" data-tab="settings" role="tab">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" stroke="currentColor" stroke-width="2"/></svg>
<span data-es="CONFIG" data-en="SETTINGS">CONFIG</span>
</button>
</nav>
<!-- RESUMEN -->
<div class="abad-panel abad-panel--active" id="panel-overview">
<div class="abad-kpi-grid">
<div class="abad-kpi abad-kpi--bl">
<div class="abad-kpi__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M5 17H3a2 2 0 01-2-2V5a2 2 0 012-2h11a2 2 0 012 2v3M9 17h8a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 002 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="7.5" cy="17.5" r="2.5" stroke="currentColor" stroke-width="2"/><circle cx="17.5" cy="17.5" r="2.5" stroke="currentColor" stroke-width="2"/></svg></div>
<div class="abad-kpi__val" id="kpi-trips-today">—</div>
<div class="abad-kpi__label" data-es="Viajes hoy" data-en="Trips today">Viajes hoy</div>
</div>
<div class="abad-kpi abad-kpi--pu">
<div class="abad-kpi__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2"/><path d="M16 2v4M8 2v4M3 10h18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></div>
<div class="abad-kpi__val" id="kpi-trips-week">—</div>
<div class="abad-kpi__label" data-es="Esta semana" data-en="This week">Esta semana</div>
</div>
<div class="abad-kpi"><div class="abad-kpi__label" data-es="Activos ahora" data-en="Active now">Activos ahora</div><div class="abad-kpi__val abad-kpi__val--orange" id="kpi-active">—</div></div>
<div class="abad-kpi"><div class="abad-kpi__label" data-es="Conductores online" data-en="Drivers online">Conductores online</div><div class="abad-kpi__val abad-kpi__val--green" id="kpi-online">—</div></div>
<div class="abad-kpi abad-kpi--or">
<div class="abad-kpi__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></div>
<div class="abad-kpi__val" id="kpi-active">—</div>
<div class="abad-kpi__label" data-es="Activos ahora" data-en="Active now">Activos ahora</div>
</div>
<div class="abad-kpi abad-kpi--gr">
<div class="abad-kpi__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="8" r="4" stroke="currentColor" stroke-width="2"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M17 11l2 2 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<div class="abad-kpi__val" id="kpi-online">—</div>
<div class="abad-kpi__label" data-es="Conductores online" data-en="Drivers online">Conductores online</div>
</div>
<div class="abad-kpi abad-kpi--gr">
<div class="abad-kpi__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><line x1="12" y1="1" x2="12" y2="23" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></div>
<div class="abad-kpi__val" id="kpi-revenue">—</div>
<div class="abad-kpi__label" data-es="Ingresos hoy" data-en="Revenue today">Ingresos hoy</div>
</div>
<div class="abad-kpi abad-kpi--re" id="kpi-sos-wrap">
<div class="abad-kpi__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></div>
<div class="abad-kpi__val" id="kpi-sos">—</div>
<div class="abad-kpi__label" data-es="SOS activos" data-en="Active SOS">SOS activos</div>
</div>
<div class="abad-kpi abad-kpi--ye" id="kpi-pending-wrap">
<div class="abad-kpi__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="8" r="4" stroke="currentColor" stroke-width="2"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="19" cy="11" r="3" stroke="currentColor" stroke-width="2"/><path d="M19 9v2l1 1" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></div>
<div class="abad-kpi__val" id="kpi-pending">—</div>
<div class="abad-kpi__label" data-es="Pendientes aprobacion" data-en="Pending">Pendientes</div>
</div>
</div>
<div class="abad-card">
<div class="abad-card__header">
<span class="abad-card__title" data-es="Viajes - Ultimos 30 dias" data-en="Trips - Last 30 days">Viajes — Ultimos 30 dias</span>
</div>
<div class="abad-card__body">
<div id="abad-overview-chart" class="abad-bar-chart"></div>
</div>
</div>
</div>
<!-- CONDUCTORES -->
<div class="abad-panel" id="panel-drivers" style="display:none">
<div class="abad-toolbar">
<input type="text" class="abad-input" id="driver-search" placeholder="Buscar conductor..." style="max-width:260px">
<select class="abad-select" id="driver-filter">
<option value="">Todos</option>
<option value="online">Online</option>
<option value="offline">Offline</option>
</select>
<div style="flex:1"></div>
<button class="abad-btn abad-btn--outline" id="btn-show-pending">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/><path d="M12 8v4M12 16h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span data-es="Cola de aprobacion" data-en="Approval queue">Cola de aprobacion</span>
<span class="abad-badge abad-badge--inline" id="pending-count-inline"></span>
</button>
</div>
<div class="abad-table-wrap">
<table class="abad-table"><thead><tr>
<th data-es="Conductor" data-en="Driver">Conductor</th>
<th data-es="Vehiculo" data-en="Vehicle">Vehiculo</th>
<th data-es="Estado" data-en="Status">Estado</th>
<th data-es="Rating" data-en="Rating">Rating</th>
<th data-es="Viajes" data-en="Trips">Viajes</th>
<th data-es="Acciones" data-en="Actions">Acciones</th>
</tr></thead><tbody id="drivers-tbody"></tbody></table>
</div>
<div class="abad-pagination" id="drivers-pagination"></div>
<div id="abad-pending-section" style="display:none;margin-top:24px">
<h3 class="abad-section-title" data-es="Conductores pendientes de aprobacion" data-en="Pending approval">Conductores pendientes de aprobacion</h3>
<div id="abad-pending-list" class="abad-cards-grid"></div>
</div>
</div>
<!-- EMPRESAS -->
<div class="abad-panel" id="panel-companies" style="display:none">
<div class="abad-toolbar">
<input type="text" class="abad-input" id="company-search" placeholder="Buscar empresa..." style="max-width:260px">
</div>
<div class="abad-table-wrap">
<table class="abad-table"><thead><tr>
<th data-es="Empresa" data-en="Company">Empresa</th>
<th data-es="Contacto" data-en="Contact">Contacto</th>
<th data-es="Viajes" data-en="Trips">Viajes</th>
<th data-es="Estado" data-en="Status">Estado</th>
<th data-es="Acciones" data-en="Actions">Acciones</th>
</tr></thead><tbody id="companies-tbody"></tbody></table>
</div>
<div class="abad-pagination" id="companies-pagination"></div>
</div>
<!-- VIAJES -->
<div class="abad-panel" id="panel-trips" style="display:none">
<div class="abad-toolbar abad-toolbar--wrap">
<input type="text" class="abad-input abad-input--sm" id="trip-search" placeholder="UUID, conductor, pasajero...">
<input type="date" class="abad-input abad-input--sm" id="trip-date-from">
<input type="date" class="abad-input abad-input--sm" id="trip-date-to">
<select class="abad-select abad-select--sm" id="trip-status-filter">
<option value="">Todos los estados</option>
<option value="assigned">assigned</option>
<option value="en_route">en_route</option>
<option value="waiting">waiting</option>
<option value="in_progress">in_progress</option>
<option value="finished">finished</option>
<option value="canceled">canceled</option>
</select>
<button class="abad-btn abad-btn--brand abad-btn--sm" id="btn-trip-search" data-es="Buscar" data-en="Search">Buscar</button>
<button class="abad-btn abad-btn--outline abad-btn--sm" id="btn-export-trips-csv" data-es="Exportar CSV" data-en="Export CSV">Exportar CSV</button>
</div>
<div class="abad-table-wrap">
<table class="abad-table"><thead><tr>
<th>Fecha</th><th>UUID</th><th>Conductor</th><th>Pasajero</th>
<th>Total</th><th>Estado</th><th>Rating</th><th></th>
</tr></thead><tbody id="trips-tbody"></tbody></table>
</div>
<div class="abad-pagination" id="trips-pagination"></div>
</div>
<!-- INCIDENTES -->
<div class="abad-panel" id="panel-incidents" style="display:none">
<div class="abad-toolbar">
<select class="abad-select" id="incident-filter">
<option value="active" data-es="Activos (SOS)" data-en="Active (SOS)">Activos (SOS)</option>
<option value="attended" data-es="Atendidos" data-en="Attended">Atendidos</option>
<option value="resolved" data-es="Resueltos" data-en="Resolved">Resueltos</option>
<option value="all" data-es="Todos" data-en="All">Todos</option>
</select>
<button class="abad-btn abad-btn--outline abad-btn--sm" id="btn-refresh-incidents">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
Actualizar
</button>
</div>
<div id="abad-incidents-list" class="abad-cards-grid"></div>
<div class="abad-pagination" id="incidents-pagination"></div>
</div>
<!-- ZONAS -->
<div class="abad-panel" id="panel-zones" style="display:none">
<div class="abad-toolbar">
<button class="abad-btn abad-btn--brand" id="btn-new-zone">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span data-es="Nueva zona" data-en="New zone">Nueva zona</span>
</button>
</div>
<div id="abad-zones-map" class="abad-map"></div>
<div id="abad-zones-list" class="abad-cards-grid" style="margin-top:16px"></div>
</div>
<!-- CONFIG -->
<div class="abad-panel" id="panel-settings" style="display:none">
<div class="abad-settings-grid">
<div class="abad-card">
<div class="abad-card__title" data-es="Plataforma" data-en="Platform">Plataforma</div>
<div class="abad-form-group"><label class="abad-label" data-es="Nombre" data-en="Name">Nombre</label><input type="text" class="abad-input" id="cfg-name"></div>
<div class="abad-form-group"><label class="abad-label">Logo URL</label><input type="url" class="abad-input" id="cfg-logo"></div>
<div class="abad-form-group"><label class="abad-label" data-es="Email SOS" data-en="SOS email">Email SOS</label><input type="email" class="abad-input" id="cfg-sos-email"></div>
<div class="abad-form-group"><label class="abad-label" data-es="Comision global (%)" data-en="Global commission (%)">Comision global (%)</label><input type="number" class="abad-input" id="cfg-fee-pct" min="0" max="100" step="0.1"></div>
<div class="abad-alert abad-alert--success" id="cfg-success" style="display:none">Guardado.</div>
<button class="abad-btn abad-btn--brand abad-btn--wide" id="btn-save-settings" data-es="GUARDAR" data-en="SAVE">GUARDAR</button>
</div>
<div class="abad-card">
<div class="abad-card__title" data-es="Tarifas por pais" data-en="Fares by country">Tarifas por pais</div>
<p class="abad-note" data-es="Fuente central de tarifas. Requiere actualizacion de plugins de pasajero y corporativo para leerla (ver CHANGES.md)." data-en="Central fare source. Requires updating passenger and corporate plugins to read it (see CHANGES.md).">Fuente central de tarifas. Ver CHANGES.md para informacion sobre la inconsistencia actual (millas vs km).</p>
<div class="abad-form-row">
<div class="abad-form-group"><label class="abad-label">Pais (ISO)</label><input type="text" class="abad-input" id="fare-country" maxlength="2" style="text-transform:uppercase"></div>
<div class="abad-form-group"><label class="abad-label">Moneda</label><input type="text" class="abad-input" id="fare-currency" maxlength="3" style="text-transform:uppercase" placeholder="USD"></div>
</div>
<div class="abad-form-row">
<div class="abad-form-group"><label class="abad-label" data-es="Base ($)" data-en="Base ($)">Base ($)</label><input type="number" class="abad-input" id="fare-base" min="0" step="0.01"></div>
<div class="abad-form-group"><label class="abad-label" data-es="Por km ($)" data-en="Per km ($)">Por km</label><input type="number" class="abad-input" id="fare-per-km" min="0" step="0.001"></div>
<div class="abad-form-group"><label class="abad-label" data-es="Por min ($)" data-en="Per min ($)">Por min</label><input type="number" class="abad-input" id="fare-per-min" min="0" step="0.001"></div>
<div class="abad-form-group"><label class="abad-label" data-es="Minimo ($)" data-en="Min ($)">Minimo</label><input type="number" class="abad-input" id="fare-minimum" min="0" step="0.01"></div>
</div>
<div class="abad-alert abad-alert--success" id="fare-success" style="display:none">Tarifa guardada.</div>
<button class="abad-btn abad-btn--brand" id="btn-save-fare" data-es="GUARDAR TARIFA" data-en="SAVE FARE">GUARDAR TARIFA</button>
<div class="abad-card__title" style="margin-top:20px" data-es="Tarifas configuradas" data-en="Configured fares">Tarifas configuradas</div>
<div id="abad-fare-list"></div>
</div>
<div class="abad-card">
<div class="abad-card__title">Seguridad</div>
<div class="abad-form-group"><label class="abad-label">IPs en whitelist (una por línea)</label><textarea class="abad-textarea" id="sec-whitelist" rows="3" placeholder="127.0.0.1"></textarea></div>
<div class="abad-form-group"><label class="abad-label">hCaptcha Site Key</label><input type="text" class="abad-input" id="sec-hcaptcha-site"></div>
<div class="abad-form-group"><label class="abad-label">hCaptcha Secret Key</label><input type="password" class="abad-input" id="sec-hcaptcha-secret"></div>
<div class="abad-alert abad-alert--success" id="sec-saved" style="display:none">Guardado.</div>
<button class="abad-btn abad-btn--brand" id="btn-save-sec">GUARDAR SEGURIDAD</button>
<div class="abad-card__title" style="margin-top:20px">IPs bloqueadas</div>
<p class="abad-note" id="sec-blocked-count">—</p>
<div id="sec-blocked-table"></div>
</div>
</div>
</div>
<!-- MODALES -->
<div class="abad-overlay" id="modal-reason" style="display:none">
<div class="abad-modal">
<h3 id="modal-reason-title">Motivo</h3>
<textarea class="abad-textarea" id="modal-reason-text" rows="3" placeholder="Motivo (opcional)..."></textarea>
<div class="abad-modal__actions">
<button class="abad-btn abad-btn--danger" id="modal-reason-confirm">Confirmar</button>
<button class="abad-btn abad-btn--outline" id="modal-reason-cancel">Cancelar</button>
</div>
</div>
</div>
<div class="abad-overlay" id="modal-trip" style="display:none">
<div class="abad-modal abad-modal--wide">
<button class="abad-modal__close" id="modal-trip-close"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>
<h3 data-es="Detalle del viaje" data-en="Trip detail">Detalle del viaje</h3>
<div id="modal-trip-map" class="abad-map abad-map--modal"></div>
<div id="modal-trip-content"></div>
</div>
</div>
<div class="abad-overlay" id="modal-incident" style="display:none">
<div class="abad-modal">
<button class="abad-modal__close" id="modal-incident-close"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>
<h3 data-es="Incidente SOS" data-en="SOS Incident">Incidente SOS</h3>
<div id="modal-incident-map" class="abad-map abad-map--modal"></div>
<div id="modal-incident-content"></div>
<div class="abad-form-group" style="margin-top:12px">
<label class="abad-label" data-es="Nota de resolucion" data-en="Resolution note">Nota de resolucion</label>
<textarea class="abad-textarea" id="incident-note" rows="2"></textarea>
</div>
<div class="abad-modal__actions">
<button class="abad-btn abad-btn--brand" id="btn-incident-attended" data-es="Marcar atendido" data-en="Mark attended">Marcar atendido</button>
<button class="abad-btn abad-btn--outline" id="btn-incident-resolved" data-es="Marcar resuelto" data-en="Mark resolved">Marcar resuelto</button>
</div>
</div>
</div>
<div class="abad-overlay" id="modal-zone" style="display:none">
<div class="abad-modal abad-modal--wide">
<button class="abad-modal__close" id="modal-zone-close"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>
<h3 id="zone-modal-title" data-es="Zona de alerta" data-en="Alert zone">Zona de alerta</h3>
<input type="hidden" id="zone-id">
<div id="zone-modal-map" class="abad-map abad-map--modal"></div>
<div class="abad-form-row">
<div class="abad-form-group" style="flex:2"><label class="abad-label">Titulo</label><input type="text" class="abad-input" id="zone-title"></div>
<div class="abad-form-group"><label class="abad-label">Tipo</label>
<select class="abad-select" id="zone-type"><option value="hotspot">Hotspot</option><option value="bonus">Bonus</option><option value="warning">Warning</option></select>
</div>
</div>
<div class="abad-form-group"><label class="abad-label">Descripcion</label><textarea class="abad-textarea" id="zone-description" rows="2"></textarea></div>
<div class="abad-form-row">
<div class="abad-form-group"><label class="abad-label">Radio (km)</label><input type="number" class="abad-input" id="zone-radius" min="0.1" step="0.1" value="2"></div>
<div class="abad-form-group"><label class="abad-label">Bono ($)</label><input type="number" class="abad-input" id="zone-bonus" min="0" step="0.01" value="0"></div>
<div class="abad-form-group"><label class="abad-label">Countdown (s)</label><input type="number" class="abad-input" id="zone-countdown" min="60" value="180"></div>
</div>
<div class="abad-form-group"><label class="abad-label">Expira (opcional)</label><input type="datetime-local" class="abad-input" id="zone-expires"></div>
<p class="abad-note" data-es="Haz clic en el mapa para posicionar la zona." data-en="Click the map to position the zone.">Haz clic en el mapa para posicionar la zona.</p>
<input type="hidden" id="zone-lat">
<input type="hidden" id="zone-lng">
<div class="abad-modal__actions">
<button class="abad-btn abad-btn--brand" id="btn-zone-save" data-es="Guardar zona" data-en="Save zone">Guardar zona</button>
<button class="abad-btn abad-btn--outline" id="btn-zone-cancel">Cancelar</button>
</div>
</div>
</div>
</div>
<?php
return ob_get_clean();
}
}
ABAdminDashboard::get_instance();
+387
View File
@@ -0,0 +1,387 @@
<?php
/**
* Plugin Name: AutoBooking Command Center
* Description: Centro de control operativo en tiempo real para supervisores de despacho
* Version: 1.0.0
* Author: AutoBooking
*/
if (!defined('ABSPATH')) exit;
/* ── Enmascaramiento de teléfonos ─────────────────────────────────────
* Admin/manage_options → número completo.
* Cualquier otro rol → últimos 4 dígitos visibles, resto enmascarado.
* ─────────────────────────────────────────────────────────────────── */
function ab_mask_phone( $phone ) {
if ( ! $phone ) return '';
if ( current_user_can('manage_options') || current_user_can('manage_autobooking') ) {
return $phone; // admin ve el número completo
}
// Conservar solo dígitos para contar
$digits = preg_replace('/\D/', '', $phone);
$len = strlen($digits);
if ( $len < 4 ) return str_repeat('*', $len);
// Máscara: *** *** **XX (últimos 4 visibles)
$visible = substr($digits, -4);
$masked = str_repeat('*', max(0, $len - 4));
$masked_fmt = chunk_split($masked, 3, ' ');
// Preservar prefijo + si existe
$prefix = (substr(trim($phone), 0, 1) === '+') ? '+' : '';
return trim($prefix . trim($masked_fmt) . $visible);
}
class AutoBookingCommandCenter {
public function __construct() {
add_action('init', [$this, 'register_role']);
add_action('rest_api_init', [$this, 'register_endpoints']);
add_shortcode('autobooking_command_center', [$this, 'render']);
add_action('wp_enqueue_scripts', [$this, 'enqueue']);
}
public function register_role() {
if (!get_role('dispatch_operator')) {
add_role('dispatch_operator', 'Operador de Despacho', [
'read' => true,
'dispatch_operations' => true,
]);
}
}
public function can_access() {
return is_user_logged_in() && (
current_user_can('manage_autobooking') ||
current_user_can('dispatch_operations') ||
current_user_can('manage_options')
);
}
/* ── REST endpoints ──────────────────────────────────────── */
public function register_endpoints() {
$ns = 'autobooking/v1';
$auth = [$this, 'can_access'];
register_rest_route($ns, '/command/stats', ['methods' => 'GET', 'callback' => [$this, 'ep_stats'], 'permission_callback' => $auth]);
register_rest_route($ns, '/command/live-map', ['methods' => 'GET', 'callback' => [$this, 'ep_live_map'], 'permission_callback' => $auth]);
register_rest_route($ns, '/command/alerts', ['methods' => 'GET', 'callback' => [$this, 'ep_alerts'], 'permission_callback' => $auth]);
register_rest_route($ns, '/command/trip/(?P<id>\d+)', ['methods' => 'GET', 'callback' => [$this, 'ep_trip'], 'permission_callback' => $auth]);
register_rest_route($ns, '/command/incident', ['methods' => 'POST', 'callback' => [$this, 'ep_incident'], 'permission_callback' => $auth]);
register_rest_route($ns, '/command/resolve/(?P<id>\d+)',['methods' => 'POST', 'callback' => [$this, 'ep_resolve'], 'permission_callback' => $auth]);
register_rest_route($ns, '/command/block-driver', ['methods' => 'POST', 'callback' => [$this, 'ep_block_driver'], 'permission_callback' => $auth]);
register_rest_route($ns, '/command/message', ['methods' => 'POST', 'callback' => [$this, 'ep_message'], 'permission_callback' => $auth]);
}
public function ep_stats() {
global $wpdb;
return rest_ensure_response([
'active_trips' => (int)$wpdb->get_var("SELECT COUNT(*) FROM wp_ab_trips WHERE status IN ('active','en_route','picking_up','in_progress')"),
'available_drivers' => (int)$wpdb->get_var("SELECT COUNT(*) FROM wp_ab_driver_status WHERE online=1 AND last_seen > DATE_SUB(NOW(), INTERVAL 5 MINUTE)"),
'open_sos' => (int)$wpdb->get_var("SELECT COUNT(*) FROM wp_ab_incidents WHERE status IN ('open','active','pending')"),
'trips_today' => (int)$wpdb->get_var("SELECT COUNT(*) FROM wp_ab_trips WHERE DATE(created_at) = CURDATE()"),
]);
}
public function ep_live_map() {
global $wpdb;
$trips = $wpdb->get_results("
SELECT t.id, t.status, t.driver_id, t.passenger_id,
t.driver_name, t.passenger_name,
t.pickup_time AS started_at,
t.dropoff_lat, t.dropoff_lng,
p.lat AS driver_lat, p.lng AS driver_lng
FROM wp_ab_trips t
LEFT JOIN (
SELECT trip_id, lat, lng
FROM wp_ab_trip_positions
WHERE id IN (SELECT MAX(id) FROM wp_ab_trip_positions GROUP BY trip_id)
) p ON t.id = p.trip_id
WHERE t.status IN ('active','en_route','picking_up','in_progress')
");
$drivers = $wpdb->get_results("
SELECT ds.driver_id AS user_id, ds.last_lat AS lat, ds.last_lng AS lng, u.display_name
FROM wp_ab_driver_status ds
JOIN wp_users u ON ds.driver_id = u.ID
WHERE ds.online = 1
AND ds.last_seen > DATE_SUB(NOW(), INTERVAL 5 MINUTE)
");
return rest_ensure_response(['trips' => $trips, 'drivers' => $drivers]);
}
public function ep_alerts() {
global $wpdb;
$sos = $wpdb->get_results("
SELECT i.id, i.trip_id, i.user_id, i.type, i.description,
i.lat, i.lng, i.status, i.created_at,
u.display_name AS user_name,
t.driver_id, t.passenger_id
FROM wp_ab_incidents i
LEFT JOIN wp_users u ON i.user_id = u.ID
LEFT JOIN wp_ab_trips t ON i.trip_id = t.id
WHERE i.status IN ('open','active','pending')
ORDER BY i.created_at DESC
LIMIT 30
");
return rest_ensure_response(['sos' => $sos, 'count' => count($sos)]);
}
public function ep_trip($req) {
global $wpdb;
$id = (int)$req['id'];
$trip = $wpdb->get_row($wpdb->prepare("
SELECT t.*,
du.display_name AS driver_name,
pu.display_name AS passenger_name,
dm.meta_value AS driver_phone,
pm.meta_value AS passenger_phone
FROM wp_ab_trips t
LEFT JOIN wp_users du ON t.driver_id = du.ID
LEFT JOIN wp_users pu ON t.passenger_id = pu.ID
LEFT JOIN wp_usermeta dm ON t.driver_id = dm.user_id AND dm.meta_key = 'phone'
LEFT JOIN wp_usermeta pm ON t.passenger_id = pm.user_id AND pm.meta_key = 'phone'
WHERE t.id = %d
", $id));
if (!$trip) return new WP_Error('not_found', 'Trip not found', ['status' => 404]);
// Posición actual del conductor
$cur = $wpdb->get_row($wpdb->prepare(
"SELECT lat, lng FROM wp_ab_trip_positions WHERE trip_id = %d ORDER BY id DESC LIMIT 1", $id
));
$trip->current_lat = $cur ? $cur->lat : null;
$trip->current_lng = $cur ? $cur->lng : null;
// Enmascarar teléfonos según rol
$trip->driver_phone = ab_mask_phone($trip->driver_phone ?? '');
$trip->passenger_phone = ab_mask_phone($trip->passenger_phone ?? '');
$incidents = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM wp_ab_incidents WHERE trip_id = %d ORDER BY created_at DESC LIMIT 10", $id
));
return rest_ensure_response(['trip' => $trip, 'incidents' => $incidents]);
}
public function ep_incident($req) {
global $wpdb;
$p = $req->get_params();
$trip_id = (int)($p['trip_id'] ?? 0);
$type = sanitize_text_field($p['type'] ?? 'operator_action');
$desc = sanitize_textarea_field($p['description'] ?? '');
$protocol = sanitize_text_field($p['protocol'] ?? '');
$wpdb->insert('wp_ab_incidents', [
'trip_id' => $trip_id,
'user_id' => get_current_user_id(),
'type' => $type,
'description' => $desc . ($protocol ? " [PROTOCOLO: {$protocol}]" : ''),
'status' => 'active',
'created_at' => current_time('mysql'),
]);
$this->audit('command_incident', compact('trip_id', 'type', 'protocol'));
return rest_ensure_response(['success' => true, 'id' => $wpdb->insert_id]);
}
public function ep_resolve($req) {
global $wpdb;
$id = (int)$req['id'];
$notes = sanitize_textarea_field($req->get_param('notes') ?? '');
$wpdb->update('wp_ab_incidents', [
'status' => 'resolved',
'resolved_at' => current_time('mysql'),
'resolution_notes' => $notes,
], ['id' => $id]);
$this->audit('command_resolve', ['alert_id' => $id, 'notes' => $notes]);
return rest_ensure_response(['success' => true]);
}
public function ep_block_driver($req) {
global $wpdb;
$driver_id = (int)($req->get_param('driver_id') ?? 0);
$reason = sanitize_text_field($req->get_param('reason') ?? '');
if (!$driver_id) return new WP_Error('invalid', 'Driver ID required', ['status' => 400]);
$wpdb->update('wp_ab_driver_status', ['online' => 0], ['driver_id' => $driver_id]);
$this->audit('command_block_driver', compact('driver_id', 'reason'));
return rest_ensure_response(['success' => true]);
}
public function ep_message($req) {
global $wpdb;
$trip_id = (int)($req->get_param('trip_id') ?? 0);
$to_user_id = (int)($req->get_param('to_user_id') ?? 0);
$message = sanitize_textarea_field($req->get_param('message') ?? '');
if (!$message) return new WP_Error('invalid', 'Message required', ['status' => 400]);
$wpdb->insert('wp_autobooking_chat', [
'trip_id' => $trip_id,
'from_user_id' => get_current_user_id(),
'to_user_id' => $to_user_id,
'message' => '[OPERADOR] ' . $message,
'created_at' => current_time('mysql'),
]);
return rest_ensure_response(['success' => true]);
}
private function audit($action, $details) {
global $wpdb;
$wpdb->insert('wp_ab_admin_audit', [
'admin_id' => get_current_user_id(),
'action' => $action,
'details' => wp_json_encode($details),
'created_at' => current_time('mysql'),
]);
}
/* ── Assets ──────────────────────────────────────────────── */
public function enqueue() {
global $post;
if (!$post || !has_shortcode($post->post_content, 'autobooking_command_center')) return;
if (!$this->can_access()) return;
$url = plugin_dir_url(__FILE__);
wp_enqueue_style('cc-style', $url . 'command-center.css', [], '1.0.0');
wp_enqueue_script('cc-script', $url . 'command-center.js',
['jquery'], '1.0.0', true
);
wp_localize_script('cc-script', 'ccConfig', [
'rest' => rest_url('autobooking/v1/'),
'nonce' => wp_create_nonce('wp_rest'),
'userId' => get_current_user_id(),
]);
}
/* ── Shortcode HTML ──────────────────────────────────────── */
public function render() {
if (!$this->can_access())
return '<p style="color:#ff4444;text-align:center;padding:2rem;">Acceso no autorizado.</p>';
ob_start(); ?>
<div id="cc-app">
<!-- Header -->
<div id="cc-header">
<div class="cc-brand">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="#FF6F00" stroke-width="2"/>
<path d="M12 8v4l3 3" stroke="#FF6F00" stroke-width="2" stroke-linecap="round"/>
</svg>
COMANDO CENTRAL
</div>
<div class="cc-kpis">
<div class="cc-kpi"><span id="kpi-active">--</span><small>Viajes activos</small></div>
<div class="cc-kpi"><span id="kpi-free">--</span><small>Conductores libres</small></div>
<div class="cc-kpi cc-kpi-sos" id="kpi-sos-box"><span id="kpi-sos">0</span><small>Alertas SOS</small></div>
<div class="cc-kpi"><span id="kpi-today">--</span><small>Viajes hoy</small></div>
</div>
<div id="cc-theme-switcher">
<button class="cc-theme-btn" data-theme="dark" title="Oscuro">Oscuro</button>
<button class="cc-theme-btn" data-theme="improved" title="Mejorado">Mejorado</button>
<button class="cc-theme-btn" data-theme="normal" title="Normal">Normal</button>
</div>
<div id="cc-clock">--:--:--</div>
</div>
<!-- Main body -->
<div id="cc-body">
<!-- Live map -->
<div id="cc-map-wrap">
<div id="cc-map"></div>
<div id="cc-legend">
<span><i class="leg-dot leg-trip"></i>En viaje</span>
<span><i class="leg-dot leg-free"></i>Disponible</span>
<span><i class="leg-dot leg-sos"></i>SOS</span>
</div>
</div>
<!-- Right sidebar -->
<div id="cc-sidebar">
<!-- SOS alerts -->
<div class="cc-section" id="cc-sos-section">
<div class="cc-sec-hdr sos-hdr">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" stroke="#ff4444" stroke-width="2"/>
<line x1="12" y1="9" x2="12" y2="13" stroke="#ff4444" stroke-width="2"/>
<line x1="12" y1="17" x2="12.01" y2="17" stroke="#ff4444" stroke-width="3"/>
</svg>
ALERTAS SOS
<span id="sos-count-badge" class="cc-badge">0</span>
</div>
<div id="sos-list"><div class="cc-empty">Sin alertas activas</div></div>
</div>
<!-- Active trips list -->
<div class="cc-section">
<div class="cc-sec-hdr">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<rect x="1" y="3" width="15" height="13" rx="2" stroke="#FF6F00" stroke-width="2"/>
<path d="M16 8h4l3 3v5h-7V8z" stroke="#FF6F00" stroke-width="2"/>
<circle cx="5.5" cy="18.5" r="2.5" stroke="#FF6F00" stroke-width="2"/>
<circle cx="18.5" cy="18.5" r="2.5" stroke="#FF6F00" stroke-width="2"/>
</svg>
VIAJES ACTIVOS
</div>
<div id="trips-list"><div class="cc-empty">Sin viajes activos</div></div>
</div>
<!-- Trip detail panel (sidebar) -->
<div class="cc-section cc-trip-panel-section" id="cc-trip-panel" style="display:none">
<div class="cc-sec-hdr">
<svg width="14" height="14" viewBox="0 0 24 40">
<path d="M4,12 Q4,4 12,2 Q20,4 20,12 L20,30 Q20,38 12,38 Q4,38 4,30 Z" fill="#FF6F00" opacity=".9"/>
<path d="M7,10 Q12,6 17,10 L16,17 Q12,15 8,17 Z" fill="rgba(255,255,255,0.4)"/>
</svg>
<span id="cc-panel-title">VIAJE SELECCIONADO</span>
<button class="cc-panel-close" onclick="CC.closeDetail()">✕</button>
</div>
<div class="cc-panel-body">
<div id="cc-panel-info"></div>
<div id="cc-panel-pattern"></div>
<div id="cc-panel-btns"></div>
</div>
</div>
</div>
</div>
<!-- Protocol modal -->
<div id="cc-modal-protocol" class="cc-modal">
<div class="cc-modal-inner">
<h3>Activar Protocolo de Seguridad</h3>
<div class="proto-grid">
<button class="proto-opt" data-p="accidente" onclick="CC.selectProtocol(this)">🚗 Accidente vial</button>
<button class="proto-opt" data-p="robo" onclick="CC.selectProtocol(this)">🔫 Robo / Asalto</button>
<button class="proto-opt" data-p="medico" onclick="CC.selectProtocol(this)">🏥 Emergencia médica</button>
<button class="proto-opt" data-p="desaparicion" onclick="CC.selectProtocol(this)">❓ Desaparición</button>
<button class="proto-opt" data-p="acoso" onclick="CC.selectProtocol(this)">⚠️ Acoso / Abuso</button>
<button class="proto-opt" data-p="ruta_sospechosa" onclick="CC.selectProtocol(this)">🗺️ Ruta sospechosa</button>
</div>
<textarea id="proto-notes" placeholder="Notas adicionales del operador..." rows="3"></textarea>
<div class="cc-modal-btns">
<button class="ccb ccb-dark" onclick="CC.closeModal('protocol')">Cancelar</button>
<button class="ccb ccb-red" onclick="CC.confirmProtocol()">Activar Protocolo</button>
</div>
</div>
</div>
<!-- Message modal -->
<div id="cc-modal-message" class="cc-modal">
<div class="cc-modal-inner">
<h3>Enviar Mensaje del Operador</h3>
<select id="msg-to">
<option value="driver">Al Conductor</option>
<option value="passenger">Al Pasajero</option>
</select>
<textarea id="msg-body" placeholder="Escribe el mensaje..." rows="4"></textarea>
<div class="cc-modal-btns">
<button class="ccb ccb-dark" onclick="CC.closeModal('message')">Cancelar</button>
<button class="ccb ccb-green" onclick="CC.sendMessage()">Enviar</button>
</div>
</div>
</div>
</div>
<?php
return ob_get_clean();
}
}
new AutoBookingCommandCenter();
File diff suppressed because it is too large Load Diff
+242
View File
@@ -0,0 +1,242 @@
<?php
/**
* Plugin Name: AutoBooking Geo Restrict
* Description: Permite operar solo en países habilitados por el admin. Cada usuario solo
* puede usar la app dentro del país donde está físicamente (GPS). Si el país
* no está en la lista de permitidos, acceso bloqueado.
* Version: 1.1.0
* Author: AutoBooking
*/
if ( ! defined( 'ABSPATH' ) ) exit;
/* ──────────────────────────────────────────────────────────────
BOUNDING BOXES por país
Formato: 'CODE' => [lat_min, lat_max, lng_min, lng_max]
Se pueden agregar más países desde el admin sin tocar el código.
─────────────────────────────────────────────────────────────── */
function ab_get_country_boxes() {
return [
'US' => [
[ 24.5, 49.5, -125.0, -66.9 ], // Contiguous USA
[ 51.2, 71.5, -180.0, -129.9 ], // Alaska
[ 18.9, 22.2, -160.2, -154.8 ], // Hawaii
],
'CO' => [[ 1.5, 13.4, -79.0, -66.8 ]],
'MX' => [[ 14.5, 32.7, -118.4, -86.7 ]],
'CA' => [[ 41.7, 83.1, -141.0, -52.6 ]],
'GB' => [[ 49.9, 60.9, -8.2, 1.8 ]],
'ES' => [[ 35.9, 43.8, -9.3, 4.3 ]],
'AR' => [[-55.0, -21.8, -73.6, -53.6 ]],
'BR' => [[-33.7, 5.3, -73.9, -34.8 ]],
'CL' => [[-55.9, -17.5, -75.6, -66.9 ]],
'PE' => [[-18.4, -0.0, -81.3, -68.7 ]],
'EC' => [[ -5.0, 1.5, -81.0, -75.2 ]],
'VE' => [[ 0.6, 12.2, -73.3, -59.8 ]],
];
}
/* ──────────────────────────────────────────────────────────────
HELPER — detecta el país de unas coordenadas
Retorna el código ISO 2 del país si está en las boxes,
o null si no coincide con ningún país configurado.
─────────────────────────────────────────────────────────────── */
function ab_get_country_from_coords( $lat, $lng ) {
$lat = (float) $lat;
$lng = (float) $lng;
if ( ! $lat || ! $lng ) return null;
foreach ( ab_get_country_boxes() as $code => $boxes ) {
foreach ( $boxes as $box ) {
if ( $lat >= $box[0] && $lat <= $box[1] && $lng >= $box[2] && $lng <= $box[3] ) {
return $code;
}
}
}
return null;
}
/* ──────────────────────────────────────────────────────────────
HELPER — verifica si un país está en la lista de permitidos
─────────────────────────────────────────────────────────────── */
function ab_is_country_allowed( $country_code ) {
if ( ! $country_code ) return false;
$allowed = get_option( 'ab_allowed_countries', [ 'US' ] );
if ( is_string( $allowed ) ) $allowed = json_decode( $allowed, true ) ?: [ 'US' ];
return in_array( strtoupper( $country_code ), array_map( 'strtoupper', (array) $allowed ), true );
}
/* ──────────────────────────────────────────────────────────────
IP GEOLOCATION — verifica país por IP como backup del GPS
─────────────────────────────────────────────────────────────── */
function ab_get_country_from_ip( $ip ) {
if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) return null;
$cache_key = 'ab_geo_ip_' . md5( $ip );
$cached = get_transient( $cache_key );
if ( $cached !== false ) return $cached ?: null;
$response = wp_remote_get( 'https://ip-api.com/json/' . rawurlencode( $ip ) . '?fields=countryCode,status', ['timeout'=>5] );
if ( is_wp_error( $response ) ) { set_transient( $cache_key, '', 3600 ); return null; }
$body = json_decode( wp_remote_retrieve_body( $response ), true );
$code = ( ! empty( $body['status'] ) && $body['status'] === 'success' ) ? strtoupper( $body['countryCode'] ?? '' ) : '';
set_transient( $cache_key, $code, 86400 );
return $code ?: null;
}
function ab_get_request_ip() {
if ( function_exists( 'abs_get_ip' ) ) return abs_get_ip();
$ip = '';
if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
$parts = explode( ',', $_SERVER['HTTP_X_FORWARDED_FOR'] ); $ip = trim( $parts[0] );
}
if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
}
return sanitize_text_field( $ip );
}
/* ──────────────────────────────────────────────────────────────
ENDPOINT PÚBLICO — GET /autobooking/v1/geo/check?lat=&lng=
El JS lo llama antes de cualquier acción crítica.
Retorna si el país está permitido Y cuál es el país detectado.
─────────────────────────────────────────────────────────────── */
add_action( 'rest_api_init', function () {
// Check de coordenadas
register_rest_route( 'autobooking/v1', '/geo/check', [
'methods' => 'GET',
'callback' => function ( WP_REST_Request $req ) {
$lat = (float) $req->get_param( 'lat' );
$lng = (float) $req->get_param( 'lng' );
$country = ab_get_country_from_coords( $lat, $lng );
$allowed = ab_is_country_allowed( $country );
if ( $allowed && $country ) {
$ip_country = ab_get_country_from_ip( ab_get_request_ip() );
if ( $ip_country && $ip_country !== $country ) {
return rest_ensure_response(['allowed'=>false,'country'=>$country,'message'=>'Ubicación inconsistente. Verifica tu conexión.']);
}
}
return rest_ensure_response( [
'allowed' => $allowed,
'country' => $country,
'message' => $allowed ? '' : get_option(
'ab_geo_blocked_msg',
'AutoBooking is not available in your current location.'
),
] );
},
'permission_callback' => '__return_true',
'args' => [
'lat' => [ 'required' => true, 'type' => 'number' ],
'lng' => [ 'required' => true, 'type' => 'number' ],
],
] );
// Leer configuración geo (para el admin dashboard)
register_rest_route( 'autobooking/v1', '/admin/geo-settings', [
'methods' => 'GET',
'callback' => function () {
$allowed = get_option( 'ab_allowed_countries', [ 'US' ] );
if ( is_string( $allowed ) ) $allowed = json_decode( $allowed, true ) ?: [ 'US' ];
return rest_ensure_response( [
'allowed_countries' => array_values( $allowed ),
'available_countries' => array_keys( ab_get_country_boxes() ),
'blocked_message' => get_option( 'ab_geo_blocked_msg', 'AutoBooking is not available in your current location.' ),
] );
},
'permission_callback' => function () {
return current_user_can( 'manage_autobooking' );
},
] );
// Guardar configuración geo (desde el admin dashboard)
register_rest_route( 'autobooking/v1', '/admin/geo-settings/save', [
'methods' => 'POST',
'callback' => function ( WP_REST_Request $req ) {
$body = $req->get_json_params();
$allowed = array_map( 'strtoupper', array_filter( (array) ( $body['allowed_countries'] ?? [] ), 'is_string' ) );
$msg = sanitize_textarea_field( $body['blocked_message'] ?? '' );
if ( empty( $allowed ) ) {
return new WP_Error( 'invalid', 'Debe haber al menos un país permitido.', [ 'status' => 400 ] );
}
update_option( 'ab_allowed_countries', array_values( $allowed ) );
if ( $msg ) update_option( 'ab_geo_blocked_msg', $msg );
return rest_ensure_response( [ 'ok' => true, 'allowed_countries' => $allowed ] );
},
'permission_callback' => function () {
return current_user_can( 'manage_autobooking' );
},
] );
} );
/* ──────────────────────────────────────────────────────────────
INTERCEPTOR REST — bloquea endpoints sensibles fuera de países permitidos
─────────────────────────────────────────────────────────────── */
add_filter( 'rest_dispatch_request', function ( $result, WP_REST_Request $request, $route, $handler ) {
if ( $request->get_method() !== 'POST' ) return $result;
$body = $request->get_json_params() ?: [];
$lat = 0;
$lng = 0;
/* Conductor se pone online */
if ( preg_match( '#^/autobooking/v1/driver/status$#', $route ) ) {
if ( ! empty( $body['online'] ) ) {
$lat = floatval( $body['lat'] ?? 0 );
$lng = floatval( $body['lng'] ?? 0 );
if ( $lat && $lng ) {
$country = ab_get_country_from_coords( $lat, $lng );
if ( ! ab_is_country_allowed( $country ) ) {
return new WP_Error(
'geo_restricted',
get_option( 'ab_geo_blocked_msg', 'AutoBooking is not available in your current location.' ),
[ 'status' => 403, 'country' => $country ]
);
}
}
}
return $result;
}
/* Pasajero solicita viaje */
if ( preg_match( '#^/autobooking/v1/passenger/(request|book|ride)$#', $route ) ) {
$lat = floatval( $body['origin_lat'] ?? $body['pickup_lat'] ?? 0 );
$lng = floatval( $body['origin_lng'] ?? $body['pickup_lng'] ?? 0 );
if ( $lat && $lng ) {
$country = ab_get_country_from_coords( $lat, $lng );
if ( ! ab_is_country_allowed( $country ) ) {
return new WP_Error(
'geo_restricted',
get_option( 'ab_geo_blocked_msg', 'AutoBooking is not available in your current location.' ),
[ 'status' => 403, 'country' => $country ]
);
}
}
return $result;
}
/* Corporate reserva viaje */
if ( preg_match( '#^/autobooking/v1/corporate/(book|trip|request)$#', $route ) ) {
$lat = floatval( $body['origin_lat'] ?? 0 );
$lng = floatval( $body['origin_lng'] ?? 0 );
if ( $lat && $lng ) {
$country = ab_get_country_from_coords( $lat, $lng );
if ( ! ab_is_country_allowed( $country ) ) {
return new WP_Error(
'geo_restricted',
get_option( 'ab_geo_blocked_msg', 'AutoBooking is not available in your current location.' ),
[ 'status' => 403, 'country' => $country ]
);
}
}
return $result;
}
return $result;
}, 10, 4 );
+146
View File
@@ -0,0 +1,146 @@
<?php
/**
* Plugin Name: AutoBooking Nav Guard
* Version: 1.1.0
* Description: Protege páginas por token de navegación. Redirige al login/home si no hay token válido.
*/
defined('ABSPATH') || exit;
/* ── Páginas protegidas: destino => URL ────────────────────────────── */
define('AB_NAV_PAGES', [
'driver' => '/driver-dashboard-2/',
'passenger' => '/customer-dashboard/',
'corporate' => '/corporate-dashboard/',
]);
/* ── Session ───────────────────────────────────────────────────────── */
add_action('init', function () {
if (!session_id() && !headers_sent()) session_start();
}, 1);
/* ── Roles permitidos por destino ───────────────────────────────────── */
function ab_nav_allowed_roles() {
return [
'driver' => ['driver', 'driver_pending'],
'passenger' => ['subscriber', 'customer'],
'corporate' => ['corporate_admin'],
];
}
/* ── Protección via template_redirect (antes de cualquier output) ──── */
add_action('template_redirect', function () {
global $post;
if (!$post) return;
$dest = get_post_meta($post->ID, '_ab_nav_page', true);
if (!$dest) return;
if (!is_user_logged_in()) {
wp_redirect(wp_login_url(get_permalink()));
exit;
}
$uid = get_current_user_id();
$user = wp_get_current_user();
$roles = (array) $user->roles;
// Admins y manage_autobooking: acceso siempre sin token
if (in_array('administrator', $roles) || user_can($uid, 'manage_autobooking')) {
return;
}
if (!session_id()) session_start();
$token = $_SESSION['ab_nav'][$dest] ?? null;
// Token válido → pasar
if ($token && $token['user'] === $uid && $token['exp'] >= time()) {
return;
}
// Sin token pero tiene el rol correcto → auto-token y pasar
$allowed = ab_nav_allowed_roles()[$dest] ?? [];
if (array_intersect($roles, $allowed)) {
$_SESSION['ab_nav'][$dest] = ['user' => $uid, 'exp' => time() + 300];
return;
}
// Rol incorrecto para esta página → home
wp_redirect(home_url('/'));
exit;
});
/* ── Auto-token en login → acceso directo al dashboard ─────────────── */
add_action('wp_login', function ($login, $user) {
if (!session_id()) session_start();
$roles = (array) $user->roles;
$exp = time() + 300;
$role_map = [
'driver' => 'driver',
'driver_pending' => 'driver',
'subscriber' => 'passenger',
'customer' => 'passenger',
'corporate_admin' => 'corporate',
];
foreach ($role_map as $role => $dest) {
if (in_array($role, $roles, true)) {
$_SESSION['ab_nav'][$dest] = ['user' => $user->ID, 'exp' => $exp];
}
}
if (user_can($user->ID, 'manage_autobooking') || in_array('administrator', $roles, true)) {
foreach (array_keys(AB_NAV_PAGES) as $dest) {
$_SESSION['ab_nav'][$dest] = ['user' => $user->ID, 'exp' => $exp];
}
}
}, 10, 2);
/* ── Redirect post-login al dashboard según rol ────────────────────── */
add_filter('login_redirect', function ($redirect, $request, $user) {
if (is_wp_error($user)) return $redirect;
$roles = (array) $user->roles;
if (in_array('driver', $roles) || in_array('driver_pending', $roles))
return home_url(AB_NAV_PAGES['driver']);
if (in_array('corporate_admin', $roles))
return home_url(AB_NAV_PAGES['corporate']);
if (in_array('subscriber', $roles) || in_array('customer', $roles))
return home_url(AB_NAV_PAGES['passenger']);
return $redirect;
}, 10, 3);
/* ── REST: generar token desde JS ───────────────────────────────────── */
add_action('rest_api_init', function () {
register_rest_route('ab/v1', '/nav-token', [
'methods' => 'POST',
'callback' => function (WP_REST_Request $req) {
$dest = sanitize_text_field($req->get_param('destination'));
if (!$dest) return new WP_Error('missing', 'destination required', ['status' => 400]);
if (!session_id()) session_start();
$_SESSION['ab_nav'][$dest] = ['user' => get_current_user_id(), 'exp' => time() + 300];
return rest_ensure_response(['ok' => true, 'destination' => $dest]);
},
'permission_callback' => fn() => is_user_logged_in(),
]);
});
/* ── JS helper global ───────────────────────────────────────────────── */
add_action('wp_footer', function () {
if (!is_user_logged_in()) return;
echo '<script>window.ab_nav_go=function(dest,url){'
. 'fetch(' . json_encode(rest_url('ab/v1/nav-token')) . ','
. '{method:"POST",headers:{"Content-Type":"application/json","X-WP-Nonce":' . json_encode(wp_create_nonce('wp_rest')) . '},'
. 'body:JSON.stringify({destination:dest})})'
. '.then(r=>r.json()).then(d=>{if(d.ok)window.location.href=url||"/"});'
. '};</script>';
});
/* ── Limpieza de tokens expirados ───────────────────────────────────── */
add_action('init', function () {
if (!session_id() || empty($_SESSION['ab_nav'])) return;
$now = time();
foreach ($_SESSION['ab_nav'] as $k => $d) {
if ($d['exp'] < $now) unset($_SESSION['ab_nav'][$k]);
}
}, 5);
+113
View File
@@ -0,0 +1,113 @@
<?php
/**
* Plugin Name: AutoBooking Roles
* Description: Crea roles Driver, Pending Driver y Corporate Admin, y la capability manage_autobooking para el admin.
* Version: 1.1.0
* Author: AutoBooking
*
* Changelog:
* 1.1.0 - Agrega rol corporate_admin, capability manage_autobooking,
* y migracion retroactiva de usuarios corporativos existentes.
* 1.0.0 - Version inicial: roles driver y driver_pending.
*/
if ( ! defined( 'ABSPATH' ) ) exit;
define( 'ABR_ROLE_DRIVER', 'driver' );
define( 'ABR_ROLE_DRIVER_PENDING', 'driver_pending' );
define( 'ABR_ROLE_CORPORATE', 'corporate_admin' );
define( 'ABR_CAP_READ_DRIVER_DASH', 'read_driver_dashboard' );
define( 'ABR_CAP_READ_CUST_DASH', 'read_customer_dashboard' );
define( 'ABR_CAP_READ_CORPORATE_DASH', 'read_corporate_dashboard' );
define( 'ABR_CAP_MANAGE_AB', 'manage_autobooking' );
/* ------------------------------------------------------------------
Registra / actualiza roles y capabilities (idempotente)
------------------------------------------------------------------ */
function abr_register_roles_caps() {
// --- Role: Driver ---
$driver_caps = [
'read' => true,
ABR_CAP_READ_DRIVER_DASH => true,
];
if ( ! get_role( ABR_ROLE_DRIVER ) ) {
add_role( ABR_ROLE_DRIVER, 'Driver', $driver_caps );
} else {
$r = get_role( ABR_ROLE_DRIVER );
foreach ( $driver_caps as $cap => $grant ) {
if ( ! $r->has_cap( $cap ) ) $r->add_cap( $cap, $grant );
}
}
// --- Role: Pending Driver ---
$pending_caps = [ 'read' => true ];
if ( ! get_role( ABR_ROLE_DRIVER_PENDING ) ) {
add_role( ABR_ROLE_DRIVER_PENDING, 'Pending Driver', $pending_caps );
} else {
$r = get_role( ABR_ROLE_DRIVER_PENDING );
foreach ( $pending_caps as $cap => $grant ) {
if ( ! $r->has_cap( $cap ) ) $r->add_cap( $cap, $grant );
}
}
// --- Role: Corporate Admin ---
$corp_caps = [
'read' => true,
ABR_CAP_READ_CORPORATE_DASH => true,
];
if ( ! get_role( ABR_ROLE_CORPORATE ) ) {
add_role( ABR_ROLE_CORPORATE, 'Corporate Admin', $corp_caps );
} else {
$r = get_role( ABR_ROLE_CORPORATE );
foreach ( $corp_caps as $cap => $grant ) {
if ( ! $r->has_cap( $cap ) ) $r->add_cap( $cap, $grant );
}
}
// --- Administrator: acceso total ---
$admin = get_role( 'administrator' );
if ( $admin ) {
foreach ( [ ABR_CAP_READ_DRIVER_DASH, ABR_CAP_READ_CUST_DASH, ABR_CAP_READ_CORPORATE_DASH, ABR_CAP_MANAGE_AB ] as $cap ) {
if ( ! $admin->has_cap( $cap ) ) $admin->add_cap( $cap );
}
}
}
/* ------------------------------------------------------------------
Migracion retroactiva: asigna rol corporate_admin a usuarios WP
que ya tienen ab_company_id en usermeta y no tienen el rol.
Idempotente: correrlo dos veces no duplica ni rompe nada.
------------------------------------------------------------------ */
function abr_migrate_existing_corporate_users() {
global $wpdb;
$user_ids = $wpdb->get_col(
"SELECT DISTINCT um.user_id
FROM {$wpdb->usermeta} um
INNER JOIN {$wpdb->prefix}ab_companies c ON c.id = CAST(um.meta_value AS UNSIGNED)
WHERE um.meta_key = 'ab_company_id'
AND um.meta_value != ''
AND um.meta_value != '0'
AND c.active = 1"
);
if ( empty( $user_ids ) ) return;
foreach ( $user_ids as $uid ) {
$user = get_userdata( (int) $uid );
if ( ! $user ) continue;
if ( ! in_array( ABR_ROLE_CORPORATE, (array) $user->roles, true ) ) {
$user->add_role( ABR_ROLE_CORPORATE );
}
}
}
function abr_activation() {
abr_register_roles_caps();
abr_migrate_existing_corporate_users();
}
register_activation_hook( __FILE__, 'abr_activation' );
add_action( 'init', 'abr_register_roles_caps' );
+320
View File
@@ -0,0 +1,320 @@
<?php
/**
* Plugin Name: AutoBooking Security
* Description: Rate limiting, IP blocking, hCaptcha, email confirmation, driver documents.
* Version: 1.0.0
* Author: AutoBooking
*/
if ( ! defined( 'ABSPATH' ) ) exit;
/* ── ACTIVACIÓN: crea tablas ──────────────────────────────── */
register_activation_hook( __FILE__, 'abs_activate' );
function abs_activate() {
global $wpdb; $charset = $wpdb->get_charset_collate();
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}ab_rate_limits (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
ip VARCHAR(45) NOT NULL DEFAULT '',
endpoint VARCHAR(80) NOT NULL DEFAULT '',
hits INT UNSIGNED NOT NULL DEFAULT 1,
window_start DATETIME NOT NULL,
PRIMARY KEY (id), KEY ip_ep_win (ip, endpoint, window_start)
) $charset;" );
dbDelta( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}ab_blocked_ips (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
ip VARCHAR(45) NOT NULL DEFAULT '',
reason VARCHAR(120) NOT NULL DEFAULT '',
blocked_at DATETIME NOT NULL,
expires_at DATETIME NOT NULL,
PRIMARY KEY (id), UNIQUE KEY ip (ip), KEY ip_exp (ip, expires_at)
) $charset;" );
dbDelta( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}ab_driver_documents (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
driver_id BIGINT UNSIGNED NOT NULL,
doc_type ENUM('license','insurance','vehicle_photo') NOT NULL,
file_url VARCHAR(512) NOT NULL DEFAULT '',
expiry_date DATE NULL,
status ENUM('pending','approved','rejected') NOT NULL DEFAULT 'pending',
reviewed_by BIGINT UNSIGNED NULL,
reviewed_at DATETIME NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id), KEY driver_doc (driver_id, doc_type), KEY status (status)
) $charset;" );
}
/* ── HELPERS ──────────────────────────────────────────────── */
function abs_get_ip() {
$ip = '';
if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
$parts = explode( ',', $_SERVER['HTTP_X_FORWARDED_FOR'] );
$ip = trim( $parts[0] );
}
if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
}
return sanitize_text_field( $ip );
}
function abs_is_whitelisted( $ip ) {
$raw = get_option( 'ab_sec_whitelist', '' );
if ( ! $raw ) return false;
return in_array( $ip, array_map( 'trim', explode( "\n", $raw ) ), true );
}
function abs_is_blocked( $ip ) {
global $wpdb;
return (bool) $wpdb->get_var( $wpdb->prepare(
"SELECT id FROM {$wpdb->prefix}ab_blocked_ips WHERE ip = %s AND expires_at > %s",
$ip, current_time( 'mysql' )
) );
}
function abs_block_ip( $ip, $reason, $hours ) {
global $wpdb;
$wpdb->query( $wpdb->prepare(
"INSERT INTO {$wpdb->prefix}ab_blocked_ips (ip, reason, blocked_at, expires_at) VALUES (%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE reason=VALUES(reason), blocked_at=VALUES(blocked_at), expires_at=VALUES(expires_at)",
$ip, $reason, current_time('mysql'), date('Y-m-d H:i:s', time() + $hours * 3600)
) );
}
function abs_check_rate( $ip, $endpoint, $limit, $window_sec, $block_hours = 0 ) {
if ( ! $ip || abs_is_whitelisted( $ip ) ) return true;
if ( abs_is_blocked( $ip ) ) return false;
global $wpdb; $table = $wpdb->prefix . 'ab_rate_limits';
$window = date( 'Y-m-d H:i:s', time() - $window_sec );
$hits = (int) $wpdb->get_var( $wpdb->prepare(
"SELECT SUM(hits) FROM $table WHERE ip=%s AND endpoint=%s AND window_start>=%s",
$ip, $endpoint, $window
) );
if ( $hits >= $limit ) {
if ( $block_hours > 0 ) abs_block_ip( $ip, "Rate limit: $endpoint", $block_hours );
return false;
}
$minute = date( 'Y-m-d H:i:00' );
$exists = $wpdb->get_var( $wpdb->prepare(
"SELECT id FROM $table WHERE ip=%s AND endpoint=%s AND window_start=%s",
$ip, $endpoint, $minute
) );
if ( $exists ) {
$wpdb->query( $wpdb->prepare(
"UPDATE $table SET hits=hits+1 WHERE ip=%s AND endpoint=%s AND window_start=%s",
$ip, $endpoint, $minute
) );
} else {
$wpdb->insert( $table, ['ip'=>$ip,'endpoint'=>$endpoint,'hits'=>1,'window_start'=>$minute] );
}
return true;
}
/* ── RATE LIMITING: login ─────────────────────────────────── */
add_action( 'wp_login_failed', function( $u ) {
abs_check_rate( abs_get_ip(), 'login', 10, 900, 2 );
} );
add_action( 'login_init', function() {
if ( abs_is_blocked( abs_get_ip() ) )
wp_die( 'Tu IP fue bloqueada temporalmente por demasiados intentos fallidos.', 'Bloqueado', ['response'=>429] );
} );
/* ── RATE LIMITING: registro ──────────────────────────────── */
add_filter( 'registration_errors', function( $errors, $login, $email ) {
if ( ! abs_check_rate( abs_get_ip(), 'register', 5, 3600, 24 ) )
$errors->add( 'rate_limit', '<strong>Error:</strong> Demasiados intentos desde tu IP. Intenta más tarde.' );
return $errors;
}, 10, 3 );
/* ── RATE LIMITING: REST ──────────────────────────────────── */
add_filter( 'rest_dispatch_request', function( $result, $request, $route, $handler ) {
if ( strpos( $route, '/autobooking/v1/' ) === false ) return $result;
if ( $request->get_method() === 'GET' ) return $result;
if ( ! abs_check_rate( abs_get_ip(), 'rest_post', 120, 60, 0 ) )
return new WP_Error( 'rate_limited', 'Demasiadas solicitudes.', ['status'=>429] );
return $result;
}, 10, 4 );
/* ── LIMPIEZA DIARIA ──────────────────────────────────────── */
add_action( 'abs_daily_cleanup', function() {
global $wpdb;
$wpdb->query("DELETE FROM {$wpdb->prefix}ab_rate_limits WHERE window_start < DATE_SUB(NOW(), INTERVAL 2 DAY)");
$wpdb->query("DELETE FROM {$wpdb->prefix}ab_blocked_ips WHERE expires_at < NOW()");
} );
if ( ! wp_next_scheduled('abs_daily_cleanup') ) wp_schedule_event( time(), 'daily', 'abs_daily_cleanup' );
/* ── HCAPTCHA ─────────────────────────────────────────────── */
add_action( 'register_form', function() {
$key = get_option('ab_hcaptcha_site_key','');
if ( ! $key ) return;
echo '<div class="h-captcha" data-sitekey="'.esc_attr($key).'" style="margin:12px 0;"></div>';
echo '<script src="https://js.hcaptcha.com/1/api.js" async defer></script>';
} );
add_filter( 'registration_errors', function( $errors, $login, $email ) {
$secret = get_option('ab_hcaptcha_secret','');
if ( ! $secret ) return $errors;
$token = sanitize_text_field( $_POST['h-captcha-response'] ?? '' );
if ( ! $token ) { $errors->add('captcha_missing','<strong>Error:</strong> Completa el captcha.'); return $errors; }
$res = wp_remote_post('https://api.hcaptcha.com/siteverify', [
'body' => ['secret'=>$secret,'response'=>$token,'remoteip'=>abs_get_ip()],
'timeout' => 10,
]);
if ( ! is_wp_error($res) ) {
$body = json_decode(wp_remote_retrieve_body($res), true);
if ( empty($body['success']) ) $errors->add('captcha_invalid','<strong>Error:</strong> Captcha inválido.');
}
return $errors;
}, 20, 3 );
/* ── EMAIL CONFIRMATION ───────────────────────────────────── */
add_action( 'user_register', function( $uid ) {
$user = get_userdata($uid);
if ( ! $user || ! in_array('driver_pending',(array)$user->roles,true) ) return;
$token = bin2hex(random_bytes(32));
update_user_meta($uid,'ab_email_token',$token);
update_user_meta($uid,'ab_email_token_expires',date('Y-m-d H:i:s',time()+86400));
update_user_meta($uid,'ab_email_confirmed',0);
$url = add_query_arg('token',$token,get_rest_url(null,'autobooking/v1/confirm-email'));
wp_mail($user->user_email,'Confirma tu email — AutoBooking',
"Hola {$user->display_name},\n\nConfirma tu email (válido 24h):\n\n$url\n\nAutoBooking");
} );
add_action( 'template_redirect', function() {
if ( ! is_page('driver-dashboard') && ! is_page('pending-driver') ) return;
if ( ! is_user_logged_in() ) return;
$user = wp_get_current_user(); $roles = (array)$user->roles;
if ( ! in_array('driver_pending',$roles,true) && ! in_array('driver',$roles,true) ) return;
if ( (int)get_user_meta($user->ID,'ab_email_confirmed',true) ) return;
$resend = esc_url(get_rest_url(null,'autobooking/v1/resend-confirmation'));
wp_die(
'<div style="font-family:system-ui;max-width:500px;margin:60px auto;text-align:center;padding:32px;background:#111;color:#fff;border-radius:16px;">'
.'<p style="font-size:20px;font-weight:700;color:#FF6F00;">Confirma tu email</p>'
.'<p style="color:rgba(255,255,255,.7);">Revisa tu bandeja de entrada y haz clic en el enlace de confirmación.</p>'
.'<form method="post" action="'.$resend.'">'.wp_nonce_field('wp_rest','_wpnonce',true,false)
.'<button type="submit" style="margin-top:16px;background:#FF6F00;color:#fff;border:none;padding:12px 28px;border-radius:10px;font-weight:700;cursor:pointer;">Reenviar email</button>'
.'</form></div>',
'Confirma tu email', ['response'=>403]
);
} );
/* ── REST ENDPOINTS ───────────────────────────────────────── */
add_action( 'rest_api_init', function() {
register_rest_route('autobooking/v1','/confirm-email',[
'methods'=>'GET','permission_callback'=>'__return_true',
'callback'=>function(WP_REST_Request $req){
global $wpdb;
$token = sanitize_text_field($req->get_param('token'));
if(!$token){wp_redirect(home_url('/?ab_confirm=invalid'));exit;}
$uid = $wpdb->get_var($wpdb->prepare(
"SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key='ab_email_token' AND meta_value=%s LIMIT 1",$token));
if(!$uid){wp_redirect(home_url('/?ab_confirm=invalid'));exit;}
if(strtotime(get_user_meta((int)$uid,'ab_email_token_expires',true))<time()){
wp_redirect(home_url('/?ab_confirm=expired'));exit;}
update_user_meta((int)$uid,'ab_email_confirmed',1);
delete_user_meta((int)$uid,'ab_email_token');
delete_user_meta((int)$uid,'ab_email_token_expires');
wp_redirect(home_url('/pending-driver/?ab_confirm=ok'));exit;
},
]);
register_rest_route('autobooking/v1','/resend-confirmation',[
'methods'=>'POST','permission_callback'=>'is_user_logged_in',
'callback'=>function(){
$user=wp_get_current_user(); $token=bin2hex(random_bytes(32));
update_user_meta($user->ID,'ab_email_token',$token);
update_user_meta($user->ID,'ab_email_token_expires',date('Y-m-d H:i:s',time()+86400));
$url=add_query_arg('token',$token,get_rest_url(null,'autobooking/v1/confirm-email'));
wp_mail($user->user_email,'Confirma tu email — AutoBooking',"Nuevo enlace (válido 24h):\n\n$url");
wp_redirect(home_url('/pending-driver/?ab_confirm=resent'));exit;
},
]);
register_rest_route('autobooking/v1','/driver/upload-doc',[
'methods'=>'POST','permission_callback'=>'is_user_logged_in',
'callback'=>function(WP_REST_Request $req){
global $wpdb; $user=wp_get_current_user();
$doc_type=sanitize_text_field($req->get_param('doc_type'));
if(!in_array($doc_type,['license','insurance','vehicle_photo'],true))
return new WP_Error('invalid_type','Tipo inválido.',['status'=>400]);
if(empty($_FILES['file'])) return new WP_Error('no_file','Sin archivo.',['status'=>400]);
$file=$_FILES['file'];
if(!in_array($file['type'],['image/jpeg','image/png','application/pdf'],true))
return new WP_Error('invalid_mime','Solo JPG, PNG o PDF.',['status'=>400]);
if($file['size']>5*1024*1024) return new WP_Error('file_too_large','Máx 5 MB.',['status'=>400]);
require_once ABSPATH.'wp-admin/includes/file.php';
$up=wp_upload_dir(); $dir=$up['basedir'].'/ab-driver-docs/'.$user->ID;
wp_mkdir_p($dir);
$ext=pathinfo($file['name'],PATHINFO_EXTENSION);
$fname=$doc_type.'_'.time().'.'.$ext;
if(!move_uploaded_file($file['tmp_name'],$dir.'/'.$fname))
return new WP_Error('upload_failed','Error al guardar.',['status'=>500]);
$url=$up['baseurl'].'/ab-driver-docs/'.$user->ID.'/'.$fname;
$data=['driver_id'=>$user->ID,'doc_type'=>$doc_type,'file_url'=>$url,'status'=>'pending','created_at'=>current_time('mysql')];
$exists=$wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$wpdb->prefix}ab_driver_documents WHERE driver_id=%d AND doc_type=%s",$user->ID,$doc_type));
if($exists) $wpdb->update("{$wpdb->prefix}ab_driver_documents",$data,['driver_id'=>$user->ID,'doc_type'=>$doc_type]);
else $wpdb->insert("{$wpdb->prefix}ab_driver_documents",$data);
return rest_ensure_response(['ok'=>true,'file_url'=>$url]);
},
]);
register_rest_route('autobooking/v1','/driver/my-docs',[
'methods'=>'GET','permission_callback'=>'is_user_logged_in',
'callback'=>function(){
global $wpdb; $uid=get_current_user_id();
return rest_ensure_response(['docs'=>$wpdb->get_results($wpdb->prepare(
"SELECT doc_type,file_url,status,expiry_date FROM {$wpdb->prefix}ab_driver_documents WHERE driver_id=%d",$uid),ARRAY_A)?:[]]);
},
]);
register_rest_route('autobooking/v1','/admin/driver/(?P<id>\d+)/docs',[
'methods'=>'GET','permission_callback'=>function(){return current_user_can('manage_autobooking');},
'callback'=>function(WP_REST_Request $req){
global $wpdb; $id=absint($req['id']);
return rest_ensure_response(['docs'=>$wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}ab_driver_documents WHERE driver_id=%d",$id),ARRAY_A)?:[]]);
},
]);
register_rest_route('autobooking/v1','/admin/doc/(?P<id>\d+)/review',[
'methods'=>'POST','permission_callback'=>function(){return current_user_can('manage_autobooking');},
'callback'=>function(WP_REST_Request $req){
global $wpdb; $id=absint($req['id']);
$status=in_array($req->get_param('status'),['approved','rejected'],true)?$req->get_param('status'):'pending';
$wpdb->update("{$wpdb->prefix}ab_driver_documents",
['status'=>$status,'reviewed_by'=>get_current_user_id(),'reviewed_at'=>current_time('mysql')],['id'=>$id]);
return rest_ensure_response(['ok'=>true]);
},
]);
register_rest_route('autobooking/v1','/admin/blocked-ips',[
'methods'=>'GET','permission_callback'=>function(){return current_user_can('manage_autobooking');},
'callback'=>function(){
global $wpdb;
return rest_ensure_response([
'blocked'=>$wpdb->get_results("SELECT id,ip,reason,blocked_at,expires_at FROM {$wpdb->prefix}ab_blocked_ips WHERE expires_at>NOW() ORDER BY blocked_at DESC LIMIT 100",ARRAY_A)?:[],
'blocked_24h'=>(int)$wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}ab_blocked_ips WHERE blocked_at>=DATE_SUB(NOW(),INTERVAL 24 HOUR)"),
]);
},
]);
register_rest_route('autobooking/v1','/admin/blocked-ips/(?P<id>\d+)/unblock',[
'methods'=>'POST','permission_callback'=>function(){return current_user_can('manage_autobooking');},
'callback'=>function(WP_REST_Request $req){
global $wpdb; $wpdb->delete("{$wpdb->prefix}ab_blocked_ips",['id'=>absint($req['id'])]);
return rest_ensure_response(['ok'=>true]);
},
]);
register_rest_route('autobooking/v1','/admin/security-settings/save',[
'methods'=>'POST','permission_callback'=>function(){return current_user_can('manage_autobooking');},
'callback'=>function(WP_REST_Request $req){
$wl = $req->get_param('whitelist');
$sk = $req->get_param('hcaptcha_site_key');
$sec = $req->get_param('hcaptcha_secret');
if($wl !== null) update_option('ab_sec_whitelist', sanitize_textarea_field($wl));
if($sk !== null) update_option('ab_hcaptcha_site_key', sanitize_text_field($sk));
if($sec !== null) update_option('ab_hcaptcha_secret', sanitize_text_field($sec));
return rest_ensure_response(['ok'=>true,'saved'=>array_filter(['whitelist'=>$wl,'sk'=>$sk,'sec'=>$sec])]);
},
]);
} );
+678
View File
@@ -0,0 +1,678 @@
<?php
/**
* Plugin Name: AutoBooking Simulator
* Description: Entorno de simulación — 12 conductores, 20 usuarios, 2 clientes institucionales, rutas en Bogotá con desvío de ruta
* Version: 1.0.0
* Author: AutoBooking
*/
defined('ABSPATH') || exit;
define('AB_SIM_CRON', 'ab_sim_tick');
define('AB_SIM_OPT', 'ab_sim_state');
define('AB_SIM_MARKER', 'ab_sim_user');
/* ══════════════════════════════════════════════════════════════════════
* RUTAS — Miami / Hialeah, Florida
* Waypoints cada ~500-700m. Ruta C tiene desvío para probar alarmas.
* ══════════════════════════════════════════════════════════════════════ */
function ab_sim_routes() {
return [
'A' => [
'name' => 'Hialeah → Brickell (S por NW 27th Ave)',
'from' => 'Hialeah Market',
'to' => 'Brickell City Centre',
'wps' => [
[25.8576, -80.2781], [25.8450, -80.2700], [25.8320, -80.2620],
[25.8190, -80.2540], [25.8060, -80.2460], [25.7930, -80.2380],
[25.7800, -80.2300], [25.7680, -80.2220], [25.7617, -80.1918],
],
],
'B' => [
'name' => 'MIA Airport → Miami Beach (E por NW 36th St)',
'from' => 'Miami International Airport',
'to' => 'South Beach',
'wps' => [
[25.7959, -80.2870], [25.7959, -80.2650], [25.7940, -80.2430],
[25.7880, -80.2210], [25.7820, -80.1990], [25.7760, -80.1770],
[25.7700, -80.1550], [25.7640, -80.1330], [25.7617, -80.1320],
],
],
'C' => [
'name' => 'Kendall → Hialeah (N por FL Turnpike) — DESVÍO POSIBLE',
'from' => 'Kendall Drive',
'to' => 'Hialeah',
'wps' => [
[25.7090, -80.3560], [25.7230, -80.3420], [25.7370, -80.3280],
[25.7510, -80.3140], [25.7650, -80.3000], [25.7790, -80.2860],
[25.7930, -80.2720], [25.8070, -80.2580], [25.8210, -80.2440],
],
'deviation_start' => 3,
'deviation_path' => [
[25.7520, -80.2900], [25.7540, -80.2650],
[25.7560, -80.2400], [25.7580, -80.2150],
],
],
'D' => [
'name' => 'Aventura → Downtown Miami (S por Biscayne Blvd)',
'from' => 'Aventura Mall',
'to' => 'Downtown Miami',
'wps' => [
[25.9565, -80.1425], [25.9380, -80.1410], [25.9190, -80.1395],
[25.9000, -80.1380], [25.8810, -80.1365], [25.8620, -80.1350],
[25.8240, -80.1320], [25.7900, -80.1800], [25.7750, -80.1900],
],
],
'E' => [
'name' => 'Doral → Coral Gables (S por Palmetto Expy)',
'from' => 'Doral City Place',
'to' => 'Coral Gables Miracle Mile',
'wps' => [
[25.8196, -80.3556], [25.8100, -80.3400], [25.8000, -80.3250],
[25.7900, -80.3100], [25.7800, -80.2950], [25.7700, -80.2800],
[25.7600, -80.2650],
],
],
];
}
/* ══════════════════════════════════════════════════════════════════════
* CONFIGURACIÓN DE CONDUCTORES (12)
* ['login', 'nombre', ruta, step_inicial, empresa_corp|null]
* route FREE = disponible | route OFF = offline
* ══════════════════════════════════════════════════════════════════════ */
function ab_sim_driver_config() {
return [
['abtest_drv_a1', 'Andrés Pérez', 'A', 0, null],
['abtest_drv_a2', 'Beatriz Torres', 'A', 3, null],
['abtest_drv_b1', 'Carlos Méndez', 'B', 0, null],
['abtest_drv_b2', 'Diana López', 'B', 5, 'TechCorp'],
['abtest_drv_c1', 'Eduardo Ruiz', 'C', 0, null],
['abtest_drv_d1', 'Felipe Vargas', 'D', 0, 'LogiCorp'],
['abtest_drv_e1', 'Gabriela Suárez', 'E', 0, null],
['abtest_drv_f1', 'Hugo Ramírez', 'FREE', null, null],
['abtest_drv_f2', 'Isabel Castro', 'FREE', null, null],
['abtest_drv_f3', 'Julián Morales', 'FREE', null, null],
['abtest_drv_f4', 'Karen Reyes', 'FREE', null, null],
['abtest_drv_f5', 'Luis Herrera', 'OFF', null, null],
];
}
function ab_sim_haversine($lat1, $lng1, $lat2, $lng2) {
$R = 6371;
$dLat = deg2rad($lat2 - $lat1);
$dLng = deg2rad($lng2 - $lng1);
$a = sin($dLat/2)**2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng/2)**2;
return $R * 2 * atan2(sqrt($a), sqrt(1 - $a));
}
function ab_sim_free_positions() {
return [
[4.6700, -74.0550],
[4.6490, -74.0680],
[4.6120, -74.0760],
[4.6320, -74.1100],
];
}
/* ══════════════════════════════════════════════════════════════════════
* ESTADO EN wp_options
* ══════════════════════════════════════════════════════════════════════ */
function ab_sim_state() {
return get_option(AB_SIM_OPT, [
'running' => false,
'deviation' => false,
'drivers' => [],
'last_tick' => 0,
]);
}
function ab_sim_save($s) { update_option(AB_SIM_OPT, $s); }
/* ══════════════════════════════════════════════════════════════════════
* PÁGINA DE ADMINISTRACIÓN (Tools > AB Simulator)
* ══════════════════════════════════════════════════════════════════════ */
add_action('admin_menu', function () {
add_management_page('AB Simulator', 'AB Simulator', 'manage_options', 'ab-sim', 'ab_sim_page');
});
function ab_sim_page() {
if (isset($_POST['ab_sim_act']) && check_admin_referer('ab_sim')) {
$act = sanitize_key($_POST['ab_sim_act']);
if ($act === 'start') ab_sim_start();
if ($act === 'stop') ab_sim_stop();
if ($act === 'reset') ab_sim_reset();
if ($act === 'deviate') ab_sim_activate_deviation();
if ($act === 'tick') ab_sim_tick();
}
$s = ab_sim_state();
$routes = ab_sim_routes();
$running = $s['running'];
?>
<div class="wrap">
<h1>🚗 AutoBooking Simulator</h1>
<div style="background:#fff;padding:20px 24px;margin:16px 0;border:1px solid #ccc;border-radius:6px;">
<p style="font-size:15px;">
Estado: <?php echo $running
? '<strong style="color:#2e7d32">▶ CORRIENDO</strong>'
: '<strong style="color:#555">⏸ DETENIDO</strong>'; ?>
&nbsp;|&nbsp; Último tick: <code><?php echo $s['last_tick'] ? date('H:i:s', $s['last_tick']) : '—'; ?></code>
&nbsp;|&nbsp; Desvío ruta C: <?php echo $s['deviation']
? '<strong style="color:#c62828">⚠ ACTIVO</strong>'
: '<span style="color:#888">inactivo</span>'; ?>
</p>
<form method="post" style="display:inline-block;margin-right:8px;">
<?php wp_nonce_field('ab_sim'); ?>
<input type="hidden" name="ab_sim_act" value="<?php echo $running ? 'stop' : 'start'; ?>">
<button class="button <?php echo $running ? '' : 'button-primary'; ?> button-large">
<?php echo $running ? '⏸ Pausar' : '▶ Iniciar simulación'; ?>
</button>
</form>
<form method="post" style="display:inline-block;margin-right:8px;">
<?php wp_nonce_field('ab_sim'); ?>
<input type="hidden" name="ab_sim_act" value="deviate">
<button class="button button-large" <?php echo !$running ? 'disabled' : ''; ?>>
🔀 Activar desvío (conductor C)
</button>
</form>
<form method="post" style="display:inline-block;margin-right:8px;">
<?php wp_nonce_field('ab_sim'); ?>
<input type="hidden" name="ab_sim_act" value="tick">
<button class="button button-large" <?php echo !$running ? 'disabled' : ''; ?>>
⏭ Avanzar un paso
</button>
</form>
<form method="post" style="display:inline-block;"
onsubmit="return confirm('¿Eliminar todos los datos simulados?')">
<?php wp_nonce_field('ab_sim'); ?>
<input type="hidden" name="ab_sim_act" value="reset">
<button class="button button-large" style="color:#c62828;">🗑 Reset total</button>
</form>
</div>
<?php if (!empty($s['drivers'])): ?>
<h2>Conductores (<?php echo count($s['drivers']); ?>)</h2>
<table class="widefat striped">
<thead><tr><th>ID</th><th>Nombre</th><th>Ruta</th><th>Paso</th><th>Estado DB</th><th>Lat / Lng</th></tr></thead>
<tbody>
<?php foreach ($s['drivers'] as $d):
$u = get_userdata($d['user_id']);
$rkey = $d['route'];
if ($rkey === 'FREE') $rname = '📍 Disponible';
elseif ($rkey === 'OFF') $rname = '🔴 Offline';
else $rname = $routes[$rkey]['name'] ?? $rkey;
?>
<tr>
<td><?php echo $d['user_id']; ?></td>
<td><?php echo esc_html($u ? $u->display_name : '—'); ?></td>
<td><?php echo esc_html($rname); ?></td>
<td><?php echo $d['step'] ?? '—'; ?></td>
<td><?php echo esc_html($d['db_status'] ?? '—'); ?></td>
<td><code><?php echo isset($d['lat']) ? round($d['lat'], 5) . ', ' . round($d['lng'], 5) : '—'; ?></code></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<h2 style="margin-top:24px;">Escenarios de prueba</h2>
<table class="widefat">
<thead><tr><th>Escenario</th><th>Qué prueba</th><th>Pasos</th></tr></thead>
<tbody>
<tr>
<td><b>Mapa en vivo</b></td>
<td>Conductores disponibles y activos en el mapa del Command Center</td>
<td>Iniciar → abrir Command Center</td>
</tr>
<tr>
<td><b>Viajes activos</b></td>
<td>7 vehículos moviéndose en tiempo real (cada ~30s)</td>
<td>Iniciar → refrescar mapa cada 30s</td>
</tr>
<tr>
<td><b>Desvío de ruta</b></td>
<td>Conductor C abandona ruta Kennedy→Suba → alerta <code>route_deviation</code> en Command Center</td>
<td>Iniciar → 2 ticks → Activar desvío → ver alertas</td>
</tr>
<tr>
<td><b>Viaje corporativo</b></td>
<td>Diana López (TechCorp) y Felipe Vargas (LogiCorp) con company_id en sus viajes</td>
<td>Iniciar → ver viajes activos con empresa asignada</td>
</tr>
<tr>
<td><b>Avance manual</b></td>
<td>Control paso a paso para debugging de posiciones exactas</td>
<td>Usar "Avanzar un paso" repetidamente</td>
</tr>
</tbody>
</table>
</div>
<?php
}
/* ══════════════════════════════════════════════════════════════════════
* CONTROL
* ══════════════════════════════════════════════════════════════════════ */
function ab_sim_start() {
$s = ab_sim_state();
if (empty($s['drivers'])) {
ab_sim_seed($s);
}
$s['running'] = true;
$s['last_tick'] = time();
ab_sim_save($s);
if (!wp_next_scheduled(AB_SIM_CRON)) {
wp_schedule_event(time(), 'ab_sim_30s', AB_SIM_CRON);
}
}
function ab_sim_stop() {
$s = ab_sim_state();
$s['running'] = false;
ab_sim_save($s);
wp_clear_scheduled_hook(AB_SIM_CRON);
}
function ab_sim_activate_deviation() {
$s = ab_sim_state();
$s['deviation'] = true;
ab_sim_save($s);
}
function ab_sim_reset() {
global $wpdb;
ab_sim_stop();
$s = ab_sim_state();
$uids = array_column($s['drivers'] ?? [], 'user_id');
$extra = get_option('ab_sim_extra_uids', []);
$all = array_unique(array_merge($uids, $extra));
foreach ($all as $uid) {
$trip_ids = $wpdb->get_col($wpdb->prepare(
"SELECT id FROM wp_ab_trips WHERE (driver_id=%d OR passenger_id=%d) AND trip_uuid LIKE 'sim\\_%'",
$uid, $uid
));
if ($trip_ids) {
$in = implode(',', array_map('intval', $trip_ids));
$wpdb->query("DELETE FROM wp_ab_trip_positions WHERE trip_id IN ($in)");
$wpdb->query("DELETE FROM wp_ab_incidents WHERE trip_id IN ($in)");
$wpdb->query("DELETE FROM wp_ab_trips WHERE id IN ($in)");
}
$wpdb->delete('wp_ab_driver_status', ['driver_id' => $uid]);
if (get_user_meta($uid, AB_SIM_MARKER, true) === '1') {
require_once ABSPATH . 'wp-admin/includes/user.php';
wp_delete_user($uid);
}
}
$wpdb->query("DELETE FROM wp_ab_companies WHERE name LIKE '%[SIM]%'");
delete_option(AB_SIM_OPT);
delete_option('ab_sim_extra_uids');
delete_option('ab_sim_company_ids');
}
/* ══════════════════════════════════════════════════════════════════════
* SEED — crear usuarios, empresas y viajes iniciales
* ══════════════════════════════════════════════════════════════════════ */
function ab_sim_seed(&$s) {
global $wpdb;
$routes = ab_sim_routes();
$drv_cfg = ab_sim_driver_config();
$free_pos = ab_sim_free_positions();
$now = current_time('mysql');
/* — 2 empresas institucionales — */
$company_ids = [];
foreach (['TechCorp [SIM]' => 'TechCorp', 'LogiCorp [SIM]' => 'LogiCorp'] as $cname => $ckey) {
$cid = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM wp_ab_companies WHERE name=%s LIMIT 1", $cname
));
if (!$cid) {
$wpdb->insert('wp_ab_companies', [
'name' => $cname,
'email' => strtolower($ckey) . '@sim.local',
'status' => 'active',
'created_at' => $now,
]);
$cid = $wpdb->insert_id;
}
$company_ids[$ckey] = (int) $cid;
}
update_option('ab_sim_company_ids', $company_ids);
/* — 20 pasajeros — */
$pass_names = [
'María García', 'José Rodríguez', 'Ana Martínez', 'Luis González',
'Carmen López', 'Pedro Sánchez', 'Laura Fernández', 'Miguel Torres',
'Isabel Ramírez', 'Carlos Flores', 'Sara Díaz', 'David Morales',
'Elena Jiménez', 'Roberto Ruiz', 'Patricia Herrera','Javier Medina',
'Sofía Delgado', 'Tomás Vargas', 'Valentina Cruz', 'Nicolás Ortiz',
];
$pass_ids = [];
foreach ($pass_names as $i => $pname) {
$login = 'abtest_pass_' . ($i + 1);
$existing = get_user_by('login', $login);
if ($existing) {
$pid = $existing->ID;
} else {
$pid = wp_insert_user([
'user_login' => $login,
'user_pass' => wp_generate_password(),
'display_name' => $pname,
'role' => 'subscriber',
]);
if (is_wp_error($pid)) continue;
update_user_meta($pid, AB_SIM_MARKER, '1');
// Empleados corporativos: índices 16-17 TechCorp, 18-19 LogiCorp
if ($i >= 16 && $i <= 17) update_user_meta($pid, 'ab_company_id', $company_ids['TechCorp']);
if ($i >= 18 && $i <= 19) update_user_meta($pid, 'ab_company_id', $company_ids['LogiCorp']);
}
$pass_ids[] = $pid;
}
update_option('ab_sim_extra_uids', $pass_ids);
/* — 12 conductores — */
$s['drivers'] = [];
$free_idx = 0;
foreach ($drv_cfg as $pi => $cfg) {
[$login, $dname, $route, $step, $corp] = $cfg;
$existing = get_user_by('login', $login);
if ($existing) {
$uid = $existing->ID;
} else {
$uid = wp_insert_user([
'user_login' => $login,
'user_pass' => wp_generate_password(),
'display_name' => $dname,
'role' => 'driver',
]);
if (is_wp_error($uid)) continue;
update_user_meta($uid, AB_SIM_MARKER, '1');
}
// Posición inicial y estado DB
if ($route === 'FREE') {
[$lat, $lng] = $free_pos[$free_idx % count($free_pos)];
$free_idx++;
$db_status = 'available';
} elseif ($route === 'OFF') {
$lat = 4.6500; $lng = -74.0600;
$db_status = 'offline';
} else {
[$lat, $lng] = $routes[$route]['wps'][$step ?? 0];
$db_status = 'on_trip';
}
// Insertar/actualizar wp_ab_driver_status
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT driver_id FROM wp_ab_driver_status WHERE driver_id=%d", $uid
));
if ($exists) {
$wpdb->update('wp_ab_driver_status',
['last_lat' => $lat, 'last_lng' => $lng, 'online' => ($db_status === 'offline') ? 0 : 1, 'last_seen' => $now],
['driver_id' => $uid]
);
} else {
$wpdb->insert('wp_ab_driver_status', [
'driver_id' => $uid,
'last_lat' => $lat,
'last_lng' => $lng,
'online' => ($db_status === 'offline') ? 0 : 1,
'last_seen' => $now,
]);
}
// Crear viaje activo para conductores en ruta
$trip_id = null;
if ($route !== 'FREE' && $route !== 'OFF') {
$pass_id = $pass_ids[$pi] ?? $pass_ids[0];
$wps = $routes[$route]['wps'];
$last_wp = $wps[count($wps) - 1];
$company_id = ($corp && isset($company_ids[$corp])) ? $company_ids[$corp] : null;
$trip_id = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM wp_ab_trips WHERE driver_id=%d AND notes LIKE '%[SIM]%' LIMIT 1", $uid
));
if (!$trip_id) {
$wps = $routes[$route]['wps'];
$last = $wps[count($wps) - 1];
$drv_user = get_userdata($uid);
$pax_user = get_userdata($pass_id);
$wpdb->insert('wp_ab_trips', [
'trip_uuid' => uniqid('sim_', true),
'driver_id' => $uid,
'passenger_id' => $pass_id,
'driver_name' => $drv_user ? $drv_user->display_name : 'Conductor SIM',
'passenger_name' => $pax_user ? $pax_user->display_name : 'Pasajero SIM',
'status' => 'in_progress',
'pickup_lat' => $wps[0][0],
'pickup_lng' => $wps[0][1],
'dropoff_lat' => $last[0],
'dropoff_lng' => $last[1],
'pickup_time' => $now,
'created_at' => $now,
]);
$trip_id = $wpdb->insert_id;
}
if ($trip_id) {
$wpdb->insert('wp_ab_trip_positions', [
'trip_id' => $trip_id,
'lat' => $lat,
'lng' => $lng,
'ts' => $now,
]);
}
}
$s['drivers'][] = [
'user_id' => $uid,
'route' => $route,
'step' => $step ?? 0,
'trip_id' => $trip_id,
'db_status' => $db_status,
'lat' => $lat,
'lng' => $lng,
'deviated' => false,
];
}
}
/* ══════════════════════════════════════════════════════════════════════
* TICK — avanzar todos los vehículos un paso
* ══════════════════════════════════════════════════════════════════════ */
add_action(AB_SIM_CRON, 'ab_sim_tick');
function ab_sim_tick() {
global $wpdb;
$s = ab_sim_state();
if (!$s['running']) return;
$routes = ab_sim_routes();
$now = current_time('mysql');
foreach ($s['drivers'] as &$d) {
$route = $d['route'];
if ($route === 'FREE') {
// Micromovimiento para conductores disponibles
$d['lat'] += (mt_rand(-4, 4) * 0.0001);
$d['lng'] += (mt_rand(-4, 4) * 0.0001);
$wpdb->update('wp_ab_driver_status',
['lat' => $d['lat'], 'lng' => $d['lng'], 'updated_at' => $now],
['user_id' => $d['user_id']]
);
continue;
}
if ($route === 'OFF') continue;
$wps = $routes[$route]['wps'];
$dev_start = $routes[$route]['deviation_start'] ?? PHP_INT_MAX;
$dev_path = $routes[$route]['deviation_path'] ?? [];
$is_deviating = $s['deviation'] && $route === 'C' && $d['step'] >= $dev_start;
if ($is_deviating) {
$di = $d['step'] - $dev_start;
if (isset($dev_path[$di])) {
[$d['lat'], $d['lng']] = $dev_path[$di];
// Primera vez que desvía: registrar separación pasajero-conductor
if (!$d['deviated'] && $d['trip_id']) {
$sep = $wps[$dev_start - 1] ?? $wps[0]; // último punto en ruta normal
$trip_row = $wpdb->get_row($wpdb->prepare(
"SELECT dropoff_lat, dropoff_lng, passenger_name FROM wp_ab_trips WHERE id = %d", $d['trip_id']
));
$dist_dest = ($trip_row && $trip_row->dropoff_lat)
? ab_sim_haversine($sep[0], $sep[1], $trip_row->dropoff_lat, $trip_row->dropoff_lng)
: 0;
$wpdb->insert('wp_ab_incidents', [
'trip_id' => $d['trip_id'],
'user_id' => $d['user_id'],
'type' => 'separacion_sospechosa',
'description' => sprintf(
'[SIM] Conductor abandonó ruta. Pasajero %s último punto conocido: %.5f, %.5f (a %.1f km del destino contratado). Conductor ahora en: %.5f, %.5f.',
$trip_row->passenger_name ?? 'desconocido',
$sep[0], $sep[1], $dist_dest,
$d['lat'], $d['lng']
),
'lat' => $d['lat'],
'lng' => $d['lng'],
'status' => 'open',
'created_at' => $now,
]);
$d['deviated'] = true;
}
$d['step']++;
} else {
// Agotó la ruta de desvío — cerrar viaje con alerta de separación fuera del destino
if ($d['trip_id']) {
$trip_row = $wpdb->get_row($wpdb->prepare(
"SELECT dropoff_lat, dropoff_lng, passenger_name FROM wp_ab_trips WHERE id = %d", $d['trip_id']
));
$sep = $wps[$dev_start - 1] ?? $wps[0];
if ($trip_row && $trip_row->dropoff_lat) {
$dist = ab_sim_haversine($sep[0], $sep[1], $trip_row->dropoff_lat, $trip_row->dropoff_lng);
$wpdb->insert('wp_ab_incidents', [
'trip_id' => $d['trip_id'],
'user_id' => $d['user_id'],
'type' => 'separacion_destino_incorrecto',
'description' => sprintf(
'[SIM] Viaje cerrado fuera del destino. Pasajero %s separado a %.1f km del destino contratado (%.5f, %.5f). Punto de separación: %.5f, %.5f.',
$trip_row->passenger_name ?? 'desconocido',
$dist,
$trip_row->dropoff_lat, $trip_row->dropoff_lng,
$sep[0], $sep[1]
),
'lat' => $sep[0],
'lng' => $sep[1],
'status' => 'open',
'created_at' => $now,
]);
}
$wpdb->update('wp_ab_trips',
['status' => 'completed', 'completed_at' => $now],
['id' => $d['trip_id']]
);
$d['trip_id'] = null;
}
$d['step'] = 0;
$d['deviated'] = false;
$d['db_status'] = 'available';
[$d['lat'], $d['lng']] = $wps[0];
$wpdb->update('wp_ab_driver_status',
['last_lat' => $d['lat'], 'last_lng' => $d['lng'], 'online' => 1, 'last_seen' => $now],
['driver_id' => $d['user_id']]
);
continue;
}
} else {
$next = $d['step'] + 1;
if ($next >= count($wps)) {
// Llegó al destino — verificar si coincide con el punto contratado
if ($d['trip_id']) {
$trip_row = $wpdb->get_row($wpdb->prepare(
"SELECT dropoff_lat, dropoff_lng, passenger_name FROM wp_ab_trips WHERE id = %d", $d['trip_id']
));
if ($trip_row && $trip_row->dropoff_lat) {
$dist = ab_sim_haversine($d['lat'], $d['lng'], $trip_row->dropoff_lat, $trip_row->dropoff_lng);
if ($dist > 0.5) {
$wpdb->insert('wp_ab_incidents', [
'trip_id' => $d['trip_id'],
'user_id' => $d['user_id'],
'type' => 'separacion_fuera_destino',
'description' => sprintf(
'[SIM] Viaje completado a %.1f km del destino contratado. Pasajero %s separado en: %.5f, %.5f. Destino original: %.5f, %.5f.',
$dist,
$trip_row->passenger_name ?? 'desconocido',
$d['lat'], $d['lng'],
$trip_row->dropoff_lat, $trip_row->dropoff_lng
),
'lat' => $d['lat'],
'lng' => $d['lng'],
'status' => 'open',
'created_at' => $now,
]);
}
}
$wpdb->update('wp_ab_trips',
['status' => 'completed', 'completed_at' => $now],
['id' => $d['trip_id']]
);
$d['trip_id'] = null;
}
$d['step'] = 0;
$d['deviated'] = false;
$d['db_status'] = 'available';
[$d['lat'], $d['lng']] = $wps[0];
$wpdb->update('wp_ab_driver_status',
['last_lat' => $d['lat'], 'last_lng' => $d['lng'], 'online' => 1, 'last_seen' => $now],
['driver_id' => $d['user_id']]
);
continue;
}
$d['step'] = $next;
[$d['lat'], $d['lng']] = $wps[$next];
}
$wpdb->update('wp_ab_driver_status',
['last_lat' => $d['lat'], 'last_lng' => $d['lng'], 'last_seen' => $now],
['driver_id' => $d['user_id']]
);
if ($d['trip_id']) {
$wpdb->insert('wp_ab_trip_positions', [
'trip_id' => $d['trip_id'],
'lat' => $d['lat'],
'lng' => $d['lng'],
'ts' => $now,
]);
}
}
unset($d);
$s['last_tick'] = time();
ab_sim_save($s);
}
/* ══════════════════════════════════════════════════════════════════════
* CRON — intervalo de 30 segundos
* ══════════════════════════════════════════════════════════════════════ */
add_filter('cron_schedules', function ($sc) {
$sc['ab_sim_30s'] = ['interval' => 30, 'display' => 'Cada 30s (AutoBooking Sim)'];
return $sc;
});
register_deactivation_hook(__FILE__, function () {
wp_clear_scheduled_hook(AB_SIM_CRON);
});
+451
View File
@@ -0,0 +1,451 @@
/* ── Command Center — AutoBooking dark theme ─────────────────── */
* { box-sizing: border-box; margin: 0; padding: 0; }
#cc-app {
background: #0b0b0b;
color: #e0e0e0;
font-family: 'Inter', 'Segoe UI', sans-serif;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Header ──────────────────────────────────────────────────── */
#cc-header {
background: rgba(17,17,17,0.97);
border-bottom: 1px solid rgba(255,111,0,0.35);
padding: 10px 18px;
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
z-index: 50;
}
.cc-brand {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 800;
letter-spacing: 2.5px;
color: #FF6F00;
white-space: nowrap;
}
.cc-kpis {
display: flex;
gap: 10px;
flex: 1;
justify-content: center;
}
.cc-kpi {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
padding: 5px 14px;
text-align: center;
min-width: 78px;
}
.cc-kpi span {
display: block;
font-size: 20px;
font-weight: 700;
color: #FF6F00;
line-height: 1.2;
}
.cc-kpi small {
font-size: 9px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.cc-kpi-sos { border-color: rgba(255,68,68,0.3); }
.cc-kpi-sos span { color: #ff4444; }
.cc-kpi-sos.sos-active { animation: sosPulse 0.8s ease-in-out infinite; border-color: rgba(255,68,68,0.7); }
@keyframes sosPulse {
0%, 100% { background: rgba(255,68,68,0.08); }
50% { background: rgba(255,68,68,0.25); }
}
#cc-theme-switcher {
display: flex;
gap: 4px;
align-items: center;
}
.cc-theme-btn {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 5px;
color: #888;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.5px;
padding: 3px 8px;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.cc-theme-btn:hover {
background: rgba(255,255,255,0.1);
color: #ccc;
}
.cc-theme-btn.active {
background: rgba(255,111,0,0.18);
border-color: rgba(255,111,0,0.5);
color: #FF6F00;
}
#cc-clock {
font-size: 15px;
font-family: 'Courier New', monospace;
color: #888;
white-space: nowrap;
}
/* ── Body layout ─────────────────────────────────────────────── */
#cc-body {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
}
/* ── Map ─────────────────────────────────────────────────────── */
#cc-map-wrap {
flex: 2;
position: relative;
min-width: 0;
min-height: 500px;
}
#cc-map {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
min-height: 500px;
}
#cc-legend {
position: absolute;
bottom: 14px;
left: 14px;
background: rgba(11,11,11,0.88);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
padding: 7px 12px;
display: flex;
gap: 14px;
font-size: 11px;
color: #bbb;
z-index: 5;
backdrop-filter: blur(6px);
}
.leg-dot {
display: inline-block;
width: 9px; height: 9px;
border-radius: 50%;
margin-right: 5px;
vertical-align: middle;
}
.leg-trip { background: #FF6F00; }
.leg-free { background: #00cc66; }
.leg-sos { background: #ff4444; }
/* ── Sidebar ─────────────────────────────────────────────────── */
#cc-sidebar {
width: 330px;
flex-shrink: 0;
background: rgba(11,11,11,0.97);
border-left: 1px solid rgba(255,111,0,0.18);
display: flex;
flex-direction: column;
overflow: hidden;
}
.cc-section {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.cc-sec-hdr {
padding: 9px 13px;
font-size: 10px;
font-weight: 800;
letter-spacing: 1.8px;
color: #FF6F00;
background: rgba(255,111,0,0.06);
display: flex;
align-items: center;
gap: 7px;
flex-shrink: 0;
text-transform: uppercase;
}
.sos-hdr { color: #ff4444; background: rgba(255,68,68,0.07); }
.cc-badge {
margin-left: auto;
background: #ff4444;
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 2px 7px;
border-radius: 10px;
min-width: 20px;
text-align: center;
line-height: 1.4;
}
#sos-list, #trips-list {
overflow-y: auto;
flex: 1;
padding: 6px;
scrollbar-width: thin;
scrollbar-color: rgba(255,111,0,0.3) transparent;
}
.cc-empty { text-align: center; color: #444; font-size: 12px; padding: 24px 10px; }
/* ── SOS card ────────────────────────────────────────────────── */
.sos-card {
background: rgba(255,68,68,0.08);
border: 1px solid rgba(255,68,68,0.35);
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 6px;
transition: border-color 0.2s;
}
.sos-card:hover { border-color: #ff4444; }
.sos-card-top { display: flex; justify-content: space-between; margin-bottom: 4px; }
.sos-card-name { font-size: 13px; font-weight: 600; color: #ff7070; }
.sos-card-time { font-size: 11px; color: #666; }
.sos-card-type { font-size: 11px; color: #999; margin-bottom: 8px; }
.sos-card-actions { display: flex; gap: 6px; }
/* ── Trip card ───────────────────────────────────────────────── */
.trip-card {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 6px;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.trip-card:hover,
.trip-card.selected {
border-color: rgba(255,111,0,0.5);
background: rgba(255,111,0,0.05);
}
.trip-card-driver { font-size: 13px; font-weight: 600; color: #FF6F00; margin-bottom: 2px; }
.trip-card-passenger { font-size: 12px; color: #999; margin-bottom: 3px; }
.trip-card-route { font-size: 11px; color: #555; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.trip-card-status { font-size: 10px; color: #00cc66; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
/* ── Google Maps InfoWindow — dark theme override ────────────── */
.gm-style-iw-c {
background: rgba(13,13,13,0.95) !important;
border: 1px solid rgba(255,111,0,0.4) !important;
border-radius: 10px !important;
padding: 0 !important;
box-shadow: 0 4px 24px rgba(0,0,0,0.75) !important;
}
.gm-style-iw-d { overflow: hidden !important; }
.gm-style-iw-t::after { background: rgba(13,13,13,0.95) !important; }
.gm-ui-hover-effect,
button[aria-label="Close"] { display: none !important; }
/* ── Trip panel (sidebar) ────────────────────────────────────── */
.cc-trip-panel-section { flex: none !important; }
.cc-panel-body {
padding: 8px;
overflow-y: auto;
max-height: 340px;
scrollbar-width: thin;
scrollbar-color: rgba(255,111,0,0.3) transparent;
}
#cc-panel-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-bottom: 8px;
}
.cc-info-item label {
display: block;
font-size: 9px;
color: #555;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 2px;
}
.cc-info-item span { font-size: 12px; color: #ddd; font-weight: 500; }
.cc-panel-close {
margin-left: auto;
background: none; border: none; color: #555;
cursor: pointer; font-size: 15px; line-height: 1;
padding: 0 2px;
transition: color 0.2s;
}
.cc-panel-close:hover { color: #fff; }
#cc-panel-btns {
display: flex; flex-wrap: wrap; gap: 6px;
padding-top: 8px;
border-top: 1px solid rgba(255,255,255,0.06);
}
/* ── Pattern analysis ────────────────────────────────────────── */
.cc-pattern {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 8px;
padding: 8px 10px;
margin-bottom: 8px;
font-size: 12px;
}
.cc-pattern-hdr {
font-size: 9px; text-transform: uppercase;
letter-spacing: 1.2px; color: #FF6F00;
font-weight: 700; margin-bottom: 7px;
}
.cc-pattern-row {
display: flex; justify-content: space-between;
margin-bottom: 4px; color: #888; font-size: 11px;
}
.cc-pattern-row strong { color: #ddd; }
.cc-p-ok { color: #00cc66; font-size: 11px; font-weight: 600; margin-top: 5px; }
.cc-p-warn { color: #FF6F00; font-size: 11px; font-weight: 600; margin-top: 5px; }
.cc-p-alert { color: #ff4444; font-size: 11px; font-weight: 600; margin-top: 5px; }
/* ── Buttons ─────────────────────────────────────────────────── */
.ccb {
padding: 8px 14px;
border-radius: 8px; border: none;
cursor: pointer; font-size: 12px; font-weight: 600;
transition: opacity 0.15s, transform 0.1s;
white-space: nowrap; font-family: inherit;
}
.ccb:hover { opacity: 0.8; }
.ccb:active { transform: scale(0.97); }
.ccb-green { background: rgba(0,204,102,0.15); color: #00cc66; border: 1px solid rgba(0,204,102,0.4); }
.ccb-red { background: rgba(255,68,68,0.15); color: #ff5555; border: 1px solid rgba(255,68,68,0.4); }
.ccb-orange { background: rgba(255,111,0,0.15); color: #FF6F00; border: 1px solid rgba(255,111,0,0.4); }
.ccb-blue { background: rgba(66,153,225,0.15); color: #66b3ff; border: 1px solid rgba(66,153,225,0.4); }
.ccb-dark { background: rgba(255,255,255,0.07); color: #999; border: 1px solid rgba(255,255,255,0.12); }
.ccbs { padding: 4px 10px; border-radius: 6px; border: none; cursor: pointer; font-size: 11px; font-weight: 600; font-family: inherit; transition: opacity 0.15s; }
.ccbs:hover { opacity: 0.75; }
.ccbs-resolve { background: rgba(0,204,102,0.15); color: #00cc66; border: 1px solid rgba(0,204,102,0.3); }
.ccbs-select { background: rgba(255,111,0,0.12); color: #FF6F00; border: 1px solid rgba(255,111,0,0.3); }
/* ── Modals ──────────────────────────────────────────────────── */
.cc-modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75); z-index: 400; align-items: center; justify-content: center; }
.cc-modal.open { display: flex; }
.cc-modal-inner {
background: #141414;
border: 1px solid rgba(255,111,0,0.3);
border-radius: 12px;
padding: 22px 24px;
width: 480px; max-width: 95vw;
max-height: 90vh; overflow-y: auto;
}
.cc-modal-inner h3 { color: #FF6F00; font-size: 14px; margin-bottom: 16px; letter-spacing: 0.5px; text-transform: uppercase; }
.cc-modal-inner select,
.cc-modal-inner textarea {
width: 100%;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.12);
color: #e0e0e0;
border-radius: 8px;
padding: 9px 12px;
font-size: 13px;
margin-bottom: 12px;
resize: vertical;
font-family: inherit;
}
.cc-modal-inner select { resize: none; }
.proto-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px; }
.proto-opt {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.09);
color: #ccc; padding: 11px 10px;
border-radius: 8px; cursor: pointer;
font-size: 13px; text-align: left;
transition: all 0.2s; font-family: inherit;
}
.proto-opt:hover { border-color: rgba(255,111,0,0.4); color: #ddd; }
.proto-opt.selected { background: rgba(255,111,0,0.15); border-color: #FF6F00; color: #FF6F00; }
.cc-modal-btns { display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; }
/* ── Toast ───────────────────────────────────────────────────── */
.cc-toast {
position: fixed; bottom: 24px; right: 20px;
background: #1c1c1c; border-radius: 8px;
padding: 11px 16px; font-size: 13px; color: #fff;
z-index: 9999; transform: translateX(120%);
transition: transform 0.3s ease;
max-width: 280px; box-shadow: 0 4px 20px rgba(0,0,0,0.5);
}
.cc-toast.show { transform: translateX(0); }
.cc-toast.t-success { border-left: 3px solid #00cc66; }
.cc-toast.t-warning { border-left: 3px solid #FF6F00; }
.cc-toast.t-error { border-left: 3px solid #ff4444; }
.cc-toast.t-info { border-left: 3px solid #66b3ff; }
/* ── SOS section flash ───────────────────────────────────────── */
.sos-flash { animation: sosFlash 0.5s ease-in-out 6; }
@keyframes sosFlash {
0%, 100% { background: transparent; }
50% { background: rgba(255,68,68,0.15); }
}
+616
View File
@@ -0,0 +1,616 @@
/* ── Command Center JS ───────────────────────────────────────── */
const CC_MAP_THEMES = {
dark: [
{ elementType: 'geometry', stylers: [{ color: '#1a1a2e' }] },
{ elementType: 'labels.text.stroke', stylers: [{ color: '#1a1a2e' }] },
{ elementType: 'labels.text.fill', stylers: [{ color: '#9a9abf' }] },
{ featureType: 'road', elementType: 'geometry', stylers: [{ color: '#2d2d44' }] },
{ featureType: 'road.highway', elementType: 'geometry', stylers: [{ color: '#3d3020' }] },
{ featureType: 'water', elementType: 'geometry', stylers: [{ color: '#0d1321' }] },
{ featureType: 'poi', stylers: [{ visibility: 'off' }] },
],
improved: [
{ elementType: 'geometry', stylers: [{ color: '#212121' }] },
{ elementType: 'labels.text.stroke', stylers: [{ color: '#212121' }] },
{ elementType: 'labels.text.fill', stylers: [{ color: '#ffffff' }] },
{ featureType: 'road', elementType: 'geometry', stylers: [{ color: '#3a3a3a' }] },
{ featureType: 'road', elementType: 'labels.text.fill', stylers: [{ color: '#ffffff' }] },
{ featureType: 'road.highway', elementType: 'geometry', stylers: [{ color: '#4a3d20' }] },
{ featureType: 'water', elementType: 'geometry', stylers: [{ color: '#0d2137' }] },
{ featureType: 'landscape', elementType: 'geometry', stylers: [{ color: '#2a2a2a' }] },
{ featureType: 'poi', elementType: 'geometry', stylers: [{ color: '#2a2a2a' }] },
{ featureType: 'poi', elementType: 'labels.text.fill', stylers: [{ color: '#aaaaaa' }] },
{ featureType: 'transit', elementType: 'geometry', stylers: [{ color: '#2f3948' }] },
],
normal: [],
};
const CC = {
map: null,
markers: {},
selectedTrip: null,
lastSosCount: 0,
audioCtx: null,
_curTrip: null,
_hoverWin: null,
_geocoder: null,
_geoCache: {},
/* ── Map init (called by Google Maps callback) ───────────── */
init() {
const savedTheme = localStorage.getItem('cc_map_theme') || 'dark';
const mapEl = document.getElementById('cc-map');
this.map = new google.maps.Map(mapEl, {
zoom: 10,
center: { lat: 25.8100, lng: -80.2780 },
mapTypeId: 'roadmap',
styles: CC_MAP_THEMES[savedTheme] || CC_MAP_THEMES.dark,
streetViewControl: false,
mapTypeControl: false,
gestureHandling: 'greedy',
});
google.maps.event.trigger(this.map, 'resize');
// Marcar botón activo según preferencia guardada
document.querySelectorAll('.cc-theme-btn').forEach(b => {
b.classList.toggle('active', b.dataset.theme === savedTheme);
});
document.querySelectorAll('.cc-theme-btn').forEach(b => {
b.addEventListener('click', () => this.setMapTheme(b.dataset.theme));
});
this.startClock();
this.poll();
setInterval(() => this.pollMap(), 10000);
setInterval(() => this.pollAlerts(), 5000);
setInterval(() => this.pollStats(), 30000);
},
setMapTheme(theme) {
this.map.setOptions({ styles: CC_MAP_THEMES[theme] || CC_MAP_THEMES.dark });
localStorage.setItem('cc_map_theme', theme);
document.querySelectorAll('.cc-theme-btn').forEach(b => {
b.classList.toggle('active', b.dataset.theme === theme);
});
},
/* ── Clock ───────────────────────────────────────────────── */
startClock() {
const tick = () => {
document.getElementById('cc-clock').textContent =
new Date().toLocaleTimeString('es-CO');
};
tick();
setInterval(tick, 1000);
},
/* ── Polling ─────────────────────────────────────────────── */
poll() {
this.pollStats();
this.pollMap();
this.pollAlerts();
},
pollStats() {
this._get('command/stats').then(d => {
if (!d) return;
document.getElementById('kpi-active').textContent = d.active_trips;
document.getElementById('kpi-free').textContent = d.available_drivers;
document.getElementById('kpi-sos').textContent = d.open_sos;
document.getElementById('kpi-today').textContent = d.trips_today;
const box = document.getElementById('kpi-sos-box');
d.open_sos > 0 ? box.classList.add('sos-active') : box.classList.remove('sos-active');
});
},
pollMap() {
this._get('command/live-map').then(d => {
if (!d) return;
this.updateMap(d);
this.updateTripsList(d.trips || []);
document.getElementById('kpi-active').textContent = (d.trips || []).length;
document.getElementById('kpi-free').textContent = (d.drivers || []).length;
});
},
pollAlerts() {
this._get('command/alerts').then(d => {
if (!d) return;
const sos = d.sos || [];
if (sos.length > this.lastSosCount && this.lastSosCount >= 0) {
this.playAlert();
const sec = document.getElementById('cc-sos-section');
sec.classList.add('sos-flash');
setTimeout(() => sec.classList.remove('sos-flash'), 3000);
}
this.lastSosCount = sos.length;
document.getElementById('sos-count-badge').textContent = sos.length;
this.updateSosList(sos);
});
},
/* ── Map markers ─────────────────────────────────────────── */
carIcon(color) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 40" width="24" height="40">
<path d="M4,12 Q4,4 12,2 Q20,4 20,12 L20,30 Q20,38 12,38 Q4,38 4,30 Z" fill="${color}"/>
<path d="M7,10 Q12,6 17,10 L16,17 Q12,15 8,17 Z" fill="rgba(255,255,255,0.45)"/>
<path d="M7,23 Q12,25 17,23 L16,30 Q12,34 8,30 Z" fill="rgba(255,255,255,0.3)"/>
<rect x="1" y="11" width="4" height="7" rx="2" fill="#111"/>
<rect x="19" y="11" width="4" height="7" rx="2" fill="#111"/>
<rect x="1" y="24" width="4" height="7" rx="2" fill="#111"/>
<rect x="19" y="24" width="4" height="7" rx="2" fill="#111"/>
<circle cx="12" cy="20" r="2" fill="rgba(255,255,255,0.25)"/>
</svg>`;
return {
url: 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg),
scaledSize: new google.maps.Size(24, 40),
anchor: new google.maps.Point(12, 38),
};
},
updateMap(data) {
const active = new Set();
(data.trips || []).forEach(t => {
if (!t.driver_lat || !t.driver_lng) return;
const key = 'trip_' + t.id;
active.add(key);
const pos = { lat: parseFloat(t.driver_lat), lng: parseFloat(t.driver_lng) };
if (this.markers[key]) {
const prev = this.markers[key].getPosition();
this.markers[key]._prevPos = { lat: prev.lat(), lng: prev.lng() };
this.markers[key].setPosition(pos);
this.markers[key]._tripData = t;
} else {
const m = new google.maps.Marker({
position: pos,
map: this.map,
title: (t.driver_name || 'Conductor') + ' → ' + (t.passenger_name || 'Pasajero'),
icon: this.carIcon('#FF6F00'),
});
m._tripData = t;
m._prevPos = null;
m.addListener('click', () => this.selectTrip(t.id));
m.addListener('mouseover', () => this._showTripHover(m));
m.addListener('mouseout', () => { if (this._hoverWin) this._hoverWin.close(); });
this.markers[key] = m;
}
});
(data.drivers || []).forEach(d => {
if (!d.lat || !d.lng) return;
const key = 'drv_' + d.user_id;
active.add(key);
const pos = { lat: parseFloat(d.lat), lng: parseFloat(d.lng) };
if (this.markers[key]) {
this.markers[key].setPosition(pos);
} else {
const m = new google.maps.Marker({
position: pos,
map: this.map,
title: (d.display_name || 'Conductor') + ' (Disponible)',
icon: this.carIcon('#00cc66'),
});
m.addListener('click', () => {
if (!this._infoWin) this._infoWin = new google.maps.InfoWindow();
this._infoWin.setContent(
`<div style="font-family:sans-serif;padding:4px 8px;min-width:160px">
<strong style="color:#FF6F00">${d.display_name || 'Conductor'}</strong><br>
<span style="color:#888;font-size:12px">Disponible</span><br>
<span style="font-size:11px">Lat: ${parseFloat(d.lat).toFixed(5)}, Lng: ${parseFloat(d.lng).toFixed(5)}</span>
</div>`
);
this._infoWin.open(this.map, m);
});
this.markers[key] = m;
}
});
Object.keys(this.markers).forEach(k => {
if (!active.has(k)) {
this.markers[k].setMap(null);
delete this.markers[k];
}
});
},
/* ── Sidebar lists ───────────────────────────────────────── */
updateSosList(sos) {
const el = document.getElementById('sos-list');
if (!sos.length) {
el.innerHTML = '<div class="cc-empty">Sin alertas activas</div>';
return;
}
el.innerHTML = sos.map(s => `
<div class="sos-card">
<div class="sos-card-top">
<span class="sos-card-name">🚨 ${this._esc(s.user_name || 'Usuario desconocido')}</span>
<span class="sos-card-time">${this._ago(s.created_at)}</span>
</div>
<div class="sos-card-type">${this._esc(s.type || 'SOS')} — Viaje #${s.trip_id || '—'}</div>
${s.description ? `<div class="sos-card-type">${this._esc(s.description)}</div>` : ''}
<div class="sos-card-actions">
${s.trip_id ? `<button class="ccbs ccbs-select" onclick="CC.selectTrip(${s.trip_id})">Ver viaje</button>` : ''}
<button class="ccbs ccbs-resolve" onclick="CC.resolveAlert(${s.id})">Resolver</button>
</div>
</div>
`).join('');
},
updateTripsList(trips) {
const el = document.getElementById('trips-list');
if (!trips.length) {
el.innerHTML = '<div class="cc-empty">Sin viajes activos</div>';
return;
}
el.innerHTML = trips.map(t => `
<div class="trip-card ${this.selectedTrip === t.id ? 'selected' : ''}" onclick="CC.selectTrip(${t.id})">
<div class="trip-card-driver">${this._esc(t.driver_name || 'Conductor #' + t.driver_id)}</div>
<div class="trip-card-passenger">👤 ${this._esc(t.passenger_name || 'Pasajero #' + t.passenger_id)}</div>
<div class="trip-card-route">📍 ${this._esc((t.pickup_address || '').substring(0, 42))}</div>
<div class="trip-card-status">${t.status}${!t.driver_lat ? ' · <span style="color:#ff4444;font-size:9px">Sin GPS</span>' : ''}</div>
</div>
`).join('');
},
/* ── Trip detail ─────────────────────────────────────────── */
selectTrip(id) {
this.selectedTrip = id;
this._get('command/trip/' + id).then(data => {
if (!data || !data.trip) return;
this._showDetail(data.trip);
});
},
_showDetail(t) {
this._curTrip = t;
document.getElementById('cc-panel-title').textContent = 'Viaje #' + t.id + (t.status ? ' · ' + t.status : '');
document.getElementById('cc-panel-info').innerHTML = `
<div class="cc-info-item"><label>Conductor</label><span>${this._esc(t.driver_name || '#' + t.driver_id)}</span></div>
<div class="cc-info-item"><label>Pasajero</label><span>${this._esc(t.passenger_name || '#' + t.passenger_id)}</span></div>
<div class="cc-info-item"><label>Tel. Conductor</label><span>${this._esc(t.driver_phone || 'N/A')}</span></div>
<div class="cc-info-item"><label>Tel. Pasajero</label><span>${this._esc(t.passenger_phone || 'N/A')}</span></div>
<div class="cc-info-item"><label>Destino</label><span id="cc-panel-dest">${this._esc(t.dropoff_address || 'Consultando...')}</span></div>
<div class="cc-info-item"><label>Estado</label><span>${this._esc(t.status || 'N/A')}</span></div>
<div class="cc-info-item"><label>Inicio</label><span>${t.started_at ? this._time(t.started_at) : 'N/A'}</span></div>
`;
document.getElementById('cc-panel-pattern').innerHTML = this._patternHtml(t);
document.getElementById('cc-panel-btns').innerHTML = `
<button class="ccb ccb-green" onclick="CC.callDriver()">📞 Conductor</button>
<button class="ccb ccb-green" onclick="CC.callPassenger()">📞 Pasajero</button>
<button class="ccb ccb-red" onclick="CC.callPolice()">🚔 Policía</button>
<button class="ccb ccb-orange" onclick="CC.openProtocol()">⚠️ Protocolo</button>
<button class="ccb ccb-blue" onclick="CC.openMessage()">💬 Mensaje</button>
<button class="ccb ccb-dark" onclick="CC.blockDriver()">🚫 Bloquear</button>
`;
document.getElementById('cc-trip-panel').style.display = '';
if (!t.dropoff_address && t.dropoff_lat && t.dropoff_lng)
this._geocodeAndUpdate(t.dropoff_lat, t.dropoff_lng, 'cc-panel-dest');
},
closeDetail() {
document.getElementById('cc-trip-panel').style.display = 'none';
this.selectedTrip = null;
this._curTrip = null;
},
/* ── Actions ─────────────────────────────────────────────── */
callDriver() {
const t = this._curTrip;
if (!t) return;
if (t.driver_phone) window.location.href = 'tel:' + t.driver_phone;
else this._toast('Teléfono del conductor no disponible', 'warning');
},
callPassenger() {
const t = this._curTrip;
if (!t) return;
if (t.passenger_phone) window.location.href = 'tel:' + t.passenger_phone;
else this._toast('Teléfono del pasajero no disponible', 'warning');
},
callPolice() {
if (!confirm('¿Confirmar llamada de emergencia a la Policía Nacional (123)?')) return;
window.location.href = 'tel:123';
if (this._curTrip) {
this._post('command/incident', {
trip_id: this._curTrip.id,
type: 'police_called',
description: 'Operador llamó a la Policía desde consola de comando',
});
}
},
openProtocol() {
if (!this._curTrip) return;
document.querySelectorAll('.proto-opt').forEach(b => b.classList.remove('selected'));
document.getElementById('proto-notes').value = '';
document.getElementById('cc-modal-protocol').classList.add('open');
},
selectProtocol(btn) {
document.querySelectorAll('.proto-opt').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
},
confirmProtocol() {
const sel = document.querySelector('.proto-opt.selected');
if (!sel) { this._toast('Selecciona un protocolo', 'warning'); return; }
const protocol = sel.dataset.p;
const notes = document.getElementById('proto-notes').value.trim();
this._post('command/incident', {
trip_id: this._curTrip.id,
type: 'protocol_activated',
description: notes,
protocol: protocol,
}).then(() => {
this.closeModal('protocol');
this._toast('Protocolo "' + protocol + '" activado', 'success');
this.poll();
});
},
openMessage() {
if (!this._curTrip) return;
document.getElementById('msg-body').value = '';
document.getElementById('cc-modal-message').classList.add('open');
},
sendMessage() {
const t = this._curTrip;
const to = document.getElementById('msg-to').value;
const msg = document.getElementById('msg-body').value.trim();
if (!msg) { this._toast('Escribe un mensaje', 'warning'); return; }
const toId = to === 'driver' ? t.driver_id : t.passenger_id;
this._post('command/message', {
trip_id: t.id,
to_user_id: toId,
message: msg,
}).then(() => {
this.closeModal('message');
this._toast('Mensaje enviado', 'success');
});
},
blockDriver() {
const t = this._curTrip;
if (!t) return;
const name = t.driver_name || '#' + t.driver_id;
const reason = prompt('¿Razón para bloquear al conductor ' + name + '?');
if (reason === null) return;
this._post('command/block-driver', { driver_id: t.driver_id, reason }).then(() => {
this._toast('Conductor bloqueado', 'warning');
this.closeDetail();
this.poll();
});
},
resolveAlert(id) {
const notes = prompt('Notas de resolución (opcional):') ?? '';
this._post('command/resolve/' + id, { notes }).then(() => {
this._toast('Alerta resuelta', 'success');
this.pollAlerts();
this.pollStats();
});
},
closeModal(name) {
document.getElementById('cc-modal-' + name).classList.remove('open');
},
/* ── Hover tooltip ───────────────────────────────────────────── */
_showTripHover(m) {
const td = m._tripData;
if (!this._hoverWin) this._hoverWin = new google.maps.InfoWindow({ disableAutoPan: true });
if (!this._geocoder) this._geocoder = new google.maps.Geocoder();
const driverName = this._esc(td.driver_name || 'Conductor');
const paxName = this._esc(td.passenger_name || 'Pasajero');
const curLat = parseFloat(td.driver_lat);
const curLng = parseFloat(td.driver_lng);
const curKey = curLat.toFixed(4) + ',' + curLng.toFixed(4);
const destKey = (td.dropoff_lat || '') + ',' + (td.dropoff_lng || '');
const dirStr = m._prevPos
? this._bearingDir(this._bearing(m._prevPos.lat, m._prevPos.lng, curLat, curLng))
: null;
const distMi = (td.dropoff_lat && td.dropoff_lng)
? (this._calcDist(curLat, curLng, parseFloat(td.dropoff_lat), parseFloat(td.dropoff_lng)) * 0.621371).toFixed(1)
: null;
const refresh = () => {
if (!this._hoverWin.getMap()) return;
const curAddr = this._geoCache[curKey] || (curLat.toFixed(4) + ', ' + curLng.toFixed(4));
const destAddr = this._geoCache[destKey] || 'Cargando...';
this._hoverWin.setContent(this._hoverHtml(driverName, paxName, curAddr, destAddr, distMi, dirStr));
};
if (!this._geoCache[curKey]) {
this._geocoder.geocode({ location: { lat: curLat, lng: curLng } }, (res, st) => {
this._geoCache[curKey] = (st === 'OK' && res[0]) ? res[0].formatted_address : curKey;
refresh();
});
}
if (!this._geoCache[destKey] && td.dropoff_lat && td.dropoff_lng) {
this._geocoder.geocode({ location: { lat: parseFloat(td.dropoff_lat), lng: parseFloat(td.dropoff_lng) } }, (res, st) => {
this._geoCache[destKey] = (st === 'OK' && res[0]) ? res[0].formatted_address : destKey;
refresh();
});
}
refresh();
this._hoverWin.open(this.map, m);
},
_hoverHtml(driver, pax, curPos, dest, distMi, dir) {
const arrow = dir ? this._dirArrow(dir) : '';
const row = (label, val) =>
'<div style="display:flex;justify-content:space-between;gap:10px;margin-bottom:3px">'
+ '<span style="color:#666;font-size:10px;white-space:nowrap">' + label + '</span>'
+ '<span style="color:#d0d0d0;font-size:11px;text-align:right">' + val + '</span></div>';
return '<div style="padding:10px 14px;min-width:210px;max-width:290px;font-family:\'Segoe UI\',sans-serif;">'
+ '<div style="color:#FF6F00;font-size:12px;font-weight:700;margin-bottom:7px;border-bottom:1px solid rgba(255,111,0,0.25);padding-bottom:5px">🚗 ' + driver + '</div>'
+ row('Pasajero', '👤 ' + pax)
+ row('Posición', curPos)
+ row('Destino', this._esc(dest))
+ (distMi ? row('Distancia', distMi + ' mi') : '')
+ (dir ? row('Dirección', arrow + ' ' + this._esc(dir)) : '')
+ '</div>';
},
_dirArrow(dir) {
const map = { 'N':'↑','NE':'↗','E':'→','SE':'↘','S':'↓','SO':'↙','O':'←','NO':'↖' };
return (map[dir.split(' ')[0]] || '↗');
},
/* ── Pattern analysis ────────────────────────────────────────── */
_patternHtml(t) {
if (!t.dropoff_lat || !t.dropoff_lng || !t.current_lat || !t.current_lng) {
return '<div class="cc-pattern"><div class="cc-pattern-hdr">Análisis de Ruta</div>'
+ '<div style="color:#555;font-size:11px">Sin datos de posición actual</div></div>';
}
const dist = this._calcDist(
parseFloat(t.current_lat), parseFloat(t.current_lng),
parseFloat(t.dropoff_lat), parseFloat(t.dropoff_lng)
);
let cls, label;
if (dist < 0.5) { cls = 'cc-p-ok'; label = '✔ Llegando al destino'; }
else if (dist < 8) { cls = 'cc-p-ok'; label = '✔ En ruta normal'; }
else if (dist < 20) { cls = 'cc-p-warn'; label = '⚠ Distancia elevada — verificar'; }
else { cls = 'cc-p-alert'; label = '✘ Posible desvío de ruta'; }
let timeRow = '';
if (t.started_at) {
const mins = Math.floor((Date.now() - new Date(t.started_at).getTime()) / 60000);
timeRow = '<div class="cc-pattern-row"><span>Tiempo en viaje</span><strong>' + mins + ' min</strong></div>';
}
return '<div class="cc-pattern">'
+ '<div class="cc-pattern-hdr">Análisis de Ruta</div>'
+ '<div class="cc-pattern-row"><span>Dist. al destino</span><strong>' + dist.toFixed(1) + ' km</strong></div>'
+ timeRow
+ '<div class="' + cls + '">' + label + '</div>'
+ '</div>';
},
_calcDist(lat1, lng1, lat2, lng2) {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180) * Math.cos(lat2*Math.PI/180) * Math.sin(dLon/2)**2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
},
_bearing(lat1, lng1, lat2, lng2) {
const φ1 = lat1 * Math.PI/180, φ2 = lat2 * Math.PI/180;
const Δλ = (lng2 - lng1) * Math.PI/180;
const y = Math.sin(Δλ) * Math.cos(φ2);
const x = Math.cos(φ1)*Math.sin(φ2) - Math.sin(φ1)*Math.cos(φ2)*Math.cos(Δλ);
return ((Math.atan2(y, x) * 180/Math.PI) + 360) % 360;
},
_bearingDir(deg) {
const dirs = ['N','NE','E','SE','S','SO','O','NO'];
return dirs[Math.round(deg / 45) % 8] + ' (' + Math.round(deg) + '°)';
},
_geocodeAndUpdate(lat, lng, elId) {
if (!this._geocoder) this._geocoder = new google.maps.Geocoder();
const key = lat + ',' + lng;
if (this._geoCache[key]) {
const el = document.getElementById(elId);
if (el) el.textContent = this._geoCache[key];
return;
}
this._geocoder.geocode({ location: { lat: parseFloat(lat), lng: parseFloat(lng) } }, (results, status) => {
const addr = (status === 'OK' && results[0]) ? results[0].formatted_address : lat + ', ' + lng;
this._geoCache[key] = addr;
const el = document.getElementById(elId);
if (el) el.textContent = addr;
});
},
/* ── Audio alert ─────────────────────────────────────────── */
playAlert() {
try {
if (!this.audioCtx)
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
[0, 0.45, 0.9].forEach(delay => {
const o = this.audioCtx.createOscillator();
const g = this.audioCtx.createGain();
o.connect(g); g.connect(this.audioCtx.destination);
o.type = 'square';
o.frequency.value = 880;
const t = this.audioCtx.currentTime + delay;
g.gain.setValueAtTime(0.12, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
o.start(t); o.stop(t + 0.35);
});
} catch (_) {}
},
/* ── Helpers ─────────────────────────────────────────────── */
_get(endpoint) {
return fetch(ccConfig.rest + endpoint, {
headers: { 'X-WP-Nonce': ccConfig.nonce },
}).then(r => r.ok ? r.json() : null).catch(() => null);
},
_post(endpoint, data) {
return fetch(ccConfig.rest + endpoint, {
method: 'POST',
headers: { 'X-WP-Nonce': ccConfig.nonce, 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).then(r => r.json()).catch(() => null);
},
_toast(msg, type = 'info') {
const el = document.createElement('div');
el.className = 'cc-toast t-' + type;
el.textContent = msg;
document.body.appendChild(el);
requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('show')));
setTimeout(() => {
el.classList.remove('show');
setTimeout(() => el.remove(), 350);
}, 3000);
},
_ago(dt) {
const m = Math.floor((Date.now() - new Date(dt).getTime()) / 60000);
if (m < 1) return 'ahora';
if (m < 60) return m + 'min';
return Math.floor(m / 60) + 'h';
},
_time(dt) { return new Date(dt).toLocaleTimeString('es-CO'); },
_esc(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
},
};
/* Wait for google.maps.Map, then give it 1500ms to stabilize before init */
(function waitForMaps(attempts) {
if (CC.map) return;
if (window.google && window.google.maps && typeof window.google.maps.Map === 'function') {
setTimeout(function() { if (!CC.map) CC.init(); }, 1500);
} else if (attempts < 40) {
setTimeout(function(){ waitForMaps(attempts + 1); }, 250);
}
})(0);
@@ -0,0 +1,583 @@
# AutoBooking Security — Plan de Implementación
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Implementar seguridad anti-spam, anti-ghost-registration, rate limiting, IP geolocation backup, documentos de conductor, y eliminar la API key de Google Maps del código fuente.
**Architecture:** Plugin nuevo `autobooking-security` centraliza rate limiting e IP blocking. Plugins existentes `autobooking-admin-dashboard` y `autobooking-geo-restrict` se modifican quirúrgicamente. Snippets #14, #15, #18, #19 NO se tocan.
**Tech Stack:** PHP 8.x, WordPress REST API, MariaDB, hCaptcha (hcaptcha.com), ip-api.com, WordPress transients.
**Servidor:** `pi@ssh.famromdon.online:2230` pw `Geronimo6&8` | Container: `autobookingonline-wordpress-autobooking-1` | Plugins: `/var/www/html/wp-content/plugins/` | Local: `D:\Proyectos Software\AutoBooking\`
**Deploy estándar** (usar en cada tarea):
```powershell
$psftp = "C:\Program Files\PuTTY\psftp.exe"
$plink = "C:\Program Files\PuTTY\plink.exe"
$cmds = "$env:TEMP\sftp_cmd.txt"
# 1. subir archivo local → /tmp/archivo.php
[System.IO.File]::WriteAllText($cmds, "put `"D:\Proyectos Software\AutoBooking\ARCHIVO.php`" /tmp/ARCHIVO.php`nbye", (New-Object System.Text.UTF8Encoding $false))
& $psftp -pw "Geronimo6&8" -P 2230 -b $cmds pi@ssh.famromdon.online
# 2. mover al container
echo "y" | & $plink -pw "Geronimo6&8" -P 2230 pi@ssh.famromdon.online 'docker cp /tmp/ARCHIVO.php autobookingonline-wordpress-autobooking-1:/var/www/html/wp-content/plugins/PLUGIN/ARCHIVO.php'
```
---
## Prerequisito manual (ANTES de empezar)
- [ ] Ir a **hcaptcha.com** → crear cuenta gratuita → crear sitio `autobooking.online` → copiar **Site Key** y **Secret Key**
- [ ] **Google Cloud Console** → Credenciales → key `AIzaSyD3VSlYZvDEbbSKUhEFRdUD5rU1JWXX03Q` → agregar restricción HTTP referrer: `*.autobooking.online/*`
---
## Tarea 1: Plugin autobooking-security — Tablas DB + Rate Limiting
**Archivos:** Crear `autobooking-security.php` → subir a `/var/www/html/wp-content/plugins/autobooking-security/autobooking-security.php`
- [ ] **1.1 Crear el archivo del plugin** en `D:\Proyectos Software\AutoBooking\autobooking-security.php`:
```php
<?php
/**
* Plugin Name: AutoBooking Security
* Description: Rate limiting, IP blocking, hCaptcha, email confirmation, driver documents.
* Version: 1.0.0
* Author: AutoBooking
*/
if ( ! defined( 'ABSPATH' ) ) exit;
/* ── ACTIVACIÓN: crea tablas ──────────────────────────────── */
register_activation_hook( __FILE__, 'abs_activate' );
function abs_activate() {
global $wpdb; $charset = $wpdb->get_charset_collate();
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}ab_rate_limits (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
ip VARCHAR(45) NOT NULL DEFAULT '',
endpoint VARCHAR(80) NOT NULL DEFAULT '',
hits INT UNSIGNED NOT NULL DEFAULT 1,
window_start DATETIME NOT NULL,
PRIMARY KEY (id), KEY ip_ep_win (ip, endpoint, window_start)
) $charset;" );
dbDelta( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}ab_blocked_ips (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
ip VARCHAR(45) NOT NULL DEFAULT '',
reason VARCHAR(120) NOT NULL DEFAULT '',
blocked_at DATETIME NOT NULL,
expires_at DATETIME NOT NULL,
PRIMARY KEY (id), UNIQUE KEY ip (ip), KEY ip_exp (ip, expires_at)
) $charset;" );
dbDelta( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}ab_driver_documents (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
driver_id BIGINT UNSIGNED NOT NULL,
doc_type ENUM('license','insurance','vehicle_photo') NOT NULL,
file_url VARCHAR(512) NOT NULL DEFAULT '',
expiry_date DATE NULL,
status ENUM('pending','approved','rejected') NOT NULL DEFAULT 'pending',
reviewed_by BIGINT UNSIGNED NULL,
reviewed_at DATETIME NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id), KEY driver_doc (driver_id, doc_type), KEY status (status)
) $charset;" );
}
/* ── HELPERS ──────────────────────────────────────────────── */
function abs_get_ip() {
$ip = '';
if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
$parts = explode( ',', $_SERVER['HTTP_X_FORWARDED_FOR'] );
$ip = trim( $parts[0] );
}
if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
}
return sanitize_text_field( $ip );
}
function abs_is_whitelisted( $ip ) {
$raw = get_option( 'ab_sec_whitelist', '' );
if ( ! $raw ) return false;
return in_array( $ip, array_map( 'trim', explode( "\n", $raw ) ), true );
}
function abs_is_blocked( $ip ) {
global $wpdb;
return (bool) $wpdb->get_var( $wpdb->prepare(
"SELECT id FROM {$wpdb->prefix}ab_blocked_ips WHERE ip = %s AND expires_at > %s",
$ip, current_time( 'mysql' )
) );
}
function abs_block_ip( $ip, $reason, $hours ) {
global $wpdb;
$wpdb->query( $wpdb->prepare(
"INSERT INTO {$wpdb->prefix}ab_blocked_ips (ip, reason, blocked_at, expires_at) VALUES (%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE reason=VALUES(reason), blocked_at=VALUES(blocked_at), expires_at=VALUES(expires_at)",
$ip, $reason, current_time('mysql'), date('Y-m-d H:i:s', time() + $hours * 3600)
) );
}
function abs_check_rate( $ip, $endpoint, $limit, $window_sec, $block_hours = 0 ) {
if ( ! $ip || abs_is_whitelisted( $ip ) ) return true;
if ( abs_is_blocked( $ip ) ) return false;
global $wpdb; $table = $wpdb->prefix . 'ab_rate_limits';
$window = date( 'Y-m-d H:i:s', time() - $window_sec );
$hits = (int) $wpdb->get_var( $wpdb->prepare(
"SELECT SUM(hits) FROM $table WHERE ip=%s AND endpoint=%s AND window_start>=%s",
$ip, $endpoint, $window
) );
if ( $hits >= $limit ) {
if ( $block_hours > 0 ) abs_block_ip( $ip, "Rate limit: $endpoint", $block_hours );
return false;
}
$minute = date( 'Y-m-d H:i:00' );
$exists = $wpdb->get_var( $wpdb->prepare(
"SELECT id FROM $table WHERE ip=%s AND endpoint=%s AND window_start=%s",
$ip, $endpoint, $minute
) );
if ( $exists ) {
$wpdb->query( $wpdb->prepare(
"UPDATE $table SET hits=hits+1 WHERE ip=%s AND endpoint=%s AND window_start=%s",
$ip, $endpoint, $minute
) );
} else {
$wpdb->insert( $table, ['ip'=>$ip,'endpoint'=>$endpoint,'hits'=>1,'window_start'=>$minute] );
}
return true;
}
/* ── RATE LIMITING: login ─────────────────────────────────── */
add_action( 'wp_login_failed', function( $u ) {
abs_check_rate( abs_get_ip(), 'login', 10, 900, 2 );
} );
add_action( 'login_init', function() {
if ( abs_is_blocked( abs_get_ip() ) )
wp_die( 'Tu IP fue bloqueada temporalmente por demasiados intentos fallidos.', 'Bloqueado', ['response'=>429] );
} );
/* ── RATE LIMITING: registro ──────────────────────────────── */
add_filter( 'registration_errors', function( $errors, $login, $email ) {
if ( ! abs_check_rate( abs_get_ip(), 'register', 5, 3600, 24 ) )
$errors->add( 'rate_limit', '<strong>Error:</strong> Demasiados intentos desde tu IP. Intenta más tarde.' );
return $errors;
}, 10, 3 );
/* ── RATE LIMITING: REST ──────────────────────────────────── */
add_filter( 'rest_dispatch_request', function( $result, $request, $route, $handler ) {
if ( strpos( $route, '/autobooking/v1/' ) === false ) return $result;
if ( $request->get_method() === 'GET' ) return $result;
if ( ! abs_check_rate( abs_get_ip(), 'rest_post', 120, 60, 0 ) )
return new WP_Error( 'rate_limited', 'Demasiadas solicitudes.', ['status'=>429] );
return $result;
}, 10, 4 );
/* ── LIMPIEZA DIARIA ──────────────────────────────────────── */
add_action( 'abs_daily_cleanup', function() {
global $wpdb;
$wpdb->query("DELETE FROM {$wpdb->prefix}ab_rate_limits WHERE window_start < DATE_SUB(NOW(), INTERVAL 2 DAY)");
$wpdb->query("DELETE FROM {$wpdb->prefix}ab_blocked_ips WHERE expires_at < NOW()");
} );
if ( ! wp_next_scheduled('abs_daily_cleanup') ) wp_schedule_event( time(), 'daily', 'abs_daily_cleanup' );
/* ── HCAPTCHA ─────────────────────────────────────────────── */
add_action( 'register_form', function() {
$key = get_option('ab_hcaptcha_site_key','');
if ( ! $key ) return;
echo '<div class="h-captcha" data-sitekey="'.esc_attr($key).'" style="margin:12px 0;"></div>';
echo '<script src="https://js.hcaptcha.com/1/api.js" async defer></script>';
} );
add_filter( 'registration_errors', function( $errors, $login, $email ) {
$secret = get_option('ab_hcaptcha_secret','');
if ( ! $secret ) return $errors;
$token = sanitize_text_field( $_POST['h-captcha-response'] ?? '' );
if ( ! $token ) { $errors->add('captcha_missing','<strong>Error:</strong> Completa el captcha.'); return $errors; }
$res = wp_remote_post('https://api.hcaptcha.com/siteverify', ['body'=>['secret'=>$secret,'response'=>$token,'remoteip'=>abs_get_ip()],'timeout'=>10]);
if ( ! is_wp_error($res) ) {
$body = json_decode(wp_remote_retrieve_body($res), true);
if ( empty($body['success']) ) $errors->add('captcha_invalid','<strong>Error:</strong> Captcha inválido.');
}
return $errors;
}, 20, 3 );
/* ── EMAIL CONFIRMATION ───────────────────────────────────── */
add_action( 'user_register', function( $uid ) {
$user = get_userdata($uid);
if ( ! $user || ! in_array('driver_pending',(array)$user->roles,true) ) return;
$token = bin2hex(random_bytes(32));
update_user_meta($uid,'ab_email_token',$token);
update_user_meta($uid,'ab_email_token_expires',date('Y-m-d H:i:s',time()+86400));
update_user_meta($uid,'ab_email_confirmed',0);
$url = add_query_arg('token',$token,get_rest_url(null,'autobooking/v1/confirm-email'));
wp_mail($user->user_email,'Confirma tu email — AutoBooking',
"Hola {$user->display_name},\n\nConfirma tu email (válido 24h):\n\n$url\n\nAutoBooking");
} );
add_action( 'template_redirect', function() {
if ( ! is_page('driver-dashboard') && ! is_page('pending-driver') ) return;
if ( ! is_user_logged_in() ) return;
$user = wp_get_current_user(); $roles = (array)$user->roles;
if ( ! in_array('driver_pending',$roles,true) && ! in_array('driver',$roles,true) ) return;
if ( (int)get_user_meta($user->ID,'ab_email_confirmed',true) ) return;
$resend = esc_url(get_rest_url(null,'autobooking/v1/resend-confirmation'));
wp_die('<div style="font-family:system-ui;max-width:500px;margin:60px auto;text-align:center;padding:32px;background:#111;color:#fff;border-radius:16px;">'
.'<p style="font-size:20px;font-weight:700;color:#FF6F00;">Confirma tu email</p>'
.'<p style="color:rgba(255,255,255,.7);">Revisa tu bandeja de entrada y haz clic en el enlace de confirmación.</p>'
.'<form method="post" action="'.$resend.'">'.wp_nonce_field('wp_rest','_wpnonce',true,false)
.'<button type="submit" style="margin-top:16px;background:#FF6F00;color:#fff;border:none;padding:12px 28px;border-radius:10px;font-weight:700;cursor:pointer;">Reenviar email</button>'
.'</form></div>','Confirma tu email',['response'=>403]);
} );
/* ── REST: confirm-email, resend, docs, admin IPs ─────────── */
add_action( 'rest_api_init', function() {
register_rest_route('autobooking/v1','/confirm-email',[
'methods'=>'GET','permission_callback'=>'__return_true',
'callback'=>function(WP_REST_Request $req){
global $wpdb;
$token = sanitize_text_field($req->get_param('token'));
if(!$token){wp_redirect(home_url('/?ab_confirm=invalid'));exit;}
$uid = $wpdb->get_var($wpdb->prepare(
"SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key='ab_email_token' AND meta_value=%s LIMIT 1",$token));
if(!$uid){wp_redirect(home_url('/?ab_confirm=invalid'));exit;}
if(strtotime(get_user_meta((int)$uid,'ab_email_token_expires',true))<time()){
wp_redirect(home_url('/?ab_confirm=expired'));exit;}
update_user_meta((int)$uid,'ab_email_confirmed',1);
delete_user_meta((int)$uid,'ab_email_token');
delete_user_meta((int)$uid,'ab_email_token_expires');
wp_redirect(home_url('/pending-driver/?ab_confirm=ok'));exit;
},
]);
register_rest_route('autobooking/v1','/resend-confirmation',[
'methods'=>'POST','permission_callback'=>'is_user_logged_in',
'callback'=>function(){
$user=wp_get_current_user(); $token=bin2hex(random_bytes(32));
update_user_meta($user->ID,'ab_email_token',$token);
update_user_meta($user->ID,'ab_email_token_expires',date('Y-m-d H:i:s',time()+86400));
$url=add_query_arg('token',$token,get_rest_url(null,'autobooking/v1/confirm-email'));
wp_mail($user->user_email,'Confirma tu email — AutoBooking',"Nuevo enlace (válido 24h):\n\n$url");
wp_redirect(home_url('/pending-driver/?ab_confirm=resent'));exit;
},
]);
register_rest_route('autobooking/v1','/driver/upload-doc',[
'methods'=>'POST','permission_callback'=>'is_user_logged_in',
'callback'=>function(WP_REST_Request $req){
global $wpdb; $user=wp_get_current_user();
$doc_type=sanitize_text_field($req->get_param('doc_type'));
if(!in_array($doc_type,['license','insurance','vehicle_photo'],true))
return new WP_Error('invalid_type','Tipo inválido.',['status'=>400]);
if(empty($_FILES['file'])) return new WP_Error('no_file','Sin archivo.',['status'=>400]);
$file=$_FILES['file'];
if(!in_array($file['type'],['image/jpeg','image/png','application/pdf'],true))
return new WP_Error('invalid_mime','Solo JPG, PNG o PDF.',['status'=>400]);
if($file['size']>5*1024*1024) return new WP_Error('file_too_large','Máx 5 MB.',['status'=>400]);
require_once ABSPATH.'wp-admin/includes/file.php';
$up=wp_upload_dir(); $dir=$up['basedir'].'/ab-driver-docs/'.$user->ID;
wp_mkdir_p($dir);
$ext=pathinfo($file['name'],PATHINFO_EXTENSION);
$fname=$doc_type.'_'.time().'.'.$ext;
if(!move_uploaded_file($file['tmp_name'],$dir.'/'.$fname))
return new WP_Error('upload_failed','Error al guardar.',['status'=>500]);
$url=$up['baseurl'].'/ab-driver-docs/'.$user->ID.'/'.$fname;
$data=['driver_id'=>$user->ID,'doc_type'=>$doc_type,'file_url'=>$url,'status'=>'pending','created_at'=>current_time('mysql')];
$exists=$wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$wpdb->prefix}ab_driver_documents WHERE driver_id=%d AND doc_type=%s",$user->ID,$doc_type));
if($exists) $wpdb->update("{$wpdb->prefix}ab_driver_documents",$data,['driver_id'=>$user->ID,'doc_type'=>$doc_type]);
else $wpdb->insert("{$wpdb->prefix}ab_driver_documents",$data);
return rest_ensure_response(['ok'=>true,'file_url'=>$url]);
},
]);
register_rest_route('autobooking/v1','/driver/my-docs',[
'methods'=>'GET','permission_callback'=>'is_user_logged_in',
'callback'=>function(){
global $wpdb; $uid=get_current_user_id();
return rest_ensure_response(['docs'=>$wpdb->get_results($wpdb->prepare(
"SELECT doc_type,file_url,status,expiry_date FROM {$wpdb->prefix}ab_driver_documents WHERE driver_id=%d",$uid),ARRAY_A)?:[]]);
},
]);
register_rest_route('autobooking/v1','/admin/driver/(?P<id>\d+)/docs',[
'methods'=>'GET','permission_callback'=>function(){return current_user_can('manage_autobooking');},
'callback'=>function(WP_REST_Request $req){
global $wpdb; $id=absint($req['id']);
return rest_ensure_response(['docs'=>$wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}ab_driver_documents WHERE driver_id=%d",$id),ARRAY_A)?:[]]);
},
]);
register_rest_route('autobooking/v1','/admin/doc/(?P<id>\d+)/review',[
'methods'=>'POST','permission_callback'=>function(){return current_user_can('manage_autobooking');},
'callback'=>function(WP_REST_Request $req){
global $wpdb; $id=absint($req['id']);
$status=in_array($req->get_param('status'),['approved','rejected'],true)?$req->get_param('status'):'pending';
$wpdb->update("{$wpdb->prefix}ab_driver_documents",
['status'=>$status,'reviewed_by'=>get_current_user_id(),'reviewed_at'=>current_time('mysql')],['id'=>$id]);
return rest_ensure_response(['ok'=>true]);
},
]);
register_rest_route('autobooking/v1','/admin/blocked-ips',[
'methods'=>'GET','permission_callback'=>function(){return current_user_can('manage_autobooking');},
'callback'=>function(){
global $wpdb;
return rest_ensure_response([
'blocked'=>$wpdb->get_results("SELECT id,ip,reason,blocked_at,expires_at FROM {$wpdb->prefix}ab_blocked_ips WHERE expires_at>NOW() ORDER BY blocked_at DESC LIMIT 100",ARRAY_A)?:[],
'blocked_24h'=>(int)$wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}ab_blocked_ips WHERE blocked_at>=DATE_SUB(NOW(),INTERVAL 24 HOUR)"),
]);
},
]);
register_rest_route('autobooking/v1','/admin/blocked-ips/(?P<id>\d+)/unblock',[
'methods'=>'POST','permission_callback'=>function(){return current_user_can('manage_autobooking');},
'callback'=>function(WP_REST_Request $req){
global $wpdb; $wpdb->delete("{$wpdb->prefix}ab_blocked_ips",['id'=>absint($req['id'])]);
return rest_ensure_response(['ok'=>true]);
},
]);
register_rest_route('autobooking/v1','/admin/security-settings/save',[
'methods'=>'POST','permission_callback'=>function(){return current_user_can('manage_autobooking');},
'callback'=>function(WP_REST_Request $req){
$body=$req->get_json_params();
if(isset($body['whitelist'])) update_option('ab_sec_whitelist',sanitize_textarea_field($body['whitelist']));
if(isset($body['hcaptcha_site_key'])) update_option('ab_hcaptcha_site_key',sanitize_text_field($body['hcaptcha_site_key']));
if(isset($body['hcaptcha_secret'])) update_option('ab_hcaptcha_secret',sanitize_text_field($body['hcaptcha_secret']));
return rest_ensure_response(['ok'=>true]);
},
]);
} );
```
- [ ] **1.2 Subir y activar el plugin**
```powershell
$psftp = "C:\Program Files\PuTTY\psftp.exe"; $plink = "C:\Program Files\PuTTY\plink.exe"
$cmds = "$env:TEMP\sftp1.txt"
[System.IO.File]::WriteAllText($cmds, "put `"D:\Proyectos Software\AutoBooking\autobooking-security.php`" /tmp/abs.php`nbye", (New-Object System.Text.UTF8Encoding $false))
& $psftp -pw "Geronimo6&8" -P 2230 -b $cmds pi@ssh.famromdon.online
echo "y" | & $plink -pw "Geronimo6&8" -P 2230 pi@ssh.famromdon.online 'mkdir -p /tmp/abs_plugin && cp /tmp/abs.php /tmp/abs_plugin/autobooking-security.php && docker exec autobookingonline-wordpress-autobooking-1 mkdir -p /var/www/html/wp-content/plugins/autobooking-security && docker cp /tmp/abs_plugin/autobooking-security.php autobookingonline-wordpress-autobooking-1:/var/www/html/wp-content/plugins/autobooking-security/autobooking-security.php'
```
- [ ] **1.3 Activar desde WP Admin**`https://autobooking.online/wp-admin/plugins.php` → Activar "AutoBooking Security"
- [ ] **1.4 Verificar tablas creadas**
```powershell
echo "y" | & $plink -pw "Geronimo6&8" -P 2230 pi@ssh.famromdon.online 'docker exec autobookingonline-wordpress-autobooking-1 php -r "require \"/var/www/html/wp-load.php\"; global \$wpdb; foreach([\"ab_rate_limits\",\"ab_blocked_ips\",\"ab_driver_documents\"] as \$t){ echo \$wpdb->get_var(\"SHOW TABLES LIKE \\\"{$wpdb->prefix}\$t\\\"\") ? \"OK \$t\n\" : \"MISSING \$t\n\"; }"'
```
Esperado: `OK ab_rate_limits`, `OK ab_blocked_ips`, `OK ab_driver_documents`
- [ ] **1.5 Commit**
```bash
git add autobooking-security.php && git commit -m "feat: autobooking-security plugin — rate limiting, hCaptcha, email confirmation, driver docs"
```
---
## Tarea 2: Eliminar GMAPS_KEY del código fuente
**Archivos:** Modificar `autobooking-admin-dashboard.php` (local)
- [ ] **2.1 Backup en servidor**
```powershell
echo "y" | & $plink -pw "Geronimo6&8" -P 2230 pi@ssh.famromdon.online 'docker exec autobookingonline-wordpress-autobooking-1 cp /var/www/html/wp-content/plugins/autobooking-admin-dashboard/autobooking-admin-dashboard.php /var/www/html/wp-content/plugins/autobooking-admin-dashboard/autobooking-admin-dashboard.php.bak'
```
- [ ] **2.2 En el archivo local**, hacer 3 cambios quirúrgicos:
**Cambio A** — línea 17, reemplazar la constante:
```php
// ANTES:
const GMAPS_KEY = 'AIzaSyD3VSlYZvDEbbSKUhEFRdUD5rU1JWXX03Q';
// DESPUÉS (eliminar la línea, la key se lee de wp_options)
```
**Cambio B** — en `enqueue()`, reemplazar el uso de la constante en la URL de Google Maps:
```php
// ANTES:
'https://maps.googleapis.com/maps/api/js?key=' . self::GMAPS_KEY . '&libraries=places,drawing',
// DESPUÉS:
'https://maps.googleapis.com/maps/api/js?key=' . esc_attr( get_option( 'ab_gmaps_key', '' ) ) . '&libraries=places,drawing',
```
**Cambio C** — en `wp_localize_script`:
```php
// ANTES:
'gmaps_key' => self::GMAPS_KEY,
// DESPUÉS:
'gmaps_key' => esc_attr( get_option( 'ab_gmaps_key', '' ) ),
```
**Cambio D** — en `rest_settings_get()`:
```php
// ANTES:
'gmaps_key' => get_option( 'ab_gmaps_key', self::GMAPS_KEY ),
// DESPUÉS:
'gmaps_key' => get_option( 'ab_gmaps_key', '' ),
```
- [ ] **2.3 Desplegar** (mismo comando deploy con `autobooking-admin-dashboard.php`)
- [ ] **2.4 Verificar** — en WP Admin → AutoBooking Admin → tab CONFIG, asegurarse que el campo "Google Maps Key" tiene el valor `AIzaSyD3VSlYZvDEbbSKUhEFRdUD5rU1JWXX03Q`. Si está vacío, pegarlo y guardar.
- [ ] **2.5 Verificar en HTML** — Ver fuente de `/admin-dashboard/` (Ctrl+U) → buscar `AIzaSyD3` → NO debe aparecer como texto plano en el source (aparece como valor de option, no hardcoded).
- [ ] **2.6 Commit**
```bash
git add autobooking-admin-dashboard.php && git commit -m "fix: Google Maps API key moved from hardcode to wp_options"
```
---
## Tarea 3: IP Geolocation en geo-restrict
**Archivos:** Modificar `autobooking-geo-restrict.php` (local)
- [ ] **3.1 Backup en servidor**
```powershell
echo "y" | & $plink -pw "Geronimo6&8" -P 2230 pi@ssh.famromdon.online 'docker exec autobookingonline-wordpress-autobooking-1 cp /var/www/html/wp-content/plugins/autobooking-geo-restrict/autobooking-geo-restrict.php /var/www/html/wp-content/plugins/autobooking-geo-restrict/autobooking-geo-restrict.php.bak'
```
- [ ] **3.2 Agregar función helper** después de `ab_is_country_allowed()` (~línea 67):
```php
function ab_get_country_from_ip( $ip ) {
if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) return null;
$cache_key = 'ab_geo_ip_' . md5( $ip );
$cached = get_transient( $cache_key );
if ( $cached !== false ) return $cached ?: null;
$response = wp_remote_get( 'http://ip-api.com/json/' . rawurlencode( $ip ) . '?fields=countryCode,status', ['timeout'=>5] );
if ( is_wp_error( $response ) ) { set_transient( $cache_key, '', 3600 ); return null; }
$body = json_decode( wp_remote_retrieve_body( $response ), true );
$code = ( ! empty( $body['status'] ) && $body['status'] === 'success' ) ? strtoupper( $body['countryCode'] ?? '' ) : '';
set_transient( $cache_key, $code, 86400 );
return $code ?: null;
}
function ab_get_request_ip() {
if ( function_exists( 'abs_get_ip' ) ) return abs_get_ip();
$ip = '';
if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) { $parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']); $ip = trim($parts[0]); }
if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) $ip = $_SERVER['REMOTE_ADDR'] ?? '';
return sanitize_text_field( $ip );
}
```
- [ ] **3.3 Modificar el callback de `/geo/check`** — reemplazar el return final del callback:
```php
// ANTES (las últimas líneas del callback):
return rest_ensure_response( [
'allowed' => $allowed,
'country' => $country,
'message' => $allowed ? '' : get_option( 'ab_geo_blocked_msg', 'AutoBooking is not available in your current location.' ),
] );
// DESPUÉS:
if ( $allowed && $country ) {
$ip_country = ab_get_country_from_ip( ab_get_request_ip() );
if ( $ip_country && $ip_country !== $country ) {
return rest_ensure_response(['allowed'=>false,'country'=>$country,'message'=>'Ubicación inconsistente. Verifica tu conexión.']);
}
}
return rest_ensure_response( [
'allowed' => $allowed,
'country' => $country,
'message' => $allowed ? '' : get_option( 'ab_geo_blocked_msg', 'AutoBooking is not available in your current location.' ),
] );
```
- [ ] **3.4 Desplegar**
- [ ] **3.5 Verificar**
```
GET https://autobooking.online/wp-json/autobooking/v1/geo/check?lat=40.7128&lng=-74.0060
```
Esperado: `{"allowed":true,"country":"US","message":""}`
- [ ] **3.6 Commit**
```bash
git add autobooking-geo-restrict.php && git commit -m "feat: IP geolocation backup in geo/check endpoint"
```
---
## Tarea 4: Sección Seguridad en Admin Dashboard CONFIG
**Archivos:** Modificar `autobooking-admin-dashboard.php` (HTML del panel CONFIG)
- [ ] **4.1 Agregar card de Seguridad** en el HTML del panel `panel-settings`, después del card de tarifas (buscar `</div>` que cierra el card de tarifas, ~línea 840):
```php
<div class="abad-card">
<div class="abad-card__title">Seguridad</div>
<div class="abad-form-group">
<label class="abad-label">IPs en whitelist (una por línea)</label>
<textarea class="abad-textarea" id="sec-whitelist" rows="3" placeholder="127.0.0.1"></textarea>
</div>
<div class="abad-form-group">
<label class="abad-label">hCaptcha Site Key</label>
<input type="text" class="abad-input" id="sec-hcaptcha-site">
</div>
<div class="abad-form-group">
<label class="abad-label">hCaptcha Secret Key</label>
<input type="password" class="abad-input" id="sec-hcaptcha-secret">
</div>
<div class="abad-alert abad-alert--success" id="sec-saved" style="display:none">Guardado.</div>
<button class="abad-btn abad-btn--brand" id="btn-save-sec">GUARDAR SEGURIDAD</button>
<div class="abad-card__title" style="margin-top:20px">IPs bloqueadas</div>
<p class="abad-note" id="sec-blocked-count"></p>
<div id="sec-blocked-table"></div>
</div>
```
- [ ] **4.2 Agregar JS** al final de `assets/admin-dashboard.js`:
```javascript
// ── SECURITY ──────────────────────────────────────────────────
async function loadSecuritySettings() {
try {
const r = await apiFetch('/admin/blocked-ips');
document.getElementById('sec-blocked-count').textContent = r.blocked_24h + ' IPs bloqueadas en las últimas 24h';
const el = document.getElementById('sec-blocked-table');
if (!r.blocked.length) { el.innerHTML = '<p style="color:rgba(255,255,255,.4);font-size:13px">Sin IPs bloqueadas.</p>'; return; }
el.innerHTML = '<table class="abad-table"><thead><tr><th>IP</th><th>Motivo</th><th>Expira</th><th></th></tr></thead><tbody>'
+ r.blocked.map(b => `<tr><td>${b.ip}</td><td style="font-size:12px">${b.reason}</td><td style="font-size:12px">${b.expires_at}</td><td><button class="abad-btn abad-btn--outline abad-btn--sm" onclick="absUnblock(${b.id})">Desbloquear</button></td></tr>`).join('')
+ '</tbody></table>';
} catch(e) {}
}
window.absUnblock = async id => { await apiFetch('/admin/blocked-ips/'+id+'/unblock',{method:'POST'}); loadSecuritySettings(); };
document.getElementById('btn-save-sec')?.addEventListener('click', async () => {
await apiFetch('/admin/security-settings/save', {method:'POST', body: JSON.stringify({
whitelist: document.getElementById('sec-whitelist').value,
hcaptcha_site_key: document.getElementById('sec-hcaptcha-site').value,
hcaptcha_secret: document.getElementById('sec-hcaptcha-secret').value,
})});
const el = document.getElementById('sec-saved'); el.style.display='block'; setTimeout(()=>el.style.display='none',3000);
});
document.querySelectorAll('.abad-tab').forEach(btn => {
if (btn.dataset.tab === 'settings') btn.addEventListener('click', loadSecuritySettings);
});
```
- [ ] **4.3 Desplegar** `autobooking-admin-dashboard.php` + `assets/admin-dashboard.js`
- [ ] **4.4 Verificar** — Admin dashboard → tab CONFIG → ver sección Seguridad → ingresar hCaptcha keys → Guardar → confirmar mensaje "Guardado."
- [ ] **4.5 Commit**
```bash
git add autobooking-admin-dashboard.php && git commit -m "feat: security section in admin CONFIG tab"
```
---
## Verificación final
- [ ] Bot no puede crear más de 5 cuentas/hora → al intento 6 aparece error de rate limit
- [ ] Conductor sin email confirmado → al entrar a `/pending-driver/` ve pantalla "Confirma tu email"
- [ ] Clic en enlace del email → redirige a `/pending-driver/?ab_confirm=ok`
- [ ] `GET /wp-json/autobooking/v1/geo/check?lat=40.7&lng=-74.0``{"allowed":true,...}`
- [ ] Ver fuente de página admin → `AIzaSyD3` no aparece hardcoded
- [ ] Login con usuario que tenga rol driver + customer → redirige a `/choose-role/` ✓ (no tocado)
@@ -0,0 +1,229 @@
# AutoBooking Security — Diseño Opción B
**Fecha:** 2026-05-30
**Estado:** Aprobado por usuario
**Alcance:** Anti-spam, anti-ghost registrations, rate limiting, IP geolocation backup, documentos de conductor
---
## 1. Contexto y problema
AutoBooking es una plataforma de transporte en WordPress (autobooking.online). Los problemas identificados:
- **Cuentas fantasma:** cualquier bot puede registrarse como conductor o pasajero sin verificación
- **Spam en chat:** `wp_autobooking_chat` sin rate limiting
- **API Key de Google Maps expuesta** en código fuente PHP y en HTML del frontend
- **GPS spoofeable:** el plugin geo-restrict confía 100% en coordenadas enviadas por el cliente
- **Sin rate limiting** en ningún endpoint REST ni en login/registro
Funcionalidades existentes que se preservan sin cambios:
- Selección de rol al login (Snippets #15 + #18: `after-login``choose-role`)
- Páginas de registro con diseño Forminator
- Plugin `autobooking-roles` v1.1.0 (roles driver, driver_pending, corporate_admin)
- Todos los plugins de dashboard (driver, passenger, corporate, admin)
---
## 2. Arquitectura
### Componentes nuevos
| Componente | Tipo | Propósito |
|---|---|---|
| `autobooking-security.php` | Plugin nuevo | Central de seguridad: rate limiting, nonces, IP geo |
| `wp_ab_rate_limits` | Tabla DB | Registro de hits por IP/endpoint |
| `wp_ab_blocked_ips` | Tabla DB | IPs bloqueadas con TTL |
| `wp_ab_driver_documents` | Tabla DB | Documentos de conductor (licencia, seguro, foto) |
| hCaptcha en registro | Hook Forminator | Verificación anti-bot en registro de conductores |
### Componentes modificados
| Componente | Cambio |
|---|---|
| `autobooking-admin-dashboard.php` | Mover `GMAPS_KEY` a `wp_options`; mostrar documentos pendientes en tab Conductores |
| `autobooking-geo-restrict.php` | Agregar verificación de IP via ip-api.com con caché transient |
---
## 3. Rate Limiting
### Tabla `wp_ab_rate_limits`
```sql
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY
ip VARCHAR(45) NOT NULL
endpoint VARCHAR(80) NOT NULL
hits INT UNSIGNED NOT NULL DEFAULT 1
window_start DATETIME NOT NULL
KEY (ip, endpoint, window_start)
```
### Tabla `wp_ab_blocked_ips`
```sql
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY
ip VARCHAR(45) NOT NULL UNIQUE
reason VARCHAR(120) NOT NULL DEFAULT ''
blocked_at DATETIME NOT NULL
expires_at DATETIME NOT NULL
KEY (ip, expires_at)
```
### Límites configurados
| Endpoint / Acción | Límite | Ventana | Bloqueo automático |
|---|---|---|---|
| Registro de usuario | 5 intentos | 60 min | 24 h |
| `wp_login_failed` | 10 fallos | 15 min | 2 h |
| `POST /autobooking/v1/*` | 120 requests | 1 min | 30 min |
| Chat (write) | 1 mensaje | 2 seg | 10 min |
### Implementación
- Hook `register_post` → verificar IP antes de crear usuario
- Hook `wp_login_failed` → contar fallos de login por IP
- Filter `rest_dispatch_request` → contar requests REST por IP
- Helper `ab_sec_check_rate( $ip, $endpoint, $limit, $window_sec )` → bool
- Helper `ab_sec_block_ip( $ip, $reason, $hours )` → void
- IPs en whitelist (configurables en tab CONFIG del admin dashboard) nunca se bloquean
---
## 4. Anti-Ghost Registration
### Flujo de registro de conductor
```
1. Usuario llena formulario Forminator en /driver-registration/
2. hCaptcha valida (verificación server-side)
3. Rate limit: máx 5 registros / hora / IP
4. WordPress crea usuario con rol driver_pending
5. Email de confirmación enviado → cuenta inactiva hasta confirmar
6. Conductor sube 3 documentos desde /pending-driver/
7. Admin aprueba en dashboard → rol pasa a driver
```
### hCaptcha
- Servicio gratuito (hcaptcha.com) — requiere crear cuenta y obtener site key + secret key
- Hook `registration_errors` para verificar `h-captcha-response` via `POST https://api.hcaptcha.com/siteverify`
- Si falla → error visible en el formulario, registro rechazado
- Site key y secret key guardadas en `wp_options`: `ab_hcaptcha_site_key`, `ab_hcaptcha_secret`
### Email confirmation
- Token de confirmación en `wp_usermeta`: `ab_email_token` (SHA256 random), `ab_email_token_expires` (24h)
- Usuario con rol `driver_pending` sin email confirmado → redirigido a `/pending-driver/` con mensaje "Confirma tu email"
- Endpoint `GET /autobooking/v1/confirm-email?token=xxx` → valida token, marca `ab_email_confirmed = 1`
- Si el token expiró (>24h): mostrar página "Token expirado" con botón para reenviar email de confirmación (endpoint `POST /autobooking/v1/resend-confirmation`)
### Tabla `wp_ab_driver_documents`
```sql
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY
driver_id BIGINT UNSIGNED NOT NULL
doc_type ENUM('license','insurance','vehicle_photo') NOT NULL
file_url VARCHAR(512) NOT NULL DEFAULT ''
expiry_date DATE NULL
status ENUM('pending','approved','rejected') NOT NULL DEFAULT 'pending'
reviewed_by BIGINT UNSIGNED NULL
reviewed_at DATETIME NULL
created_at DATETIME NOT NULL
KEY (driver_id, doc_type)
KEY (status)
```
### Regla de aprobación
- Conductor sube docs desde `/pending-driver/` — tipos permitidos: JPG, PNG, PDF (máx 5 MB por archivo), guardados en `wp-content/uploads/ab-driver-docs/{user_id}/`
- Admin ve docs en tab CONDUCTORES → cola de aprobación
- Botón "Aprobar conductor" solo habilitado si los 3 documentos existen
- Rechazo envía email automático con motivo al conductor
---
## 5. IP Geolocation Backup
### Modificación a `/geo/check`
```
1. Recibir lat/lng del cliente
2. Detectar país por bounding boxes (lógica existente — sin cambios)
3. Si país por GPS está en allowed list:
a. Obtener IP del request (X-Forwarded-For validado, o REMOTE_ADDR)
b. Si IP es privada/local (127.x, 10.x, 192.168.x) → confiar en GPS
c. Consultar caché: transient "ab_geo_ip_{md5(ip)}" TTL=86400s
d. Si no hay caché → llamar ip-api.com/json/{ip}?fields=countryCode
e. Si ip-api.com falla (timeout/error) → fail open, permitir acceso
f. Si país por IP != país por GPS → retornar allowed=false, mensaje "Ubicación inconsistente"
4. Retornar resultado normal
```
- ip-api.com: gratuito, 45 req/min — el caché de 24h por IP lo mantiene muy por debajo del límite
- El bloqueo por discrepancia es duro (no solo advertencia)
---
## 6. Google Maps API Key
### Problema
`const GMAPS_KEY = 'AIzaSyD3...'` hardcodeada en `autobooking-admin-dashboard.php:17` y expuesta en el HTML de la página de admin.
### Solución en código
- Eliminar la constante `GMAPS_KEY`
- Leer siempre de `get_option('ab_gmaps_key', '')` — este campo ya existe en el tab CONFIG del admin dashboard
- El campo ya estaba en la UI; solo hay que eliminar el fallback a la constante
### Acción manual requerida (fuera del código)
- Ir a Google Cloud Console → Credenciales → Restricciones de la key
- Agregar restricción HTTP: solo `*.autobooking.online/*`
---
## 7. Modificaciones al Admin Dashboard
### Tab CONDUCTORES
- Columna "Docs" en cola de aprobación: muestra `X/3` documentos subidos
- Botón "Aprobar" deshabilitado si hay documentos faltantes
- Modal muestra miniaturas de los 3 documentos con links para ver tamaño completo
### Tab CONFIG — nueva sección "Seguridad"
- Campo: IPs en whitelist (textarea, una por línea)
- Tabla: IPs bloqueadas actualmente con columnas IP, Motivo, Expira, botón Desbloquear
- Contador: intentos bloqueados en las últimas 24 h
---
## 8. Preservación de funcionalidad existente
| Feature | Estado | Acción |
|---|---|---|
| Snippets #15 + #18 (choose-role) | Sin cambios | No tocar |
| Snippet #14 (login gating) | Sin cambios | No tocar |
| Snippet #19 (registro override) | Sin cambios | Solo agregar hCaptcha al hook |
| Páginas Forminator | Sin cambios | Solo agregar campo h-captcha-response |
| autobooking-roles plugin | Sin cambios | No tocar |
| Driver / Passenger / Corporate dashboards | Sin cambios | No tocar |
---
## 9. Archivos a crear/modificar
```
CREAR:
wp-content/plugins/autobooking-security/autobooking-security.php
MODIFICAR:
wp-content/plugins/autobooking-admin-dashboard/autobooking-admin-dashboard.php
wp-content/plugins/autobooking-geo-restrict/autobooking-geo-restrict.php
ACCIONES MANUALES:
1. Google Cloud Console → restringir GMAPS_KEY a *.autobooking.online/*
2. hcaptcha.com → crear cuenta gratuita, obtener site_key y secret_key
3. Activar plugin autobooking-security desde WP Admin → Plugins
```
---
## 10. Criterios de éxito
1. Un bot no puede crear más de 5 cuentas por hora desde la misma IP
2. Un conductor nuevo no puede acceder al dashboard sin confirmar su email
3. Un conductor no puede ser aprobado sin los 3 documentos subidos
4. GPS de USA + IP de Rusia → bloqueado con mensaje claro
5. La API Key de Google Maps no aparece en el HTML de ninguna página pública
6. El flujo de selección de rol al login (`choose-role`) sigue funcionando sin cambios
7. Las páginas de registro mantienen su diseño visual actual
+1178
View File
File diff suppressed because it is too large Load Diff
+1404
View File
File diff suppressed because it is too large Load Diff