1) "Subiendo comprobante..." no se quitaba nunca.
La miniatura solo se limpiaba con livewire-upload-finish, que cubre la
subida del archivo pero no el procesado posterior. Y updatedFotoComprobante
tenía tres rutas de salida tempranas (sin convId, análisis deshabilitado,
validación fallida) que ni siquiera llegaban al finally.
Ahora todo el cuerpo va dentro de un try/finally que emite siempre
'comprobante-listo', y el cliente limpia con ese evento del servidor en vez
de depender de los eventos internos de Livewire. Se añade además un timeout
de 45s como red de seguridad.
2) "cuenta de hbo" abría el listado completo en vez de HBO.
El regex de servicios.listar tenía las marcas quemadas (netflix|hbo|disney…),
así que capturaba la frase y devolvía data vacía antes de que la IA pudiera
extraer el nombre. Telegram no falla porque sus keywords son solo intents
genéricos y deja los específicos al agente.
- Se sacan las marcas del regex genérico.
- Nuevo detectarServicioPorNombre(): resuelve contra los nombres reales de
la tabla servicios (nada quemado en código, sin llamada a la IA) y abre
el servicio pedido.
- clickBoton('servicios.listar') ahora honra data.servicio, como hace
dispatchAction en Telegram.
- El prompt de GeminiIntentService pide data.servicio. Este servicio lo usa
solo el chat web; Telegram usa GeminiAgentService y no se toca.
El patrón de credenciales se refuerza y pasa a evaluarse primero, para que
"no puedo entrar a netflix" siga siendo soporte y no una intención de compra.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1793 lines
75 KiB
PHP
Executable File
1793 lines
75 KiB
PHP
Executable File
<?php
|
|
|
|
namespace App\Http\Livewire\Chat;
|
|
|
|
use App\Mail\ChatOtpMail;
|
|
use App\Models\Role;
|
|
use App\Models\Cuentas;
|
|
use App\Models\ChatContact;
|
|
use Illuminate\Support\Facades\DB;
|
|
use App\Models\ChatConversation;
|
|
use App\Models\ChatMessage;
|
|
use App\Models\Historiale;
|
|
use App\Models\Historial_cuenta;
|
|
use App\Models\Preferencial;
|
|
use App\Models\Promociones;
|
|
use App\Services\CuentasHelper;
|
|
use App\Models\TarifaPromo;
|
|
use App\Models\Usuario_promo;
|
|
use App\Models\recarga;
|
|
use App\Models\Saldo;
|
|
use App\Models\Servicio;
|
|
use App\Models\Tarifas;
|
|
use App\Models\User;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Str;
|
|
use Livewire\Component;
|
|
use Livewire\WithFileUploads;
|
|
|
|
class PublicChat extends Component
|
|
{
|
|
use WithFileUploads;
|
|
|
|
// ── Paso actual ───────────────────────────────────────────
|
|
public string $paso = 'email'; // email | verificacion | registro | chat
|
|
public string $email = '';
|
|
public string $nombreRegistro = '';
|
|
public string $cedulaRegistro = '';
|
|
public string $celularRegistro = '';
|
|
public string $codigoIngresado = '';
|
|
public ?string $emailMascarado = null;
|
|
public ?string $errorEnvioOtp = null;
|
|
public ?int $contactIdPending = null; // contact.id mientras espera verificacion
|
|
public string $input = '';
|
|
public array $mensajes = [];
|
|
public ?int $convId = null;
|
|
public string $estadoConv = 'bot';
|
|
|
|
// ── Usuario identificado ──────────────────────────────────
|
|
public ?int $userId = null;
|
|
public ?string $nombreUsuario = null;
|
|
public ?float $saldoUsuario = null;
|
|
|
|
// ── Estado del wizard ─────────────────────────────────────
|
|
public string $flujo = '';
|
|
public array $flujoData = [];
|
|
|
|
// ── Upload comprobante ────────────────────────────────────
|
|
public $fotoComprobante = null;
|
|
|
|
// Último id de mensaje ya cargado — evita re-render en cada poll
|
|
public int $ultimoMsgId = 0;
|
|
|
|
/** Mensajes recientes que se mantienen en memoria/DOM. */
|
|
private const MAX_MENSAJES = 60;
|
|
|
|
public function mount(): void
|
|
{
|
|
$userId = session('chat_user_id');
|
|
|
|
if (! $userId) {
|
|
return;
|
|
}
|
|
|
|
$user = User::with('saldo')->find($userId);
|
|
if (! $user) {
|
|
session()->forget(['chat_user_id', 'chat_conv_id']);
|
|
return;
|
|
}
|
|
|
|
$this->userId = $user->id;
|
|
$this->nombreUsuario = $user->name;
|
|
$this->saldoUsuario = $user->saldo?->valor ?? 0;
|
|
$this->paso = 'chat';
|
|
|
|
$convId = session('chat_conv_id');
|
|
$conv = $convId ? ChatConversation::find($convId) : null;
|
|
|
|
if (! $conv) {
|
|
// Sesión perdida o expirada: buscar conversación activa existente o crear una nueva
|
|
session()->forget('chat_conv_id');
|
|
$contact = ChatContact::where('canal', 'web')->where('user_id', $user->id)->first();
|
|
if ($contact) {
|
|
$conv = $contact->activeConversation()
|
|
?? ChatConversation::create([
|
|
'contact_id' => $contact->id,
|
|
'canal' => 'web',
|
|
'estado' => 'bot',
|
|
'ultimo_mensaje_at' => now(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
if ($conv) {
|
|
$this->convId = $conv->id;
|
|
$this->estadoConv = $conv->estado;
|
|
session()->put('chat_conv_id', $conv->id);
|
|
$this->cargarMensajes();
|
|
}
|
|
}
|
|
|
|
protected $rules = [
|
|
'email' => 'required|email|max:100',
|
|
'nombreRegistro' => 'required|max:80',
|
|
'cedulaRegistro' => 'nullable|max:20',
|
|
'celularRegistro' => 'nullable|max:20',
|
|
'codigoIngresado' => 'nullable|digits:6',
|
|
];
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Paso 1: buscar por correo
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
public function iniciar(): void
|
|
{
|
|
$this->validate(['email' => 'required|email|max:100']);
|
|
|
|
$user = User::where('email', $this->email)->first();
|
|
|
|
if (! $user) {
|
|
$this->paso = 'registro';
|
|
return;
|
|
}
|
|
|
|
$contact = ChatContact::firstOrCreate(
|
|
['canal' => 'web', 'canal_id' => $user->email],
|
|
['nombre' => $user->name, 'telefono' => $user->email, 'user_id' => $user->id]
|
|
);
|
|
|
|
if (! $contact->user_id) {
|
|
$contact->update(['user_id' => $user->id]);
|
|
}
|
|
|
|
$this->contactIdPending = $contact->id;
|
|
$this->emailMascarado = $this->enmascararEmail($user->email);
|
|
$this->enviarCodigoOtp($contact->id, $user);
|
|
$this->paso = 'verificacion';
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Paso 2a: crear cuenta nueva
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
public function crearCuenta(): void
|
|
{
|
|
$this->validate([
|
|
'email' => 'required|email|max:100',
|
|
'nombreRegistro' => 'required|max:80',
|
|
'cedulaRegistro' => 'nullable|max:20',
|
|
'celularRegistro' => 'nullable|max:20',
|
|
]);
|
|
|
|
$rolCliente = Role::where('nombre', 'cliente')->first();
|
|
|
|
$user = User::create([
|
|
'name' => $this->nombreRegistro,
|
|
'email' => $this->email,
|
|
'password' => Hash::make(Str::random(16)),
|
|
'cedula' => $this->cedulaRegistro ?: null,
|
|
'celular' => $this->celularRegistro ?: null,
|
|
'estado' => 'activo',
|
|
'rol_id' => $rolCliente?->id,
|
|
]);
|
|
|
|
$contact = ChatContact::create([
|
|
'canal' => 'web',
|
|
'canal_id' => $user->email,
|
|
'nombre' => $user->name,
|
|
'telefono' => $user->email,
|
|
'user_id' => $user->id,
|
|
]);
|
|
|
|
$this->contactIdPending = $contact->id;
|
|
$this->emailMascarado = $this->enmascararEmail($user->email);
|
|
$this->enviarCodigoOtp($contact->id, $user);
|
|
$this->paso = 'verificacion';
|
|
}
|
|
|
|
public function rechazarRegistro(): void
|
|
{
|
|
$this->email = '';
|
|
$this->nombreRegistro = '';
|
|
$this->cedulaRegistro = '';
|
|
$this->celularRegistro = '';
|
|
$this->paso = 'email';
|
|
}
|
|
|
|
public function confirmarCierreSesion(): void
|
|
{
|
|
if (! $this->convId) {
|
|
return;
|
|
}
|
|
|
|
ChatMessage::create([
|
|
'conversation_id' => $this->convId,
|
|
'tipo' => 'bot',
|
|
'tipo_ui' => 'buttons',
|
|
'contenido' => '¿Seguro que deseas cerrar sesión? Tendrás que volver a ingresar tu correo y código la próxima vez.',
|
|
'payload' => ['botones' => [
|
|
['label' => '✅ Sí, cerrar sesión', 'action' => 'sesion.cerrar.confirmar', 'data' => []],
|
|
['label' => '❌ No, quedarme', 'action' => 'menu.principal', 'data' => []],
|
|
]],
|
|
'leido' => true,
|
|
]);
|
|
}
|
|
|
|
public function limpiarChat(): void
|
|
{
|
|
$this->flujo = '';
|
|
$this->flujoData = [];
|
|
$this->mensajes = [];
|
|
|
|
// Crear nueva conversación en lugar de dejar convId en null
|
|
if ($this->userId) {
|
|
$contact = ChatContact::where('canal', 'web')->where('user_id', $this->userId)->first();
|
|
if ($contact) {
|
|
$conv = ChatConversation::create([
|
|
'contact_id' => $contact->id,
|
|
'canal' => 'web',
|
|
'estado' => 'bot',
|
|
'ultimo_mensaje_at' => now(),
|
|
]);
|
|
$this->convId = $conv->id;
|
|
$this->estadoConv = 'bot';
|
|
session()->put('chat_conv_id', $conv->id);
|
|
$this->mostrarMenuPrincipal($conv->id);
|
|
$this->cargarMensajes();
|
|
}
|
|
}
|
|
}
|
|
|
|
public function cerrarSesion(): void
|
|
{
|
|
session()->forget(['chat_user_id', 'chat_conv_id']);
|
|
|
|
$this->email = '';
|
|
$this->nombreRegistro = '';
|
|
$this->cedulaRegistro = '';
|
|
$this->celularRegistro = '';
|
|
$this->codigoIngresado = '';
|
|
$this->emailMascarado = null;
|
|
$this->contactIdPending = null;
|
|
$this->userId = null;
|
|
$this->nombreUsuario = null;
|
|
$this->saldoUsuario = null;
|
|
$this->convId = null;
|
|
$this->estadoConv = 'bot';
|
|
$this->flujo = '';
|
|
$this->flujoData = [];
|
|
$this->mensajes = [];
|
|
$this->input = '';
|
|
$this->paso = 'email';
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Paso 2b: verificar código OTP
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
private function enviarCodigoOtp(int $contactId, User $user): bool
|
|
{
|
|
$codigo = (string) random_int(100000, 999999);
|
|
Cache::put("chat_otp_{$contactId}", $codigo, now()->addMinutes(10));
|
|
|
|
try {
|
|
Mail::to($user->email)->send(new ChatOtpMail($codigo, $user->name));
|
|
$this->errorEnvioOtp = null;
|
|
return true;
|
|
} catch (\Throwable $e) {
|
|
\Illuminate\Support\Facades\Log::error('[ChatOTP] Error enviando correo a ' . $user->email . ': ' . $e->getMessage(), [
|
|
'exception' => $e,
|
|
'mailer' => config('mail.default'),
|
|
'host' => config('mail.mailers.smtp.host'),
|
|
]);
|
|
$this->errorEnvioOtp = 'No pudimos enviar el correo. Verifica tu email o intenta reenviar.';
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public function verificarCodigo(): void
|
|
{
|
|
$this->validate(['codigoIngresado' => 'required|digits:6']);
|
|
|
|
if (! $this->contactIdPending) {
|
|
$this->addError('codigoIngresado', 'Sesion expirada. Recarga la pagina.');
|
|
return;
|
|
}
|
|
|
|
$codigoGuardado = Cache::get("chat_otp_{$this->contactIdPending}");
|
|
|
|
if (! $codigoGuardado) {
|
|
$this->addError('codigoIngresado', 'El codigo expiro. Solicita uno nuevo.');
|
|
return;
|
|
}
|
|
|
|
if ($this->codigoIngresado !== $codigoGuardado) {
|
|
$this->addError('codigoIngresado', 'Codigo incorrecto. Intentalo de nuevo.');
|
|
return;
|
|
}
|
|
|
|
Cache::forget("chat_otp_{$this->contactIdPending}");
|
|
|
|
$contact = ChatContact::find($this->contactIdPending);
|
|
if (! $contact) {
|
|
$this->addError('codigoIngresado', 'Sesion invalida. Recarga la pagina.');
|
|
return;
|
|
}
|
|
|
|
$this->contactIdPending = null;
|
|
$this->codigoIngresado = '';
|
|
$this->abrirChat($contact);
|
|
}
|
|
|
|
public function reenviarCodigo(): void
|
|
{
|
|
if (! $this->contactIdPending) {
|
|
return;
|
|
}
|
|
|
|
$contact = ChatContact::find($this->contactIdPending);
|
|
if (! $contact || ! $contact->user_id) {
|
|
return;
|
|
}
|
|
|
|
$user = User::find($contact->user_id);
|
|
if ($user) {
|
|
$ok = $this->enviarCodigoOtp($this->contactIdPending, $user);
|
|
if ($ok) {
|
|
session()->flash('otp_reenviado', 'Código reenviado a tu correo.');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Abrir chat (después de identificación)
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
private function abrirChat(ChatContact $contact): void
|
|
{
|
|
if ($contact->user_id) {
|
|
$user = User::with('saldo')->find($contact->user_id);
|
|
$this->userId = $user->id;
|
|
$this->nombreUsuario = $user->name;
|
|
$this->saldoUsuario = $user->saldo?->valor ?? 0;
|
|
}
|
|
|
|
$conv = $contact->activeConversation();
|
|
$esNueva = ! $conv;
|
|
|
|
if ($esNueva) {
|
|
$conv = ChatConversation::create([
|
|
'contact_id' => $contact->id,
|
|
'canal' => 'web',
|
|
'estado' => 'bot',
|
|
'ultimo_mensaje_at' => now(),
|
|
]);
|
|
$saludo = $this->nombreUsuario
|
|
? "¡Hola, {$this->nombreUsuario}! Bienvenido."
|
|
: "¡Hola! Bienvenido.";
|
|
$this->guardarMensajeBot($conv->id, $saludo);
|
|
} else {
|
|
// Conversación existente: el usuario acaba de verificarse por OTP
|
|
$saludo = $this->nombreUsuario
|
|
? "Bienvenido de nuevo, {$this->nombreUsuario}!"
|
|
: "Bienvenido de nuevo!";
|
|
$this->guardarMensajeBot($conv->id, $saludo);
|
|
}
|
|
|
|
$this->convId = $conv->id;
|
|
$this->estadoConv = $conv->estado;
|
|
$this->paso = 'chat';
|
|
|
|
session()->put('chat_user_id', $this->userId);
|
|
session()->put('chat_conv_id', $conv->id);
|
|
|
|
// Siempre muestra el menú al autenticarse (mount no llama a este método)
|
|
$this->mostrarMenuPrincipal($conv->id);
|
|
$this->cargarMensajes();
|
|
}
|
|
|
|
private function enmascararEmail(string $email): string
|
|
{
|
|
[$local, $dominio] = explode('@', $email, 2);
|
|
$n = min(4, mb_strlen($local));
|
|
$visible = mb_substr($local, 0, $n);
|
|
return $visible . str_repeat('*', max(0, mb_strlen($local) - $n)) . '@' . $dominio;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Envío de texto libre
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* El texto llega como argumento desde Alpine para poder pintar la burbuja
|
|
* del usuario al instante en el cliente (sin esperar el round-trip).
|
|
* Se mantiene el fallback a $this->input por compatibilidad.
|
|
*/
|
|
public function enviar(?string $texto = null): void
|
|
{
|
|
$texto = trim($texto ?? $this->input);
|
|
|
|
if (! $this->convId || $texto === '') {
|
|
return;
|
|
}
|
|
|
|
$this->input = '';
|
|
|
|
$this->guardarMensajeUsuario($this->convId, $texto);
|
|
|
|
$conv = ChatConversation::find($this->convId);
|
|
if (! $conv || $conv->estado === 'agente') {
|
|
$this->cargarMensajes();
|
|
return;
|
|
}
|
|
|
|
// Capturar nombre del titular bancario para validación de recarga
|
|
if ($this->flujo === 'pago.esperando_nombre') {
|
|
$nombre = trim($texto);
|
|
$palabras = array_filter(explode(' ', $nombre), fn($p) => strlen($p) >= 2);
|
|
if (count($palabras) < 2) {
|
|
$this->guardarMensajeBot($this->convId, "Escribe al menos nombre y apellido. Ejemplo: *Juan Garcia*");
|
|
$this->cargarMensajes();
|
|
$this->dispatchBrowserEvent('scroll-chat');
|
|
return;
|
|
}
|
|
$monto = (int) ($this->flujoData['monto'] ?? 0);
|
|
$this->flujoData['nombre_remitente'] = $nombre;
|
|
$this->flujo = '';
|
|
$this->mostrarTransferenciaDatos($monto);
|
|
$this->cargarMensajes();
|
|
$this->dispatchBrowserEvent('scroll-chat');
|
|
return;
|
|
}
|
|
|
|
// Capturar monto personalizado de recarga
|
|
if ($this->flujo === 'recarga.monto_custom') {
|
|
$monto = (int) preg_replace('/\D/', '', $texto);
|
|
if ($monto >= 1000) {
|
|
$this->flujo = '';
|
|
$this->flujoData = [];
|
|
$this->elegirMetodoRecarga($monto);
|
|
} else {
|
|
$this->guardarMensajeBot($this->convId, "El monto debe ser al menos $1.000. Escríbelo de nuevo:");
|
|
}
|
|
$this->cargarMensajes();
|
|
$this->dispatchBrowserEvent('scroll-chat');
|
|
return;
|
|
}
|
|
|
|
$accion = $this->detectarIntencion($texto);
|
|
|
|
if ($accion) {
|
|
$this->clickBoton($accion['action'], $accion['data'] ?? []);
|
|
} else {
|
|
ChatMessage::create([
|
|
'conversation_id' => $this->convId,
|
|
'tipo' => 'bot',
|
|
'tipo_ui' => 'buttons',
|
|
'contenido' => "No entendí bien tu mensaje. Puedes escribir cosas como:\n\"ver planes\", \"hacer recarga\", \"mis credenciales\", \"ver promociones\" o usar los botones:",
|
|
'payload' => ['botones' => [
|
|
['label' => 'Ver planes', 'action' => 'servicios.listar', 'data' => []],
|
|
['label' => 'Recargar saldo', 'action' => 'recarga.iniciar', 'data' => []],
|
|
['label' => 'Mis credenciales','action' => 'credenciales.listar','data' => []],
|
|
['label' => 'Menú principal', 'action' => 'menu.principal', 'data' => []],
|
|
]],
|
|
'leido' => true,
|
|
]);
|
|
}
|
|
|
|
$this->cargarMensajes();
|
|
$this->dispatchBrowserEvent('scroll-chat');
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Clic en botón
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
public function clickBoton(string $action, array $data = []): void
|
|
{
|
|
if (! $this->convId) {
|
|
return;
|
|
}
|
|
|
|
match (true) {
|
|
$action === 'menu.principal' => $this->mostrarMenuPrincipal($this->convId),
|
|
$action === 'servicios.listar' => ! empty($data['servicio'])
|
|
? $this->verServicioPorNombre((string) $data['servicio'])
|
|
: $this->listarServicios(),
|
|
$action === 'servicios.ver' => $this->verServicio((int) ($data['id'] ?? 0)),
|
|
$action === 'compra.iniciar' => $this->iniciarCompra((int) ($data['tarifa_id'] ?? 0)),
|
|
$action === 'compra.pagar.saldo' => $this->pagarConSaldo(),
|
|
$action === 'promo.listar' => $this->listarPromociones(),
|
|
$action === 'promo.ver' => $this->verPromocion((int) ($data['id'] ?? 0)),
|
|
$action === 'promo.comprar.saldo' => $this->comprarPromoConSaldo((int) ($data['id'] ?? 0)),
|
|
$action === 'recarga.iniciar' => isset($data['monto']) && (int) $data['monto'] >= 1000
|
|
? $this->elegirMetodoRecarga((int) $data['monto'])
|
|
: $this->mostrarFormRecarga(),
|
|
$action === 'recarga.otro_valor' => $this->solicitarMontoPersonalizado(),
|
|
$action === 'recarga.elegir_metodo' => $this->elegirMetodoRecarga((int) ($data['monto'] ?? 0)),
|
|
$action === 'recarga.transferencia' => $this->mostrarTransferencia((int) ($data['monto'] ?? 0)),
|
|
$action === 'credenciales.listar' => $this->listarCredenciales(),
|
|
$action === 'historial.ver' => $this->verHistorial(),
|
|
$action === 'perfil.ver' => $this->verPerfil(),
|
|
$action === 'asesor.solicitar' => $this->solicitarAsesor(),
|
|
$action === 'pago.directo.breb' => $this->pagarDirectoBreb($data),
|
|
$action === 'pago.aplicar' => $this->aplicarRecargaValidada($data),
|
|
$action === 'pago.reintentar' => $this->reintentarValidacionPago(),
|
|
$action === 'pago.nombre_previo' => $this->usarNombrePrevio($data['nombre'] ?? ''),
|
|
$action === 'pago.nombre_nuevo' => $this->pedirNombreNuevo(),
|
|
$action === 'sesion.cerrar' => $this->confirmarCierreSesion(),
|
|
$action === 'sesion.cerrar.confirmar' => $this->cerrarSesion(),
|
|
default => $this->mostrarMenuPrincipal($this->convId),
|
|
};
|
|
|
|
$this->cargarMensajes();
|
|
$this->dispatchBrowserEvent('scroll-chat');
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Upload comprobante de pago
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* El finally exterior es importante: pase lo que pase hay que emitir
|
|
* 'comprobante-listo' para que el cliente quite la miniatura de "Subiendo
|
|
* comprobante…". Depender solo de livewire-upload-finish dejaba el
|
|
* indicador colgado en cuanto el método salía por una de las rutas cortas.
|
|
*/
|
|
public function updatedFotoComprobante(): void
|
|
{
|
|
if (! $this->fotoComprobante || ! $this->convId) {
|
|
$this->dispatchBrowserEvent('comprobante-listo');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (\App\Models\ChatConfig::get('validacion_foto_habilitada', '0') !== '1') {
|
|
$this->guardarMensajeBot($this->convId, "El analisis de comprobantes no esta habilitado. Contacta a un asesor.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$this->validate([
|
|
'fotoComprobante' => 'image|mimes:jpg,jpeg,png,webp,heic|max:8192',
|
|
]);
|
|
} catch (\Illuminate\Validation\ValidationException $e) {
|
|
$this->guardarMensajeBot($this->convId, "Ese archivo no sirve como comprobante. Envía una imagen (JPG o PNG) de máximo 8 MB.");
|
|
return;
|
|
}
|
|
|
|
// Verificar solicitud activa ANTES de guardar nada
|
|
$solicitud = $this->userId ? \App\Models\SolicitudRecarga::pendiente($this->userId) : null;
|
|
if (! $solicitud) {
|
|
$this->guardarMensajeBot($this->convId, "⚠️ No tienes ninguna solicitud de recarga activa. Primero inicia el proceso de recarga desde el menú.");
|
|
return;
|
|
}
|
|
|
|
// Este método debe terminar rápido: la lectura con IA y la búsqueda
|
|
// en el correo tardan más de lo que aguanta el gateway, así que las
|
|
// hace ValidarPagoWebJob. Aquí solo se guarda la imagen.
|
|
//
|
|
// Disco 'local' + carpeta photos: es el único symlink publicado
|
|
// (config/filesystems.php links). El disco 'public' no tiene symlink
|
|
// en este proyecto, por eso las imágenes daban 404.
|
|
$path = $this->fotoComprobante->store('photos');
|
|
|
|
ChatMessage::create([
|
|
'conversation_id' => $this->convId,
|
|
'tipo' => 'usuario',
|
|
'tipo_ui' => 'imagen',
|
|
'contenido' => '[Comprobante de pago]',
|
|
'payload' => ['url' => asset($path)],
|
|
'leido' => false,
|
|
]);
|
|
|
|
$this->guardarMensajeBot($this->convId, "🔍 Recibido. Estoy leyendo los datos de tu comprobante...");
|
|
|
|
$this->lanzarValidacion($solicitud, $path);
|
|
|
|
} catch (\Throwable $e) {
|
|
Log::error('[PublicChat] Error recibiendo comprobante: ' . $e->getMessage());
|
|
$this->flujo = '';
|
|
$this->flujoData = [];
|
|
$this->guardarMensajeBot($this->convId, "No pude recibir la imagen. Por favor intenta de nuevo.");
|
|
} finally {
|
|
$this->fotoComprobante = null;
|
|
$this->cargarMensajes();
|
|
$this->dispatchBrowserEvent('comprobante-listo');
|
|
$this->dispatchBrowserEvent('scroll-chat');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deja el flujo en 'pago.procesando' y manda el trabajo pesado a la cola.
|
|
* Mientras dure, la vista hace poll a revisarPago() —consulta barata— y el
|
|
* job va escribiendo su progreso como mensajes normales del chat.
|
|
*/
|
|
private function lanzarValidacion(\App\Models\SolicitudRecarga $solicitud, string $path, ?array $datosPago = null): void
|
|
{
|
|
$this->flujo = 'pago.procesando';
|
|
$this->flujoData = array_merge($this->flujoData, [
|
|
'solicitud_id' => $solicitud->id,
|
|
'comprobante_path' => $path,
|
|
'datosPago' => $datosPago,
|
|
'inicio' => now()->timestamp,
|
|
]);
|
|
|
|
Cache::forget(\App\Jobs\ValidarPagoWebJob::claveResultado($this->convId, $solicitud->id));
|
|
|
|
\App\Jobs\ValidarPagoWebJob::dispatch(
|
|
$this->convId,
|
|
$this->userId,
|
|
$solicitud->id,
|
|
$path,
|
|
$this->flujoData['nombre_remitente'] ?? $this->nombreUsuario,
|
|
$datosPago,
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Menu principal
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
private function mostrarMenuPrincipal(int $convId): void
|
|
{
|
|
$this->flujo = '';
|
|
$this->flujoData = [];
|
|
|
|
ChatMessage::create([
|
|
'conversation_id' => $convId,
|
|
'tipo' => 'bot',
|
|
'tipo_ui' => 'buttons',
|
|
'contenido' => 'En que te puedo ayudar?',
|
|
'payload' => ['botones' => [
|
|
['label' => 'Ver Servicios', 'action' => 'servicios.listar', 'data' => []],
|
|
['label' => 'Promociones', 'action' => 'promo.listar', 'data' => []],
|
|
['label' => 'Recargar Saldo', 'action' => 'recarga.iniciar', 'data' => []],
|
|
['label' => 'Mis Credenciales', 'action' => 'credenciales.listar', 'data' => []],
|
|
['label' => 'Historial', 'action' => 'historial.ver', 'data' => []],
|
|
['label' => 'Mi Perfil', 'action' => 'perfil.ver', 'data' => []],
|
|
['label' => 'Hablar con asesor', 'action' => 'asesor.solicitar', 'data' => []],
|
|
['label' => '🚪 Cerrar sesión', 'action' => 'sesion.cerrar', 'data' => []],
|
|
]],
|
|
'leido' => true,
|
|
]);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Servicios
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
private function listarServicios(): void
|
|
{
|
|
$rolId = $this->userId ? User::find($this->userId)?->rol_id : Role::where('nombre', 'cliente')->value('id');
|
|
$servicios = Servicio::where('estado', 'activo')
|
|
->whereHas('tarifas', fn ($q) => $q->where('estado', 'activo')->where('rol_id', $rolId))
|
|
->orderBy('ubicacion')
|
|
->get();
|
|
|
|
if ($servicios->isEmpty()) {
|
|
$this->guardarMensajeBot($this->convId, "No hay servicios disponibles en este momento.");
|
|
$this->volver('menu.principal');
|
|
return;
|
|
}
|
|
|
|
$cards = $servicios->map(fn ($s) => [
|
|
'imagen' => $s->img_inicio,
|
|
'titulo' => $s->nombre,
|
|
'detalle' => $s->por_tiempo ? 'Por tiempo' : 'Por pantallas',
|
|
'accion' => ['label' => 'Ver planes', 'action' => 'servicios.ver', 'data' => ['id' => $s->id]],
|
|
])->values()->all();
|
|
|
|
ChatMessage::create([
|
|
'conversation_id' => $this->convId,
|
|
'tipo' => 'bot',
|
|
'tipo_ui' => 'cards_slider',
|
|
'contenido' => 'Selecciona el servicio:',
|
|
'payload' => ['cards' => $cards],
|
|
'leido' => true,
|
|
]);
|
|
|
|
$this->volver('menu.principal');
|
|
}
|
|
|
|
private function verServicio(int $id): void
|
|
{
|
|
$rolId = $this->userId ? User::find($this->userId)?->rol_id : Role::where('nombre', 'cliente')->value('id');
|
|
$servicio = Servicio::with(['tarifas' => fn ($q) => $q->where('estado', 'activo')->where('rol_id', $rolId)])->find($id);
|
|
|
|
if (! $servicio) {
|
|
$this->guardarMensajeBot($this->convId, "Servicio no encontrado.");
|
|
return;
|
|
}
|
|
|
|
$tarifas = $servicio->tarifas;
|
|
|
|
if ($tarifas->isEmpty()) {
|
|
$this->guardarMensajeBot($this->convId, "No hay planes para {$servicio->nombre} en este momento.");
|
|
$this->volver('servicios.listar');
|
|
return;
|
|
}
|
|
|
|
$botones = [];
|
|
foreach ($tarifas as $t) {
|
|
$precio = $this->efectivePrice($t->id, $this->userId, $t->valor);
|
|
$label = $servicio->por_tiempo
|
|
? "{$t->dias} dias - $" . number_format($precio)
|
|
: "{$t->pantallas} pantalla(s) / {$t->dias} dias - $" . number_format($precio);
|
|
$botones[] = ['label' => $label, 'action' => 'compra.iniciar', 'data' => ['tarifa_id' => $t->id]];
|
|
}
|
|
$botones[] = ['label' => 'Volver', 'action' => 'servicios.listar', 'data' => []];
|
|
|
|
ChatMessage::create([
|
|
'conversation_id' => $this->convId,
|
|
'tipo' => 'bot',
|
|
'tipo_ui' => 'buttons',
|
|
'contenido' => "Planes de {$servicio->nombre}:",
|
|
'payload' => ['botones' => $botones],
|
|
'leido' => true,
|
|
]);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Compra de plan
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
private function iniciarCompra(int $tarifaId): void
|
|
{
|
|
$tarifa = Tarifas::with('servicio')->find($tarifaId);
|
|
if (! $tarifa) {
|
|
$this->guardarMensajeBot($this->convId, "Plan no encontrado.");
|
|
return;
|
|
}
|
|
|
|
if ($this->contarDisponibles($tarifa) < 1) {
|
|
$this->guardarMensajeBot($this->convId, "Sin cuentas disponibles para este plan. Intenta con otro.");
|
|
$this->volver('servicios.listar');
|
|
return;
|
|
}
|
|
|
|
$precio = $this->efectivePrice($tarifaId, $this->userId, $tarifa->valor);
|
|
|
|
$this->flujo = 'compra';
|
|
$this->flujoData = [
|
|
'tarifa_id' => $tarifaId,
|
|
'valor' => $precio,
|
|
'servicio' => $tarifa->servicio->nombre,
|
|
'dias' => $tarifa->dias,
|
|
'pantallas' => $tarifa->pantallas,
|
|
];
|
|
|
|
$detalle = $tarifa->servicio->por_tiempo
|
|
? "{$tarifa->dias} dias"
|
|
: "{$tarifa->pantallas} pantalla(s) / {$tarifa->dias} dias";
|
|
|
|
if ((float) $this->saldoUsuario >= $precio) {
|
|
ChatMessage::create([
|
|
'conversation_id' => $this->convId,
|
|
'tipo' => 'bot',
|
|
'tipo_ui' => 'buttons',
|
|
'contenido' => "{$tarifa->servicio->nombre} - {$detalle}\nPrecio: $" . number_format($precio) . "\n\nElige como pagar:",
|
|
'payload' => ['botones' => [
|
|
['label' => 'Pagar con saldo ($' . number_format($precio) . ')', 'action' => 'compra.pagar.saldo', 'data' => []],
|
|
['label' => 'Volver', 'action' => 'servicios.listar', 'data' => []],
|
|
]],
|
|
'leido' => true,
|
|
]);
|
|
} else {
|
|
$falta = $precio - (float) $this->saldoUsuario;
|
|
|
|
$this->flujoData = array_merge($this->flujoData, [
|
|
'pending_purchase' => [
|
|
'type' => 'tarifa',
|
|
'tarifa_id' => $tarifaId,
|
|
'valor' => $precio,
|
|
'servicio' => $tarifa->servicio->nombre,
|
|
'dias' => $tarifa->dias,
|
|
'pantallas' => $tarifa->pantallas,
|
|
],
|
|
]);
|
|
|
|
ChatMessage::create([
|
|
'conversation_id' => $this->convId,
|
|
'tipo' => 'bot',
|
|
'tipo_ui' => 'buttons',
|
|
'contenido' => "{$tarifa->servicio->nombre} - {$detalle}\nPrecio: $" . number_format($precio) . "\n\nSaldo actual: $" . number_format($this->saldoUsuario) . " — te faltan $" . number_format($falta) . "\n\n¿Cómo quieres pagar?",
|
|
'payload' => ['botones' => [
|
|
['label' => "💳 Pagar con Bre-B directo ($" . number_format($precio) . ")", 'action' => 'pago.directo.breb', 'data' => ['tipo' => 'tarifa', 'tarifa_id' => $tarifaId, 'valor' => $precio]],
|
|
['label' => "💰 Recargar saldo ($" . number_format($falta) . " faltantes)", 'action' => 'recarga.iniciar', 'data' => []],
|
|
['label' => 'Volver', 'action' => 'servicios.listar', 'data' => []],
|
|
]],
|
|
'leido' => true,
|
|
]);
|
|
}
|
|
}
|
|
|
|
private function pagarConSaldo(): void
|
|
{
|
|
if (! $this->userId || $this->flujo !== 'compra') {
|
|
$this->guardarMensajeBot($this->convId, "Debes estar identificado para pagar con saldo.");
|
|
return;
|
|
}
|
|
|
|
$tarifa = Tarifas::with('servicio')->find($this->flujoData['tarifa_id'] ?? 0);
|
|
if (! $tarifa) {
|
|
$this->guardarMensajeBot($this->convId, "Ocurrio un error. Intenta de nuevo.");
|
|
return;
|
|
}
|
|
|
|
$user = User::with('saldo')->find($this->userId);
|
|
$saldo = $user->saldo;
|
|
$precio = (float) ($this->flujoData['valor'] ?? $tarifa->valor);
|
|
|
|
if (! $saldo || $saldo->valor < $precio) {
|
|
$this->guardarMensajeBot(
|
|
$this->convId,
|
|
"Saldo insuficiente. Tienes $" . number_format($saldo?->valor ?? 0) .
|
|
" y el plan cuesta $" . number_format($precio) . "."
|
|
);
|
|
$this->volver('menu.principal');
|
|
return;
|
|
}
|
|
|
|
$cuenta = $this->buscarCuentaDisponible($tarifa);
|
|
if (! $cuenta) {
|
|
$this->guardarMensajeBot($this->convId, "No hay cuentas disponibles en este momento. Intenta más tarde o contacta a un asesor.");
|
|
return;
|
|
}
|
|
|
|
$nuevoSaldo = $saldo->valor - $precio;
|
|
|
|
DB::transaction(function () use ($tarifa, $user, $saldo, $cuenta, $precio, $nuevoSaldo) {
|
|
$historial = Historiale::create([
|
|
'fecha_inicio' => now(),
|
|
'fecha_final' => now()->addDays($tarifa->dias),
|
|
'valor' => $precio,
|
|
'utilidad' => $tarifa->utilidad ?? 0,
|
|
'tipo_pago' => 'saldo',
|
|
'estado' => 'entregado',
|
|
'vendedor_id' => $this->userId,
|
|
'tarifa_id' => $tarifa->id,
|
|
'cliente_id' => $this->userId,
|
|
'nombre_cliente' => $user->name,
|
|
]);
|
|
Historial_cuenta::create(['historial_id' => $historial->id, 'cuenta_id' => $cuenta->id]);
|
|
$cuenta->update(['estado' => 'ocupado']);
|
|
$saldo->update(['valor' => $nuevoSaldo]);
|
|
});
|
|
|
|
$this->saldoUsuario = $nuevoSaldo;
|
|
|
|
ChatMessage::create([
|
|
'conversation_id' => $this->convId,
|
|
'tipo' => 'bot',
|
|
'tipo_ui' => 'credenciales',
|
|
'contenido' => '¡Muchas gracias por tu compra! 🎉 Disfruta tu servicio. Si tienes algún inconveniente no dudes en escribirnos, estamos aquí para ayudarte.',
|
|
'payload' => [
|
|
'servicio' => $tarifa->servicio->nombre,
|
|
'email' => $cuenta->correo,
|
|
'password' => $cuenta->password,
|
|
'perfil' => $tarifa->pantallas ?? null,
|
|
'vence' => now()->addDays($tarifa->dias)->format('d/m/Y'),
|
|
],
|
|
'leido' => true,
|
|
]);
|
|
|
|
$this->flujo = '';
|
|
$this->flujoData = [];
|
|
$this->volver('menu.principal');
|
|
}
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Promociones
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
private function listarPromociones(): void
|
|
{
|
|
$rolId = $this->userId
|
|
? User::find($this->userId)?->rol_id
|
|
: Role::where('nombre', 'cliente')->value('id');
|
|
|
|
$promos = Promociones::where('visible', 'true')
|
|
->where(fn ($q) => $q->whereNull('fecha_limite')->orWhere('fecha_limite', '>=', now()))
|
|
->with(['tarifaPromo' => fn ($q) => $q->where('rol_id', $rolId)])
|
|
->get();
|
|
|
|
if ($promos->isEmpty()) {
|
|
$this->guardarMensajeBot($this->convId, "No hay promociones activas en este momento.");
|
|
$this->volver('menu.principal');
|
|
return;
|
|
}
|
|
|
|
$cards = $promos->map(function ($p) {
|
|
$tp = $p->tarifaPromo->first();
|
|
$upric = $this->userId
|
|
? Usuario_promo::where('usuario_id', $this->userId)->where('promocion_id', $p->id)->first()
|
|
: null;
|
|
$precio = $upric ? $upric->precio : ($tp ? $tp->precio : $p->precio);
|
|
|
|
return [
|
|
'imagen' => $p->img_publicidad,
|
|
'titulo' => $p->nombre ?? 'Promocion especial',
|
|
'descripcion' => $p->descripcion ?? null,
|
|
'precio' => $precio,
|
|
'detalle' => $p->fecha_limite ? 'Hasta ' . Carbon::parse($p->fecha_limite)->format('d/m/Y') : null,
|
|
'accion' => ['label' => 'Comprar', 'action' => 'promo.ver', 'data' => ['id' => $p->id]],
|
|
];
|
|
})->values()->all();
|
|
|
|
ChatMessage::create([
|
|
'conversation_id' => $this->convId,
|
|
'tipo' => 'bot',
|
|
'tipo_ui' => 'cards_slider',
|
|
'contenido' => 'Promociones disponibles:',
|
|
'payload' => ['cards' => $cards],
|
|
'leido' => true,
|
|
]);
|
|
|
|
$this->volver('menu.principal');
|
|
}
|
|
|
|
private function verPromocion(int $id): void
|
|
{
|
|
$rolId = $this->userId
|
|
? User::find($this->userId)?->rol_id
|
|
: Role::where('nombre', 'cliente')->value('id');
|
|
|
|
$promo = Promociones::with(['tarifaPromo' => fn ($q) => $q->where('rol_id', $rolId)])->find($id);
|
|
if (! $promo) {
|
|
$this->guardarMensajeBot($this->convId, "Promocion no encontrada.");
|
|
return;
|
|
}
|
|
|
|
$upric = $this->userId
|
|
? Usuario_promo::where('usuario_id', $this->userId)->where('promocion_id', $id)->first()
|
|
: null;
|
|
$tp = $promo->tarifaPromo->first();
|
|
$precio = $upric ? $upric->precio : ($tp ? $tp->precio : $promo->precio);
|
|
|
|
if ((float) $this->saldoUsuario >= $precio) {
|
|
ChatMessage::create([
|
|
'conversation_id' => $this->convId,
|
|
'tipo' => 'bot',
|
|
'tipo_ui' => 'buttons',
|
|
'contenido' => ($promo->nombre ?? 'Promocion') . "\n$" . number_format($precio) . "\n\nElige como pagar:",
|
|
'payload' => ['botones' => [
|
|
['label' => 'Pagar con saldo ($' . number_format($precio) . ')', 'action' => 'promo.comprar.saldo', 'data' => ['id' => $id]],
|
|
['label' => 'Volver', 'action' => 'promo.listar', 'data' => []],
|
|
]],
|
|
'leido' => true,
|
|
]);
|
|
} else {
|
|
$falta = $precio - (float) $this->saldoUsuario;
|
|
|
|
$this->flujoData = array_merge($this->flujoData, [
|
|
'pending_purchase' => [
|
|
'type' => 'promo',
|
|
'promo_id' => $id,
|
|
'valor' => $precio,
|
|
'nombre' => $promo->nombre ?? 'Promoción',
|
|
],
|
|
]);
|
|
|
|
ChatMessage::create([
|
|
'conversation_id' => $this->convId,
|
|
'tipo' => 'bot',
|
|
'tipo_ui' => 'buttons',
|
|
'contenido' => ($promo->nombre ?? 'Promocion') . "\n$" . number_format($precio) . "\n\nSaldo actual: $" . number_format($this->saldoUsuario) . " — te faltan $" . number_format($falta) . "\n\n¿Cómo quieres pagar?",
|
|
'payload' => ['botones' => [
|
|
['label' => "💳 Pagar con Bre-B directo ($" . number_format($precio) . ")", 'action' => 'pago.directo.breb', 'data' => ['tipo' => 'promo', 'promo_id' => $id, 'valor' => $precio]],
|
|
['label' => "💰 Recargar saldo ($" . number_format($falta) . " faltantes)", 'action' => 'recarga.iniciar', 'data' => []],
|
|
['label' => 'Volver', 'action' => 'promo.listar', 'data' => []],
|
|
]],
|
|
'leido' => true,
|
|
]);
|
|
}
|
|
}
|
|
|
|
private function comprarPromoConSaldo(int $promoId): void
|
|
{
|
|
if (! $this->userId) {
|
|
$this->guardarMensajeBot($this->convId, "No se pudo procesar la compra.");
|
|
return;
|
|
}
|
|
|
|
$user = User::with('saldo')->find($this->userId);
|
|
$rolId = $user->rol_id;
|
|
|
|
$promo = Promociones::with(['tarifaPromo' => fn ($q) => $q->where('rol_id', $rolId)])->find($promoId);
|
|
if (! $promo) {
|
|
$this->guardarMensajeBot($this->convId, "No se pudo procesar la compra.");
|
|
return;
|
|
}
|
|
|
|
$upric = Usuario_promo::where('usuario_id', $this->userId)->where('promocion_id', $promoId)->first();
|
|
$tp = $promo->tarifaPromo->first();
|
|
$precio = $upric ? $upric->precio : ($tp ? $tp->precio : $promo->precio);
|
|
$utilidad = $upric ? $upric->utilidad : ($tp ? $tp->utilidad : 0);
|
|
|
|
$saldo = $user->saldo;
|
|
|
|
if (! $saldo || $saldo->valor < $precio) {
|
|
$this->guardarMensajeBot($this->convId, "Saldo insuficiente ($" . number_format($saldo?->valor ?? 0) . "). La promo cuesta $" . number_format($precio) . ".");
|
|
return;
|
|
}
|
|
|
|
$cuenta = Cuentas::where('promocion_id', $promoId)->where('estado', 'activo')->first();
|
|
if (! $cuenta) {
|
|
$this->guardarMensajeBot($this->convId, "No hay cuentas disponibles para esta promocion.");
|
|
return;
|
|
}
|
|
|
|
$nuevoSaldo = $saldo->valor - $precio;
|
|
|
|
DB::transaction(function () use ($promo, $user, $saldo, $cuenta, $precio, $utilidad, $promoId, $nuevoSaldo) {
|
|
$historial = Historiale::create([
|
|
'fecha_inicio' => now(),
|
|
'fecha_final' => now()->addDays(30),
|
|
'valor' => $precio,
|
|
'utilidad' => $utilidad,
|
|
'tipo_pago' => 'saldo',
|
|
'estado' => 'entregado',
|
|
'vendedor_id' => $this->userId,
|
|
'promocion_id' => $promoId,
|
|
'cliente_id' => $this->userId,
|
|
'nombre_cliente' => $user->name,
|
|
]);
|
|
Historial_cuenta::create(['historial_id' => $historial->id, 'cuenta_id' => $cuenta->id]);
|
|
$cuenta->update(['estado' => 'ocupado']);
|
|
$saldo->update(['valor' => $nuevoSaldo]);
|
|
});
|
|
|
|
$this->saldoUsuario = $nuevoSaldo;
|
|
|
|
ChatMessage::create([
|
|
'conversation_id' => $this->convId,
|
|
'tipo' => 'bot',
|
|
'tipo_ui' => 'credenciales',
|
|
'contenido' => '¡Muchas gracias por tu compra! 🎉 Disfruta tu servicio. Si tienes algún inconveniente no dudes en escribirnos, estamos aquí para ayudarte.',
|
|
'payload' => [
|
|
'servicio' => $promo->nombre ?? 'Promocion',
|
|
'email' => $cuenta->correo,
|
|
'password' => $cuenta->password,
|
|
'vence' => now()->addDays(30)->format('d/m/Y'),
|
|
],
|
|
'leido' => true,
|
|
]);
|
|
|
|
$this->volver('menu.principal');
|
|
}
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Pago directo con Bre-B sin necesidad de saldo previo
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
private function pagarDirectoBreb(array $data): void
|
|
{
|
|
$tipo = $data['tipo'] ?? 'tarifa';
|
|
$valor = (int) ($data['valor'] ?? 0);
|
|
|
|
if ($valor <= 0) {
|
|
$this->guardarMensajeBot($this->convId, "No se pudo iniciar el pago. Intenta de nuevo.");
|
|
return;
|
|
}
|
|
|
|
if ($tipo === 'tarifa') {
|
|
$tarifa = Tarifas::with('servicio')->find($data['tarifa_id'] ?? 0);
|
|
if (! $tarifa) {
|
|
$this->guardarMensajeBot($this->convId, "Plan no encontrado.");
|
|
return;
|
|
}
|
|
$this->flujoData = array_merge($this->flujoData, [
|
|
'pending_purchase' => [
|
|
'type' => 'tarifa',
|
|
'tarifa_id' => $data['tarifa_id'],
|
|
'valor' => $valor,
|
|
'servicio' => $tarifa->servicio->nombre,
|
|
'dias' => $tarifa->dias,
|
|
'pantallas' => $tarifa->pantallas,
|
|
],
|
|
]);
|
|
} elseif ($tipo === 'promo') {
|
|
$promo = Promociones::find($data['promo_id'] ?? 0);
|
|
if (! $promo) {
|
|
$this->guardarMensajeBot($this->convId, "Promoción no encontrada.");
|
|
return;
|
|
}
|
|
$this->flujoData = array_merge($this->flujoData, [
|
|
'pending_purchase' => [
|
|
'type' => 'promo',
|
|
'promo_id' => $data['promo_id'],
|
|
'valor' => $valor,
|
|
'nombre' => $promo->nombre ?? 'Promoción',
|
|
],
|
|
]);
|
|
}
|
|
|
|
$this->mostrarTransferencia($valor);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Recarga de saldo
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
private function mostrarFormRecarga(): void
|
|
{
|
|
$botones = [];
|
|
foreach ([20000, 50000, 100000, 200000, 500000] as $m) {
|
|
$botones[] = ['label' => '$' . number_format($m), 'action' => 'recarga.elegir_metodo', 'data' => ['monto' => $m]];
|
|
}
|
|
$botones[] = ['label' => 'Otro valor', 'action' => 'recarga.otro_valor', 'data' => []];
|
|
$botones[] = ['label' => 'Volver', 'action' => 'menu.principal', 'data' => []];
|
|
|
|
ChatMessage::create([
|
|
'conversation_id' => $this->convId,
|
|
'tipo' => 'bot',
|
|
'tipo_ui' => 'buttons',
|
|
'contenido' => "Selecciona el monto a recargar:",
|
|
'payload' => ['botones' => $botones],
|
|
'leido' => true,
|
|
]);
|
|
}
|
|
|
|
private function solicitarMontoPersonalizado(): void
|
|
{
|
|
$this->flujo = 'recarga.monto_custom';
|
|
$this->flujoData = [];
|
|
$this->guardarMensajeBot($this->convId, "Escribe el monto que deseas recargar (mínimo $1.000):");
|
|
}
|
|
|
|
private function elegirMetodoRecarga(int $monto): void
|
|
{
|
|
if ($monto <= 0) {
|
|
return;
|
|
}
|
|
|
|
$llave = \App\Models\ChatConfig::get('recarga_banco_llave', '');
|
|
if (! $llave) {
|
|
$this->guardarMensajeBot($this->convId, "⚠️ La recarga no está disponible en este momento. Contacta a un asesor.");
|
|
$this->volver('menu.principal');
|
|
return;
|
|
}
|
|
|
|
ChatMessage::create([
|
|
'conversation_id' => $this->convId,
|
|
'tipo' => 'bot',
|
|
'tipo_ui' => 'buttons',
|
|
'contenido' => "Recarga de $" . number_format($monto) . "\n¿Como quieres pagar?",
|
|
'payload' => ['botones' => [
|
|
['label' => '🔑 Bre-B', 'action' => 'recarga.transferencia', 'data' => ['monto' => $monto]],
|
|
['label' => 'Volver', 'action' => 'recarga.iniciar', 'data' => []],
|
|
]],
|
|
'leido' => true,
|
|
]);
|
|
}
|
|
|
|
private function mostrarTransferencia(int $monto): void
|
|
{
|
|
if ($monto <= 0) {
|
|
return;
|
|
}
|
|
|
|
$this->flujoData = array_merge($this->flujoData, ['monto' => $monto]);
|
|
|
|
$nombresPrevios = $this->userId ? \App\Models\SolicitudRecarga::nombresPrevios($this->userId) : [];
|
|
|
|
if (! empty($nombresPrevios)) {
|
|
$this->flujo = 'pago.esperando_nombre';
|
|
|
|
$botones = array_map(fn($n) => ['label' => $n, 'action' => 'pago.nombre_previo', 'data' => ['nombre' => $n]], $nombresPrevios);
|
|
$botones[] = ['label' => '✏️ Usar otro nombre', 'action' => 'pago.nombre_nuevo', 'data' => []];
|
|
|
|
ChatMessage::create([
|
|
'conversation_id' => $this->convId,
|
|
'tipo' => 'bot',
|
|
'tipo_ui' => 'buttons',
|
|
'contenido' => '👤 ¿Desde qué cuenta vas a transferir? Elige un nombre anterior o usa uno nuevo:',
|
|
'payload' => ['botones' => $botones],
|
|
'leido' => true,
|
|
]);
|
|
} else {
|
|
$this->flujo = 'pago.esperando_nombre';
|
|
$this->guardarMensajeBot($this->convId, "👤 Escribe tu nombre completo tal como aparece en tu cuenta bancaria o billetera:");
|
|
}
|
|
|
|
$this->cargarMensajes();
|
|
$this->dispatchBrowserEvent('scroll-chat');
|
|
}
|
|
|
|
private function usarNombrePrevio(string $nombre): void
|
|
{
|
|
if (strlen(trim($nombre)) < 3) {
|
|
return;
|
|
}
|
|
$this->flujoData['nombre_remitente'] = trim($nombre);
|
|
$this->flujo = '';
|
|
$this->mostrarTransferenciaDatos((int) ($this->flujoData['monto'] ?? 0));
|
|
}
|
|
|
|
private function pedirNombreNuevo(): void
|
|
{
|
|
$this->flujo = 'pago.esperando_nombre';
|
|
$this->guardarMensajeBot($this->convId, "👤 Escribe tu nombre completo tal como aparece en tu cuenta bancaria:");
|
|
}
|
|
|
|
private function mostrarTransferenciaDatos(int $monto): void
|
|
{
|
|
$banco = \App\Models\ChatConfig::get('recarga_banco_nombre', '');
|
|
$clave = \App\Models\ChatConfig::get('recarga_banco_llave', '');
|
|
$titular = \App\Models\ChatConfig::get('recarga_banco_titular', '');
|
|
|
|
if (! $clave) {
|
|
$this->guardarMensajeBot($this->convId, "El pago por Bre-B no esta disponible en este momento. Contacta a un asesor.");
|
|
return;
|
|
}
|
|
|
|
ChatMessage::create([
|
|
'conversation_id' => $this->convId,
|
|
'tipo' => 'bot',
|
|
'tipo_ui' => 'banco',
|
|
'contenido' => 'Datos para transferencia:',
|
|
'payload' => [
|
|
'banco' => $banco,
|
|
'clave' => $clave,
|
|
'titular' => $titular,
|
|
'monto' => $monto,
|
|
],
|
|
'leido' => true,
|
|
]);
|
|
|
|
$this->guardarMensajeBot($this->convId,
|
|
"📋 *Al enviar el comprobante, asegúrate de que se vea claramente:*\n"
|
|
. "✓ Tu nombre completo\n"
|
|
. "✓ El valor transferido (\$" . number_format($monto) . ")\n"
|
|
. "✓ La fecha y hora de la transacción\n\n"
|
|
. "Cuando hayas transferido, envia el comprobante usando el icono de camara (📷) y el sistema lo validara automaticamente.\n\n"
|
|
. "Tienes hasta el final del dia para enviar el soporte."
|
|
);
|
|
|
|
if ($this->userId) {
|
|
$nombre = $this->flujoData['nombre_remitente'] ?? null;
|
|
$solicitud = \App\Models\SolicitudRecarga::crear($this->userId, $monto, 'web', $nombre);
|
|
$this->flujo = 'pago.solicitud';
|
|
$this->flujoData = array_merge($this->flujoData, ['solicitud_id' => $solicitud->id, 'monto' => $monto]);
|
|
}
|
|
|
|
$this->volver('menu.principal');
|
|
}
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Credenciales
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
private function listarCredenciales(): void
|
|
{
|
|
if (! $this->userId) {
|
|
$this->guardarMensajeBot($this->convId, "No encontre tu cuenta. Contacta a un asesor para ver tus credenciales.");
|
|
$this->volver('menu.principal');
|
|
return;
|
|
}
|
|
|
|
$historiales = Historiale::where('cliente_id', $this->userId)
|
|
->where('estado', 'entregado')
|
|
->where('fecha_final', '>=', now())
|
|
->with('tarifa.servicio')
|
|
->orderBy('fecha_final', 'desc')
|
|
->limit(10)
|
|
->get();
|
|
|
|
if ($historiales->isEmpty()) {
|
|
$this->guardarMensajeBot($this->convId, "No tienes planes activos en este momento.");
|
|
$this->volver('menu.principal');
|
|
return;
|
|
}
|
|
|
|
$this->guardarMensajeBot($this->convId, "Tus planes activos:");
|
|
|
|
foreach ($historiales as $h) {
|
|
foreach ($h->cuentas as $c) {
|
|
ChatMessage::create([
|
|
'conversation_id' => $this->convId,
|
|
'tipo' => 'bot',
|
|
'tipo_ui' => 'credenciales',
|
|
'contenido' => '',
|
|
'payload' => [
|
|
'servicio' => $h->tarifa?->servicio?->nombre ?? 'Servicio',
|
|
'email' => $c->correo,
|
|
'password' => $c->password,
|
|
'perfil' => $h->tarifa?->pantallas ?? null,
|
|
'vence' => Carbon::parse($h->fecha_final)->format('d/m/Y'),
|
|
],
|
|
'leido' => true,
|
|
]);
|
|
}
|
|
}
|
|
|
|
$this->volver('menu.principal');
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Historial
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
private function verHistorial(): void
|
|
{
|
|
if (! $this->userId) {
|
|
$this->guardarMensajeBot($this->convId, "Inicia sesion para ver tu historial.");
|
|
$this->volver('menu.principal');
|
|
return;
|
|
}
|
|
|
|
$historiales = Historiale::where('cliente_id', $this->userId)
|
|
->with('tarifa.servicio')
|
|
->orderBy('created_at', 'desc')
|
|
->limit(10)
|
|
->get();
|
|
|
|
if ($historiales->isEmpty()) {
|
|
$this->guardarMensajeBot($this->convId, "No tienes compras registradas.");
|
|
$this->volver('menu.principal');
|
|
return;
|
|
}
|
|
|
|
$lineas = ["Tus ultimas compras:\n"];
|
|
foreach ($historiales as $h) {
|
|
$activo = Carbon::parse($h->fecha_final)->gte(now()) ? '[activo]' : '[vencido]';
|
|
$nombre = $h->tarifa?->servicio?->nombre ?? ($h->promocion_id ? 'Promo' : 'Plan');
|
|
$fecha = Carbon::parse($h->created_at)->format('d/m/Y');
|
|
$lineas[] = "{$activo} {$nombre} - $" . number_format($h->valor) . " ({$fecha})";
|
|
}
|
|
|
|
$this->guardarMensajeBot($this->convId, implode("\n", $lineas));
|
|
$this->volver('menu.principal');
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Perfil
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
private function verPerfil(): void
|
|
{
|
|
if (! $this->userId) {
|
|
$this->guardarMensajeBot($this->convId, "No encontre tu cuenta. Contacta a un asesor.");
|
|
$this->volver('menu.principal');
|
|
return;
|
|
}
|
|
|
|
$user = User::with('saldo')->find($this->userId);
|
|
|
|
$this->guardarMensajeBot(
|
|
$this->convId,
|
|
"Tu perfil\nNombre: {$user->name}\nEmail: {$user->email}\nSaldo: $" . number_format($user->saldo?->valor ?? 0)
|
|
);
|
|
|
|
$this->volver('menu.principal');
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Asesor
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
private function solicitarAsesor(): void
|
|
{
|
|
$conv = ChatConversation::find($this->convId);
|
|
if ($conv) {
|
|
$conv->update(['estado' => 'agente']);
|
|
$this->estadoConv = 'agente';
|
|
}
|
|
$this->guardarMensajeBot($this->convId, "Un asesor se comunicara contigo en breve. Por favor espera.");
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Aplicar recarga validada por IA
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Poll barato mientras el job valida (flujo 'pago.procesando').
|
|
* No hace llamadas externas: solo mira si el job ya dejó su desenlace.
|
|
* La recarga se aplica aquí y no en el job porque aquí vive la compra
|
|
* pendiente que debe completarse tras acreditar el saldo.
|
|
*/
|
|
public function revisarPago(): void
|
|
{
|
|
if ($this->flujo !== 'pago.procesando' || ! $this->convId) {
|
|
return;
|
|
}
|
|
|
|
$solicitudId = (int) ($this->flujoData['solicitud_id'] ?? 0);
|
|
if (! $solicitudId) {
|
|
$this->flujo = '';
|
|
$this->flujoData = [];
|
|
return;
|
|
}
|
|
|
|
$clave = \App\Jobs\ValidarPagoWebJob::claveResultado($this->convId, $solicitudId);
|
|
$resultado = Cache::get($clave);
|
|
|
|
if (! $resultado) {
|
|
// Red de seguridad: si el worker está caído el job nunca escribirá
|
|
// nada y el chat se quedaría con los puntitos para siempre.
|
|
// El job reintenta ~3 min, así que a los 6 damos por perdido.
|
|
$inicio = (int) ($this->flujoData['inicio'] ?? 0);
|
|
|
|
if ($inicio && now()->timestamp - $inicio > 360) {
|
|
$this->flujo = '';
|
|
$this->flujoData = [];
|
|
$this->guardarMensajeBot($this->convId, "No pude terminar de validar tu pago. Si ya transferiste, escribe a un asesor y lo revisamos manualmente.");
|
|
$this->volver('menu.principal');
|
|
$this->cargarMensajes();
|
|
$this->dispatchBrowserEvent('scroll-chat');
|
|
}
|
|
|
|
return; // el job sigue trabajando; sus mensajes llegan por pollMensajes()
|
|
}
|
|
|
|
Cache::forget($clave);
|
|
|
|
if (($resultado['estado'] ?? '') === 'confirmado') {
|
|
$this->finalizarRecargaConfirmada();
|
|
return;
|
|
}
|
|
|
|
// 'agotado' deja el flujo listo para el botón manual de Reintentar
|
|
if (($resultado['estado'] ?? '') === 'agotado') {
|
|
$this->flujo = 'pago.pendiente';
|
|
// El job ya leyó el comprobante: guardamos los datos para que un
|
|
// reintento manual no tenga que volver a gastar una llamada a la IA.
|
|
if (! empty($resultado['datosPago'])) {
|
|
$this->flujoData['datosPago'] = $resultado['datosPago'];
|
|
}
|
|
} else {
|
|
$this->flujo = '';
|
|
$this->flujoData = [];
|
|
$this->volver('menu.principal');
|
|
}
|
|
|
|
$this->cargarMensajes();
|
|
$this->dispatchBrowserEvent('scroll-chat');
|
|
}
|
|
|
|
/** Boton "Reintentar": vuelve a encolar la busqueda, sin bloquear el request. */
|
|
private function reintentarValidacionPago(): void
|
|
{
|
|
// Basta con tener la imagen: si no hay datosPago el job la vuelve a leer.
|
|
if (empty($this->flujoData['comprobante_path'])) {
|
|
$this->guardarMensajeBot($this->convId, 'No hay ningun comprobante pendiente. Envia la foto de tu comprobante.');
|
|
return;
|
|
}
|
|
|
|
$solicitud = $this->userId ? \App\Models\SolicitudRecarga::pendiente($this->userId) : null;
|
|
if (! $solicitud) {
|
|
$this->flujo = '';
|
|
$this->flujoData = [];
|
|
$this->guardarMensajeBot($this->convId, 'Tu solicitud de recarga expiro. Inicia una nueva recarga desde el menu.');
|
|
return;
|
|
}
|
|
|
|
$this->guardarMensajeBot($this->convId, 'Buscando tu pago de nuevo...');
|
|
|
|
$this->lanzarValidacion(
|
|
$solicitud,
|
|
$this->flujoData['comprobante_path'],
|
|
$this->flujoData['datosPago'] ?? null
|
|
);
|
|
}
|
|
|
|
/**
|
|
* El job ya acreditó el saldo y avisó al usuario. Aquí solo refrescamos el
|
|
* saldo en pantalla y rematamos la compra que hubiera quedado esperando.
|
|
*/
|
|
private function finalizarRecargaConfirmada(): void
|
|
{
|
|
$this->saldoUsuario = User::with('saldo')->find($this->userId)?->saldo?->valor ?? $this->saldoUsuario;
|
|
|
|
$pp = $this->flujoData['pending_purchase'] ?? null;
|
|
$this->flujo = '';
|
|
$this->flujoData = [];
|
|
|
|
if ($pp && ($pp['type'] ?? '') === 'tarifa') {
|
|
$this->flujo = 'compra';
|
|
$this->flujoData = [
|
|
'tarifa_id' => $pp['tarifa_id'],
|
|
'valor' => $pp['valor'],
|
|
'servicio' => $pp['servicio'],
|
|
'dias' => $pp['dias'],
|
|
'pantallas' => $pp['pantallas'],
|
|
];
|
|
$this->pagarConSaldo();
|
|
} elseif ($pp && ($pp['type'] ?? '') === 'promo') {
|
|
$this->comprarPromoConSaldo($pp['promo_id']);
|
|
} else {
|
|
$this->volver('menu.principal');
|
|
}
|
|
|
|
$this->cargarMensajes();
|
|
$this->dispatchBrowserEvent('scroll-chat');
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
private function aplicarRecargaValidada(array $data): void
|
|
{
|
|
if (! $this->userId) {
|
|
$this->guardarMensajeBot($this->convId, "No puedo aplicar la recarga: no estas identificado.");
|
|
return;
|
|
}
|
|
|
|
$monto = (int) ($data['monto'] ?? 0);
|
|
if ($monto <= 0) {
|
|
$this->guardarMensajeBot($this->convId, "Monto invalido.");
|
|
return;
|
|
}
|
|
|
|
$user = User::with('saldo')->find($this->userId);
|
|
$saldo = $user->saldo ?? Saldo::create(['usuario_id' => $this->userId, 'valor' => 0]);
|
|
$nuevoSaldo = $saldo->valor + $monto;
|
|
$saldo->update(['valor' => $nuevoSaldo]);
|
|
$this->saldoUsuario = $nuevoSaldo;
|
|
|
|
$this->guardarMensajeBot(
|
|
$this->convId,
|
|
"Recarga de $" . number_format($monto) . " aplicada. Tu nuevo saldo: $" . number_format($nuevoSaldo) . "."
|
|
);
|
|
|
|
$this->volver('menu.principal');
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// ─────────────────────────────────────────────────────────
|
|
// Detección de intención: keywords primero, Gemini como fallback
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
private function detectarIntencion(string $texto): ?array
|
|
{
|
|
return $this->detectarIntencionKeyword($texto)
|
|
?? $this->detectarServicioPorNombre($texto)
|
|
?? $this->detectarIntencionIA($texto);
|
|
}
|
|
|
|
/**
|
|
* "cuenta de hbo" debe abrir HBO, no el listado completo.
|
|
* Se resuelve contra los nombres reales de la tabla servicios, así que no
|
|
* hay marcas quemadas en el código ni hace falta una llamada a la IA.
|
|
*
|
|
* Corre DESPUÉS de las keywords genéricas a propósito: frases como
|
|
* "no puedo entrar a netflix" son un problema de credenciales, no una
|
|
* intención de compra, y esas ya las capturó el paso anterior.
|
|
*/
|
|
private function detectarServicioPorNombre(string $texto): ?array
|
|
{
|
|
$t = $this->normalizarTexto($texto);
|
|
|
|
if ($t === '') {
|
|
return null;
|
|
}
|
|
|
|
$servicios = Servicio::where('estado', 'activo')->get(['id', 'nombre']);
|
|
|
|
// Nombre más largo primero: "hbo max" debe ganarle a un eventual "max"
|
|
$candidatos = [];
|
|
|
|
foreach ($servicios as $s) {
|
|
$nombre = $this->normalizarTexto($s->nombre);
|
|
if ($nombre === '') {
|
|
continue;
|
|
}
|
|
|
|
foreach (array_unique(array_merge([$nombre], explode(' ', $nombre))) as $clave) {
|
|
if (mb_strlen($clave) >= 3) {
|
|
$candidatos[] = ['clave' => $clave, 'id' => $s->id];
|
|
}
|
|
}
|
|
}
|
|
|
|
usort($candidatos, fn ($a, $b) => mb_strlen($b['clave']) <=> mb_strlen($a['clave']));
|
|
|
|
foreach ($candidatos as $c) {
|
|
if (preg_match('/\b' . preg_quote($c['clave'], '/') . '\b/u', $t)) {
|
|
return ['action' => 'servicios.ver', 'data' => ['id' => $c['id']]];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** Minúsculas y sin tildes, para comparar sin sorpresas. */
|
|
private function normalizarTexto(string $s): string
|
|
{
|
|
$s = mb_strtolower(trim($s));
|
|
return iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s) ?: $s;
|
|
}
|
|
|
|
/** Resuelve un servicio por nombre cuando la IA devuelve data.servicio. */
|
|
private function verServicioPorNombre(string $nombre): void
|
|
{
|
|
$nombre = trim($nombre);
|
|
|
|
$servicio = $nombre === '' ? null : Servicio::where('estado', 'activo')
|
|
->whereRaw('LOWER(nombre) LIKE ?', ['%' . mb_strtolower($nombre) . '%'])
|
|
->first();
|
|
|
|
$servicio ? $this->verServicio($servicio->id) : $this->listarServicios();
|
|
}
|
|
|
|
private function detectarIntencionKeyword(string $texto): ?array
|
|
{
|
|
$t = mb_strtolower(trim($texto));
|
|
|
|
$patrones = [
|
|
// Recarga de saldo
|
|
'recarga.iniciar' => '/recarg|recargar|cargar\s*(saldo|cuenta|dinero|plata)|abonar|depositar|agregar\s*(saldo|dinero|plata)|quiero\s*poner\s*saldo/',
|
|
// Credenciales / acceso. Va antes que los servicios porque
|
|
// "no puedo entrar a netflix" es soporte, no intención de compra.
|
|
'credenciales.listar' => '/credencial|contrase[ñn]a|clave|password|mis\s*datos\s*de\s*acceso|mi\s*cuenta\b|c[oó]mo\s*(entro|accedo|me\s*conecto)|no\s*(puedo|me\s*deja|logro)\s*(entrar|acceder|iniciar)|no\s*(inicia|abre|carga|funciona|sirve)\b/',
|
|
// Ver servicios / planes. Sin marcas: los nombres concretos los
|
|
// resuelve detectarServicioPorNombre() contra la tabla servicios,
|
|
// que además abre el servicio pedido en vez del listado entero.
|
|
'servicios.listar' => '/servicio|plan(es)?|cat[aá]logo|qu[eé]\s*(hay|tienen|tienes)|ver\s*plan|streaming|aplicacion|aplicaci[oó]n/',
|
|
// Promociones
|
|
'promo.listar' => '/promo(ci[oó]n)?|oferta|descuento|especial/',
|
|
// Historial de compras
|
|
'historial.ver' => '/historial|compra(s)?|pedido(s)?|lo\s*que\s*(compr[eé]|he\s*comprado)|mis\s*compras/',
|
|
// Perfil
|
|
'perfil.ver' => '/perfil|mis\s*datos|mi\s*informaci[oó]n|mis\s*datos\s*personales/',
|
|
// Asesor humano
|
|
'asesor.solicitar' => '/asesor|agente|persona|humano|soporte|ayuda\s*(de\s*una\s*persona)?|hablar\s*con\s*(alguien|una\s*persona|un\s*asesor)/',
|
|
// Menú / saludo
|
|
'menu.principal' => '/^(hola|buenas?|buenos?|hey|men[uú]|inicio|principal|volver|regresar|empezar|comenzar|start)[\s!.]*$/',
|
|
];
|
|
|
|
foreach ($patrones as $action => $pattern) {
|
|
if (preg_match($pattern, $t)) {
|
|
$data = [];
|
|
if ($action === 'recarga.iniciar') {
|
|
if (preg_match('/\$?\s*(\d{1,3}(?:[.,]\d{3})+|\d{4,})\b/', $t, $m)) {
|
|
$monto = (int) preg_replace('/[.,]/', '', $m[1]);
|
|
if ($monto >= 1000) {
|
|
$data['monto'] = $monto;
|
|
}
|
|
}
|
|
}
|
|
return ['action' => $action, 'data' => $data];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function detectarIntencionIA(string $texto): ?array
|
|
{
|
|
try {
|
|
return app(\App\Services\GeminiIntentService::class)->detectar($texto);
|
|
} catch (\Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Cargar mensajes
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Trae los últimos MAX_MENSAJES en orden cronológico.
|
|
* El 'id' es imprescindible: alimenta el wire:key de la vista para que
|
|
* morphdom reutilice los nodos existentes en vez de recrear toda la lista.
|
|
*/
|
|
private function consultarMensajes(): array
|
|
{
|
|
return ChatMessage::where('conversation_id', $this->convId)
|
|
->orderByDesc('id')
|
|
->limit(self::MAX_MENSAJES)
|
|
->get()
|
|
->reverse()
|
|
->map(fn ($m) => [
|
|
'id' => $m->id,
|
|
'tipo' => $m->tipo,
|
|
'tipo_ui' => $m->tipo_ui ?? 'text',
|
|
'contenido' => $m->contenido,
|
|
'payload' => $m->payload ?? [],
|
|
'created_at' => $m->created_at->format('H:i'),
|
|
])
|
|
->values()
|
|
->toArray();
|
|
}
|
|
|
|
public function cargarMensajes(): void
|
|
{
|
|
if (! $this->convId) {
|
|
return;
|
|
}
|
|
|
|
$this->mensajes = $this->consultarMensajes();
|
|
$this->ultimoMsgId = (int) (end($this->mensajes)['id'] ?? 0);
|
|
|
|
$this->dispatchBrowserEvent('scroll-chat');
|
|
}
|
|
|
|
// Llamado por wire:poll cada 3s. Consulta barata: solo pregunta el último id
|
|
// y recarga la lista únicamente si llegó algo nuevo. Sin cambios en $mensajes
|
|
// el diff de Livewire queda vacío y el DOM no parpadea.
|
|
public function pollMensajes(): void
|
|
{
|
|
if (! $this->convId) {
|
|
return;
|
|
}
|
|
|
|
$ultimo = (int) ChatMessage::where('conversation_id', $this->convId)->max('id');
|
|
|
|
if ($ultimo === $this->ultimoMsgId) {
|
|
return;
|
|
}
|
|
|
|
$this->mensajes = $this->consultarMensajes();
|
|
$this->ultimoMsgId = $ultimo;
|
|
// No dispatches scroll-chat — el MutationObserver del JS maneja el badge
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Helpers
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
private function guardarMensajeBot(int $convId, string $contenido): void
|
|
{
|
|
ChatMessage::create([
|
|
'conversation_id' => $convId,
|
|
'tipo' => 'bot',
|
|
'tipo_ui' => 'text',
|
|
'contenido' => $contenido,
|
|
'leido' => true,
|
|
]);
|
|
}
|
|
|
|
private function guardarMensajeUsuario(int $convId, string $contenido): void
|
|
{
|
|
ChatMessage::create([
|
|
'conversation_id' => $convId,
|
|
'tipo' => 'usuario',
|
|
'tipo_ui' => 'text',
|
|
'contenido' => $contenido,
|
|
'leido' => false,
|
|
]);
|
|
}
|
|
|
|
private function contarDisponibles(Tarifas $tarifa): int
|
|
{
|
|
return CuentasHelper::contar($tarifa);
|
|
}
|
|
|
|
private function buscarCuentaDisponible(Tarifas $tarifa): ?Cuentas
|
|
{
|
|
return CuentasHelper::buscar($tarifa);
|
|
}
|
|
|
|
private function efectivePrice(int $tarifaId, ?int $userId, float $base): float
|
|
{
|
|
return CuentasHelper::precio($tarifaId, $userId, $base);
|
|
}
|
|
|
|
private function volver(string $action): void
|
|
{
|
|
ChatMessage::create([
|
|
'conversation_id' => $this->convId,
|
|
'tipo' => 'bot',
|
|
'tipo_ui' => 'buttons',
|
|
'contenido' => '',
|
|
'payload' => ['botones' => [
|
|
['label' => 'Menu principal', 'action' => $action, 'data' => []],
|
|
]],
|
|
'leido' => true,
|
|
]);
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
return view('livewire.chat.public-chat');
|
|
}
|
|
}
|