diff --git a/app/Http/Livewire/Chat/PublicChat.php b/app/Http/Livewire/Chat/PublicChat.php index f8f7d2c..d9c7fbc 100755 --- a/app/Http/Livewire/Chat/PublicChat.php +++ b/app/Http/Livewire/Chat/PublicChat.php @@ -495,7 +495,9 @@ class PublicChat extends Component match (true) { $action === 'menu.principal' => $this->mostrarMenuPrincipal($this->convId), - $action === 'servicios.listar' => $this->listarServicios(), + $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(), @@ -530,43 +532,45 @@ class PublicChat extends Component // 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) { - return; - } - - 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."); - $this->fotoComprobante = null; - $this->cargarMensajes(); + $this->dispatchBrowserEvent('comprobante-listo'); return; } try { - $this->validate([ - 'fotoComprobante' => 'image|mimes:jpg,jpeg,png,webp,heic|max:8192', - ]); - } catch (\Illuminate\Validation\ValidationException $e) { - $this->fotoComprobante = null; - $this->guardarMensajeBot($this->convId, "Ese archivo no sirve como comprobante. Envía una imagen (JPG o PNG) de máximo 8 MB."); - $this->cargarMensajes(); - 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 se hacen - // en ValidarPagoWebJob y aquí solo se guarda la imagen y se despacha. - try { - // Verificar solicitud activa ANTES de guardar nada - $solicitud = $this->userId ? \App\Models\SolicitudRecarga::pendiente($this->userId) : null; - if (! $solicitud) { - $this->fotoComprobante = null; - $this->guardarMensajeBot($this->convId, "⚠️ No tienes ninguna solicitud de recarga activa. Primero inicia el proceso de recarga desde el menú."); - $this->cargarMensajes(); + 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. @@ -593,6 +597,7 @@ class PublicChat extends Component } finally { $this->fotoComprobante = null; $this->cargarMensajes(); + $this->dispatchBrowserEvent('comprobante-listo'); $this->dispatchBrowserEvent('scroll-chat'); } } @@ -1543,9 +1548,75 @@ class PublicChat extends Component 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)); @@ -1553,12 +1624,15 @@ class PublicChat extends Component $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/', - // Ver servicios / planes - 'servicios.listar' => '/servicio|plan(es)?|cat[aá]logo|qu[eé]\s*(hay|tienen|tienes)|ver\s*plan|netflix|disney|hbo|prime|spotify|crunchyroll|paramount|streaming|aplicacion|aplicaci[oó]n/', + // 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/', - // Credenciales / acceso - 'credenciales.listar' => '/credencial|contrase[ñn]a|clave|password|acceso|usuario|mi\s*cuenta|mis\s*datos\s*de\s*acceso|c[oó]mo\s*(entro|accedo|me\s*conecto)/', // Historial de compras 'historial.ver' => '/historial|compra(s)?|pedido(s)?|lo\s*que\s*(compr[eé]|he\s*comprado)|mis\s*compras/', // Perfil diff --git a/app/Services/GeminiIntentService.php b/app/Services/GeminiIntentService.php index 2083736..9b16301 100755 --- a/app/Services/GeminiIntentService.php +++ b/app/Services/GeminiIntentService.php @@ -115,6 +115,8 @@ Los usuarios escriben en español latinoamericano informal, con jerga colombiana Acciones válidas — devuelve EXACTAMENTE una de estas: - menu.principal → saludo, "hola", "inicio", "volver", "menú", pedir ayuda general - servicios.listar → ver planes, catálogo, qué servicios hay, "quiero Netflix", "qué tienen", "cuánto vale", preguntar por precios + Si el usuario nombra un servicio concreto (Netflix, HBO, Disney, Spotify…), + devuélvelo en data.servicio para abrir ese servicio directamente. - promo.listar → ver promociones, ofertas, combos, descuentos, "hay algo especial" - recarga.iniciar → recargar saldo, cargar dinero, "quiero poner saldo", "cómo recargo", "agregar plata", "depositar" - credenciales.listar → ver contraseña, cómo entrar, "no puedo acceder", "dame el usuario", "mis datos de Netflix", "cómo me conecto" @@ -126,8 +128,10 @@ Ejemplos de clasificación: "quiero recargar" → recarga.iniciar "cargar saldo" → recarga.iniciar "poner plata" → recarga.iniciar -"ver planes" → servicios.listar -"qué tienen de Netflix" → servicios.listar +"ver planes" → servicios.listar, data {} +"qué tienen de Netflix" → servicios.listar, data {"servicio": "Netflix"} +"cuenta de hbo" → servicios.listar, data {"servicio": "HBO"} +"cuánto vale disney" → servicios.listar, data {"servicio": "Disney"} "hay promociones" → promo.listar "mis contraseñas" → credenciales.listar "no puedo entrar a mi cuenta" → credenciales.listar diff --git a/resources/views/livewire/chat/public-chat.blade.php b/resources/views/livewire/chat/public-chat.blade.php index cb7369c..e2c9e1a 100755 --- a/resources/views/livewire/chat/public-chat.blade.php +++ b/resources/views/livewire/chat/public-chat.blade.php @@ -191,8 +191,12 @@ } }" x-on:livewire-upload-start.window="uploading = true" - x-on:livewire-upload-finish.window="uploading = false; previewUrl = null" - x-on:livewire-upload-error.window="uploading = false; previewUrl = null"> + x-on:livewire-upload-finish.window="uploading = false" + x-on:livewire-upload-error.window="uploading = false; previewUrl = null" + {{-- Quien manda es el servidor: emite 'comprobante-listo' en todas + sus rutas de salida. Los eventos de upload de Livewire solo + cubren la subida del archivo, no el procesado posterior. --}} + x-on:comprobante-listo.window="uploading = false; previewUrl = null"> {{-- Header --}}
@@ -743,7 +747,16 @@ {{-- Botón adjuntar foto --}}