feat: AutoBooking initial commit — PHP WordPress Plugin (REST API, wpdb, WP_User_Query)
This commit is contained in:
+47
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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 ? '★ ' + 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 ? '★ ' + 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 ? '★ ' + 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);
|
||||
});
|
||||
|
||||
})();
|
||||
@@ -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();
|
||||
@@ -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
@@ -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 );
|
||||
@@ -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);
|
||||
@@ -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' );
|
||||
@@ -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])]);
|
||||
},
|
||||
]);
|
||||
|
||||
} );
|
||||
@@ -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>'; ?>
|
||||
| Último tick: <code><?php echo $s['last_tick'] ? date('H:i:s', $s['last_tick']) : '—'; ?></code>
|
||||
| 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);
|
||||
});
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
},
|
||||
};
|
||||
|
||||
/* 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
|
||||
File diff suppressed because it is too large
Load Diff
+1404
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user