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'); } }