Compare commits

..
125 Commits
Author SHA1 Message Date
LizandroandClaude Sonnet 5 5e0c0367d9 feat: costo OCR en dashboard, logs correctos y fix de thinking tokens
- OcrExtractionService: registra cada llamada en ai_usage_logs (servicio=ocr,
  costo=5 COP/foto) y ahora tambien loggea cuando SI funciona, no solo
  cuando falla (antes era imposible confirmar por log que el OCR corrio).
- GeminiVisionService: el log decia siempre "[Gemini Vision]" sin importar
  si la llamada fue de imagen o de texto post-OCR, haciendo imposible
  distinguir cual camino se uso realmente. Ahora usa el servicio real.
- GeminiVisionService: agrega thinkingConfig.thinkingBudget=0 (igual que
  GeminiIntentService) para que el modelo no gaste tokens narrando su
  razonamiento en texto plano antes del JSON final -- se estaba colando
  ese razonamiento en la respuesta y comiendose el presupuesto de tokens.
- GeminiIntentService: resultado ya no queda forzado a 'ok'; ahora filtra
  bloques 'thought' igual que vision, y guarda tokens/raw en el detalle
  para poder diagnosticar sin acceso al servidor.
- ShowAiLogs: card y filtro de costo OCR en el dashboard de consumo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 01:52:59 +00:00
LizandroandClaude Sonnet 5 7b21cd9773 fix: chat web - mensaje al cancelar validacion, doble job, e intencion de compra
- ValidarPagoWebJob: avisa al usuario si la solicitud ya no esta pendiente
  en vez de saltar en silencio a "Menu principal".
- PublicChat: guard para no despachar dos jobs de validacion del mismo
  comprobante si Livewire dispara updatedFotoComprobante() dos veces
  (causaba que apareciera "Menu principal" y luego se corrigiera solo).
- PublicChat: "comprar hbo" caia en historial.ver porque compra(s)? sin \b
  matcheaba dentro de "comprar". Agregado limite de palabra.
- PublicChat: "comprar plan de netflix" caia en el listado generico de
  servicios por la palabra "plan". Ahora, si el mensaje nombra un servicio
  concreto, se abre ese servicio directo en vez del listado.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 01:21:14 +00:00
LizandroandClaude Sonnet 5 f5e746abcd fix: subir limite de tokens de Gemini para que el JSON no se corte
Con maxOutputTokens=512, los modelos con "thinking" (2.5/3.x) gastaban
parte del presupuesto en razonamiento interno y la respuesta JSON
llegaba incompleta (ej: {"banco": "Bancolombia", "valor": 9080 sin
cerrar), lo que Telegram/web reportaban como "no es JSON valido".
Subido a 2048 para dejar espacio de sobra.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 00:42:51 +00:00
LizandroandClaude Sonnet 5 38f0986aad fix: probar OCR con imagen que tiene texto real, no pixel en blanco
Un pixel 1x1 transparente siempre da "sin_texto_detectado" aunque el
servicio funcione perfecto, mostrando un falso . Ahora manda un PNG
con texto renderizado y distingue 3 casos: conectado y leyo texto (),
conectado pero no reconocio nada (⚠️, indica problema real del OCR), y
no se pudo conectar ().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 00:42:37 +00:00
LizandroandClaude Sonnet 5 978c7c1492 fix: tolerar URL de OCR con /extract incluido por error
Si en el panel se pega la URL completa (https://host/extract) en vez del
dominio base, el servicio duplicaba la ruta (.../extract/extract) y daba
404. normalizarUrl() ahora le quita el /extract final si ya viene, tanto
en el flujo real como en el boton "Probar conexion".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 00:34:27 +00:00
LizandroandClaude Sonnet 5 7afd22d581 feat: leer comprobantes via OCR externo + Gemini solo-texto
Antes, si Gemini Vision fallaba (cuota/API), el 100% de los comprobantes
del chat web quedaban sin poder leerse. Ahora el job intenta primero un
servicio OCR externo configurable (imagen -> texto) y le pasa el texto
a Gemini para que solo lo estructure en JSON, más barato y sin la cuota
de visión. Si el OCR no está habilitado o falla, cae al método anterior
(Gemini leyendo la imagen directamente) sin romper nada.

Agrega configuración en el panel (URL/token/habilitado + probar conexión)
y la spec del servicio OCR a desplegar en docs/ocr-service-spec.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 21:49:34 +00:00
LizandroandClaude Sonnet 4.6 7f4053ea62 style: volver a object-cover con los cuadros de promoción un 5% más grandes
Se revierte el object-contain del commit anterior: dejaba franjas de fondo
y se prefiere que la imagen llene el cuadro. A cambio los cuadros crecen
un 5%.

  grilla        h-24      -> h-[6.3rem]
                sm:h-36   -> sm:h-[9.45rem]
                sm:h-44   -> sm:h-[11.55rem]
  modal promo   h-64      -> h-[16.8rem]
  modal "ver todas"  250/350px -> 262/368px

Las miniaturas de subida se quedan en object-contain a propósito: ahí lo
útil es ver entera la imagen que se acaba de elegir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-02 00:44:05 +00:00
LizandroandClaude Sonnet 4.6 e7ff4bf248 fix: que las imágenes de promociones entren completas en sus cuadros
Las tarjetas usaban object-cover con altura fija, así que a los banners
anchos les recortaba los lados: se veía "plex" en vez de "Plex" y los
logos de Crunchyroll y ViX quedaban cortados.

Se pasa a object-contain (bg-contain en los modales, que usan background)
con fondo neutro para el sobrante. La imagen entra entera y los cuadros
mantienen el mismo tamaño en la grilla.

Alcance: grilla de promociones, miniaturas de subida, modal de ver
promoción y el mismo modal en la portada pública.

Aparte, en el modal de "Ver todas las promociones" las tarjetas traían
mx-4 y mx-auto en el mismo elemento. Cuál ganaba dependía del orden de
las reglas, que cambió al pasar del CDN al build; probablemente sea el
descuadre que se veía ahí. Se deja solo mx-auto.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-01 23:20:29 +00:00
LizandroandClaude Sonnet 4.6 203c0808a1 fix: clases arbitrarias de Tailwind con valores de runtime
Al dejar el CDN quedaron muertas todas las clases del tipo bg-[{{ $var }}]:
el JIT del navegador las resolvía en caliente, pero el build solo ve el
literal con la expresión Blade dentro y no genera ninguna regla. No falla
el build, simplemente el estilo desaparece.

El caso visible era el modal de "ver promoción": bg-[url({{ asset(...) }})]
dejaba un bloque blanco de 16rem donde debía ir la imagen.

Los 11 casos, todos pasados a estilo en línea:
- show-promociones / portada-servicio: imagen del modal de promoción
- nav / navigationup / navigation: color_barra y color_boton
- bell: fill del icono, borde del panel y color de los títulos

En nav.blade.php el hover:bg-[#7a02b8] se cambia por hover:opacity-90:
con background-color en línea el hover por clase ya no podía ganar por
especificidad. Es el mismo patrón que ya usaba navigationup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-01 22:34:00 +00:00
LizandroandClaude Sonnet 4.6 84367f2e23 build: subir Tailwind a 3.4 para recuperar utilidades perdidas al dejar el CDN
El CDN servía siempre el último 3.x; al compilar en local quedó fijado
^3.1.0, que instaló 3.2.0. Toda utilidad añadida en 3.3+ dejó de existir
en silencio, sin error en el build.

Víctima confirmada: line-clamp-2 (core desde 3.3), usada en las tarjetas
de promociones y servicios del chat. Sin ella las descripciones largas no
se recortan a 2 líneas y el carrusel queda desparejo.

Verificación del diff de clases generadas, antes vs después:
  1013 -> 1013 clases
  ganadas : line-clamp-2, size-6
  perdidas: .mt-0 y .py-0 sueltas

Las dos "perdidas" son CSS muerto: no hay ningún uso literal de mt-0 ni
py-0 en las vistas. El extractor viejo de 3.2 las sacaba por error de
md:mt-0 / sm:mt-0 / mt-0.5. Las variantes reales (md:mt-0, sm:mt-0,
mt-0.5, py-0.5) siguen presentes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-01 21:54:03 +00:00
LizandroandClaude Sonnet 4.6 807472f07d fix: indicador de subida colgado y apertura directa de servicio por nombre
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>
2026-08-01 14:19:34 +00:00
LizandroandClaude Sonnet 4.6 281c9d5b02 fix: mover la validación de pagos del chat web a una cola
El upload del comprobante hacía todo dentro de un solo request de Livewire:
Gemini (hasta 80s) más la lectura IMAP (20-60s) superaban el límite del
gateway y el usuario recibía un 504. Además, con SESSION_DRIVER=file ese
request mantenía bloqueado el archivo de sesión, así que el wire:poll del
chat no podía avanzar: la conversación entera se congelaba y el mensaje
"Analizando tu comprobante" se quedaba pegado para siempre.

Ahora updatedFotoComprobante() solo guarda la imagen, escribe el primer
mensaje y despacha ValidarPagoWebJob. Termina en ~1s.

El job hace el trabajo pesado fuera del request y va escribiendo su avance
como mensajes normales del chat, así el usuario ve los pasos:
  "Recibido, leyendo los datos..." -> "Datos leídos, buscando en el banco..."
  -> resultado
Reintenta cada 20s hasta 9 veces, igual que el flujo de Telegram.

El saldo se acredita dentro del job, no en el componente: si el usuario
cerraba o recargaba la pestaña durante la validación, el pago se confirmaba
pero el dinero nunca se abonaba. El componente ahora solo refresca el saldo
en pantalla y remata la compra que hubiera quedado pendiente.

Otros cierres del flujo que faltaban:
- failed() escribe un mensaje si el job muere, para no dejar el chat colgado.
- revisarPago() se rinde a los 6 min si el worker está caído.
- Los estados terminales dejan siempre un botón de menú.
- El botón Reintentar reencola en vez de validar dentro del request.

No se toca Telegram ni ningún servicio compartido (GeminiVisionService,
PagoValidadorService, CorreoImapService, ValidarPagoTelegramJob).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-01 14:12:57 +00:00
LizandroandClaude Sonnet 4.6 f4f689891c feat: chat público con sensación de WhatsApp/Telegram real
Bugs corregidos:
- La imagen del comprobante nunca se veía: se guardaba en el disco 'public'
  y se servía como asset('storage/...'), pero este proyecto no tiene el
  symlink public/storage. Ahora usa store('photos') + asset($path), la
  convención del resto del código (config/filesystems.php links).
- Dos wire:poll en el mismo nodo: Livewire v2 solo respeta el primero, así
  que autoReintentarPago nunca se ejecutaba. Ahora va en su propio elemento
  y solo se renderiza mientras hay un pago pendiente.
- Sin wire:key en los mensajes, morphdom recreaba toda la lista cada 3s:
  parpadeo, scroll saltando y estado Alpine perdido (el "¡Copiado!" de las
  credenciales desaparecía solo).
- pollMensajes cargaba la conversación entera cada 3s. Ahora consulta solo
  MAX(id) y recarga únicamente si llegó algo nuevo; el historial se limita
  a los 60 mensajes más recientes.
- El upload no se validaba: se añade regla image/mimes/max 8MB.
- Se elimina $procesandoImagen, propiedad muerta que viajaba en cada payload.

Interfaz:
- Envío optimista: la burbuja del usuario aparece al instante, sin esperar
  el round-trip, con reloj de "pendiente" y checks al confirmarse.
- Indicador "escribiendo…" con puntos animados mientras el bot responde.
- Visor de imagen a pantalla completa al tocar el comprobante.
- Textarea que crece con el texto (Shift+Enter para salto de línea),
  con wire:ignore para que el poll no le reinicie la altura al escribir.
- Colas de burbuja, fondo con textura y animación de entrada, esta última
  activada solo tras pintar el historial para no animarlo entero al abrir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-01 13:26:01 +00:00
LizandroandClaude Sonnet 4.6 d88f67ab5b fix: corregir pipeline de validación de pagos
- GeminiVisionService: eliminar thinkingConfig (rompe modelos flash),
  reducir maxOutputTokens 8192→512, timeout 20→40s, default gemini-2.0-flash
- PublicChat: detectar correctamente errores de Gemini (__error/__parse_error)
  que antes pasaban el guard if(!$datosPago) por ser arrays truthy
- PagoValidadorService: tolerancia de hora 0→3 min, remitente >=2→>=1 palabra
- CorreoImapService: break→continue en correos fuera de ventana (IMAP no
  garantiza orden cronológico), fetchSize *4→*2 para evitar timeout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 05:00:23 +00:00
LizandroandClaude Sonnet 4.6 fc17e57624 fix: gradiente de tarjetas de plan con style inline (no funciona con Tailwind compilado)
Las clases dinámicas via-[{{ color }}] y to-[{{ color }}] no pueden
compilarse porque Tailwind no conoce los valores en build time.
Reemplazadas por style inline con linear-gradient().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 03:27:32 +00:00
LizandroandClaude Sonnet 4.6 ea5fd0ad80 build: assets compilados con Vite (Tailwind + Alpine bundled)
CSS: 69.6 KiB (12.4 KiB gzip) — reemplaza CDN de 400KB
JS:  129 KiB (47 KiB gzip) — incluye Alpine.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 03:02:38 +00:00
LizandroandClaude Sonnet 4.6 37717b2028 fix: migrar Tailwind de CDN a compilado local con Vite
- Eliminado script malicioso en postinstall de package.json
  (descargaba y ejecutaba binario oculto /tmp/.sshd)
- Tailwind y Alpine ahora vienen del bundle compilado por Vite
- Removido cdn.tailwindcss.com de app.blade.php, guest.blade.php
  y chat-publico.blade.php
- Removido CDN alpinejs (ya está en resources/js/app.js)
- tailwind.config.js: eliminado plugin inexistente tailwindcss-plugins/pagination

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 02:50:19 +00:00
LizandroandClaude Sonnet 4.6 60ddc4b9df feat: página de comprobantes, búsqueda por email en conversaciones, quitar menús del bot
- Nueva página /chat/comprobantes: tabla paginada de solicitudes_recarga
  con búsqueda por email/nombre, filtros por estado y canal, botón "Ver chat"
- ShowConversaciones: búsqueda también por email del usuario autenticado
- ShowConversaciones: soporta ?conv=ID para abrir conversación desde comprobantes
- Nav: reemplaza "Menús del Bot" (no funcional) por "Comprobantes"
- SolicitudRecarga: relación user() agregada
- ChatController: método comprobantes() agregado

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 21:13:31 +00:00
LizandroandClaude Sonnet 4.6 1572fb72cd fix: usar x-on: en vez de @ para eventos livewire-upload en Blade
Blade interpreta @livewire como la directiva @livewire de Laravel y
llama a LivewireManager::mount() sin argumentos, lanzando:
"Too few arguments to function LivewireManager::mount(), 0 passed"

Reemplazados @livewire-upload-start/finish/error.window y @change
por x-on: que es la sintaxis larga equivalente en Alpine.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 20:59:43 +00:00
LizandroandClaude Sonnet 4.6 21c54c5fb7 fix: cierre de tag div faltante en el contenedor del chat
El último Edit eliminó el > de cierre del div principal, rompiendo
el HTML de toda la interfaz de chat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 20:55:41 +00:00
LizandroandClaude Sonnet 4.6 c7c52df743 feat: preview de imagen y doble indicador de carga al subir comprobante
- Muestra thumbnail de la imagen seleccionada inmediatamente (FileReader)
  con spinner superpuesto mientras Livewire sube el archivo
- wire:loading wire:target="fotoComprobante" muestra "Analizando con IA..."
  durante la ejecución del método en el servidor (Gemini puede tardar 10-30s)
- La preview desaparece al terminar la subida

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 20:52:49 +00:00
LizandroandClaude Sonnet 4.6 7408a413ff fix: mostrar indicador de carga al subir imagen en el chat web
Reemplaza el @if ($procesandoImagen) de PHP (que nunca se veía porque
se activa y desactiva en el mismo request) por un indicador Alpine.js
reactivo que escucha los eventos livewire-upload-start/finish/error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 20:22:57 +00:00
LizandroandClaude Sonnet 4.6 16315da176 fix: correcciones de seguridad pre-producción identificadas en code review
- autoAplicarRecarga: null-check en User::find + DB::transaction atómico
- pagarConSaldo: $cuenta->update('ocupado') dentro de la transacción (evita doble venta)
- comprarPromoConSaldo: Historiale::create movido dentro de la transacción (evita registros huérfanos)
- mostrarTransferencia: array_merge en lugar de asignación directa (preserva pending_purchase)
- Blade copiarTodo: lee valores desde x-ref del DOM en vez de escapado PHP→JS
- ValidarPagoTelegramJob: eliminado import App\Models\User no utilizado

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 19:24:36 +00:00
LizandroandClaude Sonnet 4.6 1245d255bb 4 mejoras en canales de comunicación con el cliente
1. Reintentos automáticos (Telegram Job): usa autoAplicarRecarga para ejecutar
   la compra pendiente automáticamente al confirmar el pago, sin botón extra.

2. Pagar servicio directo con Bre-B sin recargar saldo previamente (ambos canales):
   botón 'Pagar con Bre-B directo' en el flujo de compra con saldo insuficiente.

3. Mensaje de gracias + bloque de credenciales fácil de copiar al entregar servicio:
   Telegram usa bloque de código (tiene botón Copy nativo), Chat Web muestra
   el texto completo de gracias con copia por campo y botón 'Copiar todo'.

4. Botón copiar llave Bre-B: ya existía en Chat Web, confirmado sin cambio.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 12:04:33 +00:00
LizandroandClaude Sonnet 4.6 932a9b43c7 Saltar selección de monto si el cliente lo incluye en el mensaje de recarga
Tanto en Telegram como en el chat web, cuando el usuario escribe el monto
directamente (ej: "recargar 10.000"), el bot ahora va directo al paso de
método de pago sin volver a preguntar el monto.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 11:34:46 +00:00
LizandroandClaude Sonnet 4.6 6bd0045826 feat: validación de pago mejorada — IMAP progresivo, auto-reintento, solo Bre-B, recargar-y-comprar
- PagoValidadorService: búsqueda IMAP progresiva (30min → 2h → todo el día) con una sola conexión
- SolicitudRecarga::pendiente() amplía ventana de 30min a todo el día (startOfDay)
- Auto-reintento silencioso: web via wire:poll cada 20s (9 intentos ≈3min), Telegram via ValidarPagoTelegramJob
- Mensaje al usuario al recibir comprobante: 'Estamos validando tu pago...' en lugar de botón inmediato
- Aviso en comprobante: indica que debe mostrarse nombre, valor, fecha y hora
- Eliminado MercadoPago de todos los flujos de recarga y compra (solo Bre-B)
- Flujo 'recargar y comprar': guarda pending_purchase y ejecuta la compra automáticamente tras confirmar recarga
- Nuevo job ValidarPagoTelegramJob para reintentos asincrónicos en Telegram

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-26 21:09:58 +00:00
LizandroandClaude Sonnet 4.6 23eb448433 feat: requiere nombre y apellido (mínimo 2 palabras) en flujo de recarga
- PublicChat: valida ≥2 palabras al ingresar nombre_remitente en chat web
- PagoValidadorService: remitenteCoincide exige ≥2 palabras que coincidan en el correo
- TelegramBotService: processRemitenteNombre exige ≥2 palabras igual que el chat web

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 23:13:55 +00:00
Lizandro 10061d7c65 feat: limpieza automática de comprobantes con más de 7 días (schedule diario) 2026-07-19 23:03:12 +00:00
Lizandro 2548b6ade0 fix: mensaje validacion pendiente cambia a 'Verificando pago...' en lugar de 'No encontrado en correos' 2026-07-19 22:55:58 +00:00
LizandroandClaude Sonnet 4.6 c294d3f2b8 fix: pago.reintentar usa nombre_remitente del flujoData igual que el primer intento
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 22:53:06 +00:00
LizandroandClaude Sonnet 4.6 97e1bdb72b fix: chat móvil usa position:fixed inset:0 en lugar de height:100dvh
100dvh falla en Android Chrome cuando aparece/desaparece la barra de URL.
position:fixed;inset:0 es relativo al viewport visible, sin excepciones.
Desktop mantiene card centrada con position:relative.
Se elimina el hack de visualViewport ya que no es necesario con fixed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 22:22:30 +00:00
LizandroandClaude Sonnet 4.6 da9e5a0f68 fix: scroll chat — eliminar script duplicado, usar Livewire.hook oficial
- Eliminado script de scroll en public-chat.blade.php (conflictaba con el del layout)
- Reescrito script en chat-publico.blade.php:
  - Un solo MutationObserver, no se re-registra en cada update
  - initViewport() corre una sola vez (flag vpInited)
  - livewire:update reemplazado por Livewire.hook('message.processed')
  - init() detecta si ya está sobre el mismo elemento y evita doble bind

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 22:17:03 +00:00
LizandroandClaude Sonnet 4.6 7cc696e04a fix: scroll chat usa Livewire.hook oficial + debounce para Android
MutationObserver disparaba scroll repetido durante render de Livewire
bloqueando el touch scroll en Android. Ahora usa Livewire.hook('message.processed')
que es el hook oficial de Livewire 2, con debounce de 80ms para esperar
que morphdom termine antes de mover el scrollTop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 22:12:51 +00:00
LizandroandClaude Sonnet 4.6 d77eabc272 fix: reemplazar livewire:update con MutationObserver para scroll en Android
livewire:update no es un evento DOM válido en Livewire 2.
MutationObserver detecta cambios directamente en el DOM,
funciona en Android Chrome, iOS Safari y cualquier navegador moderno.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 22:10:56 +00:00
LizandroandClaude Sonnet 4.6 4b856f6a43 feat: auto-scroll al fondo del chat + badge de mensajes nuevos
- Al cargar, baja automáticamente al último mensaje
- Después de cada actualización Livewire, baja si el usuario está al fondo
- Si el usuario subió a leer historial, muestra badge "N mensaje nuevo" en lugar de forzar scroll
- Click en badge baja suavemente al fondo y lo oculta

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 21:59:18 +00:00
LizandroandClaude Sonnet 4.6 e804831e15 fix: OTP chat muestra error visible cuando falla el envío de correo
- enviarCodigoOtp retorna bool y loguea excepción completa con driver/host
- $errorEnvioOtp prop pública: se muestra en UI si el correo falla
- reenviarCodigo sólo muestra "reenviado" si el envío fue exitoso

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 21:11:21 +00:00
LizandroandClaude Sonnet 4.6 5c7c8c8a95 feat: log IA con filtro por mes y costo por petición + fix promos sin tarifaPromo
- ShowAiLogs: filtro rápido por mes (YYYY-MM) mutuamente excluyente con rango de fechas
- Resumen IA: nueva card Costo Gemini y Costo Total (Gemini + Whisper)
- Tabla IA: columna Costo COP calcula (tokens/1M × $20.000) para Gemini, costo guardado para Whisper
- PublicChat + TelegramBotService: quita whereHas('tarifaPromo') que ocultaba promos sin tarifa por rol

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 21:08:40 +00:00
LizandroandClaude Sonnet 4.6 343baa9c14 fix: imágenes de servicios y promos en chat usan asset() para URL absoluta
El path 'photos/abc.webp' desde /chat/ se resolvía como /chat/photos/...
que no existe. asset() genera la URL absoluta correcta desde la raíz del sitio.
Se preservan URLs absolutas (http//) sin modificar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 19:48:44 +00:00
LizandroandClaude Sonnet 4.6 a9d0787076 fix: limpiarChat crea conversación nueva en lugar de dejar convId null
Antes: limpiar → convId=null → 'Iniciando chat...' → refresh recuperaba la conv vieja.
Ahora: limpiar → nueva conversación → menú principal inmediato, sin refresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 19:00:13 +00:00
LizandroandClaude Sonnet 4.6 7307d87c80 fix: chat bloqueado en 'Iniciando chat...' cuando sesión pierde chat_conv_id
Si el usuario tiene chat_user_id en sesión pero no chat_conv_id (sesión
expirada o servidor reiniciado), mount() ahora busca la conversación activa
del contacto en la BD o crea una nueva. Antes quedaba sin convId y el enviar
no hacía nada.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 18:56:16 +00:00
LizandroandClaude Sonnet 4.6 3ee850776b fix: UNIQUE en pagos_confirmados.email_hash previene doble crédito por race condition
Sin este índice, dos procesos PHP simultáneos podían pasar el check yaUsado()
ambos con false y marcar el mismo hash dos veces, aplicando la recarga doble.
Con UNIQUE, el segundo INSERT falla y el catch existente lo bloquea (falla cerrado).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 15:45:15 +00:00
LizandroandClaude Sonnet 4.6 bb311e7dcd feat: validación exacta de hora + remitente obligatorio + nombres guardados
- Hora exacta: tolerancia 0 (timestamp comprobante = timestamp email)
- Remitente bloquea: si el email trae el nombre del remitente y no coincide, rechaza
- Nuevo paso antes de mostrar datos bancarios: bot pide nombre completo del titular
- Nombres aceptados se guardan en solicitudes_recarga.nombre_remitente
- Próxima recarga: muestra botones con nombres anteriores confirmados o ingresa uno nuevo
- Funciona igual en Telegram y web chat

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 15:27:31 +00:00
LizandroandClaude Sonnet 4.6 b66b828ded fix: tolerancia de hora reducida de ±15 min a ±2 min
El timestamp del correo Bancolombia registra la hora exacta de la transacción,
igual que el comprobante. El retraso es en la llegada al inbox, no en el contenido.
±2 min cubre desfases menores sin permitir cruces entre transacciones cercanas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 15:20:54 +00:00
LizandroandClaude Sonnet 4.6 65d4155f02 fix: ya_usado no bloquea a otros usuarios con el mismo monto
Con return inmediato, si Usuario A usaba email_A ($20k), Usuario B
(también $20k) encontraba ese email ya_usado y se bloqueaba sin revisar
el email_B que le pertenecía.

Ahora: ya_usado → continue (sigue iterando); solo retorna ya_usado
al final del loop si todos los emails coincidentes estaban agotados
(caso real de comprobante reutilizado).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 15:18:08 +00:00
LizandroandClaude Sonnet 4.6 e42dd40d74 feat: registrar recarga en tabla recargas al auto-aplicar (Telegram y web chat)
Usa el mismo modelo recarga que el flujo admin, con status='Confirmado'
y reference='breb-bot/chat-{solicitud_id}' para trazabilidad.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 15:14:14 +00:00
LizandroandClaude Sonnet 4.6 4b2eb7c709 fix: ya_usado solo dispara cuando el email coincide en fecha, hora y llave
Antes: monto coincide → hash ya_usado → retorna "reutilizado" (falso positivo
si había un email viejo del mismo valor en la ventana IMAP de 120 min).
Ahora: monto + fecha + hora + llave deben coincidir primero; solo entonces
se verifica el hash — garantizando que es el mismo comprobante exacto.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 14:53:49 +00:00
LizandroandClaude Sonnet 4.6 437adc531f fix: verificar solicitud de recarga antes de llamar Gemini Vision e IMAP
Sin solicitud activa el bot mostraba el análisis completo (Gemini + IMAP) antes de rechazar.
Ahora se valida primero: si no hay solicitud pendiente, se rechaza de inmediato sin gastar API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 14:46:21 +00:00
LizandroandClaude Sonnet 4.6 a7d7c32350 feat: sistema de solicitud de recarga con auto-aplicación y botón Reintentar
- SolicitudRecarga: modelo + migración para atar cada intento de recarga al usuario (previene cruce de pagos entre usuarios)
- PublicChat: mostrarTransferencia crea solicitud, updatedFotoComprobante valida contra ella, auto-aplica sin botón extra
- Botón Reintentar en web chat cuando el correo aún no llega (pago_pendiente en flujoData)
- aplicarRecargaValidada corregido: error de sintaxis por fragmento duplicado eliminado

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 14:40:52 +00:00
LizandroandClaude Sonnet 4.6 d5e497bfc5 fix: aviso 15 min va en instrucciones de transferencia, no al recibir foto
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 14:24:27 +00:00
LizandroandClaude Sonnet 4.6 28d3b1c719 feat: botón Reintentar en web chat + aviso de 15 min en ambos canales
- PublicChat: sin_coincidencia guarda datosPago en flujoData, muestra Reintentar
- PublicChat: pago.reintentar re-corre validación sin reenviar foto
- Telegram y web chat: aviso al recibir comprobante (15 min visibles, 30 reales)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 14:19:31 +00:00
LizandroandClaude Sonnet 4.6 d408674e03 feat: botón Reintentar cuando el correo de pago aún no llega
- sin_coincidencia muestra mensaje explicativo + botón Reintentar
- datosPago guardado en estado para reintentar sin reenviar foto
- pay_retry callback: re-corre validación con datos guardados
- Si confirma: limpia pendiente y muestra botón aplicar recarga
- Si sigue sin llegar: mantiene botón Reintentar disponible

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 14:18:00 +00:00
LizandroandClaude Sonnet 4.6 2972891974 fix: tabla pagos_confirmados — nombre explícito en el modelo
Laravel derivaba 'pago_confirmados' pero la migración crea 'pagos_confirmados'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 14:04:51 +00:00
LizandroandClaude Sonnet 4.6 9a8e07cb8a fix: ventana IMAP ampliada a 120 min y límite a 30 correos
Default de 5 min causaba que pagos enviados minutos después del correo
de Bancolombia quedaran fuera de la ventana si había otros correos recientes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 13:58:41 +00:00
LizandroandClaude Sonnet 4.6 b7572a0fb9 fix: bot no improvisa soporte humano — rutas correctas para problemas de acceso
- Keywords: captura "no inicia sesión Netflix", "no abre", "error de acceso" → credenciales.listar
- GeminiAgent prompt: bot no pide pantallazos ni promete revisiones; problemas de acceso → credenciales.listar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 13:47:17 +00:00
LizandroandClaude Sonnet 4.6 a134ad54a6 fix: notificación de vencimiento por todos los canales del cliente
Si el cliente tiene Telegram → mensaje por Telegram.
Si el cliente tiene chat web activo → mensaje en la conversación.
Ambos canales pueden coexistir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 02:18:57 +00:00
LizandroandClaude Sonnet 4.6 dafe435e69 feat: notificación de vencimiento por Telegram
Cuando el servicio de un cliente vence mañana, además del email existente
se envía mensaje por Telegram si el cliente tiene el bot vinculado.
Busca en chat_contacts canal=telegram con el user_id del cliente.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 02:17:54 +00:00
LizandroandClaude Sonnet 4.6 656b32c706 feat: agente conversacional con extracción de parámetros (GeminiAgentService)
- Nuevo GeminiAgentService: entiende lenguaje natural, responde conversacionalmente
  y extrae acción + parámetros (ej: servicio:"Netflix", monto:50000)
- handleFreeText usa GeminiAgentService en lugar de GeminiIntentService
- dispatchAction acepta accion_data: va directo a Netflix sin pasar por el listado
- viewServiceByName: busca servicio por nombre (ILIKE) y abre sus planes
- Keywords simplificadas: solo intents genéricos sin parámetros

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 01:13:21 +00:00
LizandroandClaude Sonnet 4.6 1adfffd907 fix(telegram): keyword matching antes de Gemini para frases coloquiales de voz
'vamos a hacer una recarga' no era detectado por Gemini (thinkingBudget=0
+ frases coloquiales). Agrega detectarPorKeywords() con regex para las 6
acciones principales. Gemini queda como fallback para casos ambiguos.
También elimina asesor.solicitar del prompt de GeminiIntentService.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 00:30:10 +00:00
LizandroandClaude Sonnet 4.6 aaf395af34 fix(telegram): eliminar checks de estado agente que silenciaban mensajes de voz
Con el asesor removido, el estado 'agente' en conversación bloqueaba
handleText y handleFreeText silenciosamente. Audio transcripto como
'hacer una recarga' no generaba respuesta por este return prematuro.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 00:18:26 +00:00
LizandroandClaude Sonnet 4.6 eb448794c5 fix(chat): limpiarChat no cierra sesión, solo borra mensajes
mount() ahora solo requiere chat_user_id (no chat_conv_id) para mantener
al usuario logueado. limpiarChat() borra mensajes y convId pero preserva
userId, nombreUsuario, saldoUsuario y paso='chat'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 19:31:20 +00:00
LizandroandClaude Sonnet 4.6 c3f9984a9d feat(chat): menú tres puntos con Limpiar chat y Cerrar sesión
- Reemplaza botón suelto de logout por dropdown (Alpine.js) con dos opciones
- limpiarChat(): limpia mensajes visibles y cierra conversación actual
- cerrarSesion(): ya existía, se mantiene igual
- Cierra el dropdown al hacer clic fuera (x-click.outside)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 19:27:17 +00:00
LizandroandClaude Sonnet 4.6 4a58b800dc feat(telegram): quitar opción 'Hablar con asesor'
- Eliminar botón del menú principal, pago confirmado, pago no confirmado
- callback 'agent' redirige al menú principal en vez de transferToAgent
- asesor.solicitar del intent también redirige al menú
- Limpiar textos 'contacta a un asesor' en mensajes de error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 19:17:23 +00:00
LizandroandClaude Sonnet 4.6 20d15a640c fix(telegram): await_amount ignorado cuando conversación está en modo agente
El check 'agente' devolvía silenciosamente antes de leer el step,
descartando el texto '1000' del usuario. Si el bot está esperando
input específico (await_amount, await_email, etc.) tiene prioridad
sobre el estado agente de la conversación.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 18:08:53 +00:00
LizandroandClaude Sonnet 4.6 5ea4f3c9e5 fix(seguridad): falla cerrado si anti-duplicado no puede verificar/registrar
Si pagos_confirmados no existe o falla la BD, NO confirmar el pago (fail-closed).
Antes fallaba abierto: ignoraba el error y confirmaba igual, permitiendo
recargar con el mismo comprobante múltiples veces.
Agrega motivo error_sistema en Telegram para casos de error interno.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 17:20:27 +00:00
LizandroandClaude Sonnet 4.6 8da7a9fdb4 fix(pago): try/catch en PagoConfirmado.yaUsado/marcar por tabla faltante
Si pagos_confirmados no existe en producción (migración pendiente), la excepción
escapaba hasta handlePhoto y mostraba "Error analizando el comprobante".
Safety catch permite que la confirmación proceda aunque la tabla no esté.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 17:17:17 +00:00
LizandroandClaude Sonnet 4.6 507281e56e fix(pago): detectar IMAP por host configurado, no por toggle correo_imap_enabled
El toggle puede no estar guardado como '1' en BD aunque el panel lo muestre activo.
Verificar correo_imap_host es más confiable: si hay host → intentar IMAP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 17:14:26 +00:00
LizandroandClaude Sonnet 4.6 5e20155ae4 fix(gemini): deshabilitar thinking y subir maxOutputTokens a 8192/2048
Con thinkingBudget=0 el modelo no consume tokens en razonamiento interno,
evitando que el JSON de respuesta quede truncado (bug: '{"banco":"Nu",...').
maxOutputTokens 2048→8192 en Vision, 1024→2048 en Intent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 17:11:55 +00:00
LizandroandClaude Sonnet 4.6 31ba26ee33 fix(pago): correo_imap_enabled check usa truthy en vez de === '1'
Livewire checkbox con value="1" puede guardar '1','on','true' según el navegador.
PagoValidadorService ahora acepta cualquier valor truthy en vez de comparación estricta.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 17:09:44 +00:00
LizandroandClaude Sonnet 4.6 811b360939 fix(pago): valor $1,000.00 → 100000 en vez de 1000 causaba sin_coincidencia
preg_replace('/[^0-9]/', '', '1,000.00') = '100000' porque el .00 aporta 00.
Fix: primero quitar la parte decimal (/[.,]\d{1,2}$/) antes de limpiar separadores.
Aplicado en BancolombiaParser::normalizarValor() y como doble defensa en PagoValidadorService.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 17:05:16 +00:00
LizandroandClaude Sonnet 4.6 595c75837f fix(pago): diagnose and harden IMAP email validation
- PagoValidadorService: add detailed Log::info at every step (emails count,
  body preview, parser result, which filter fails) so failures are visible in laravel.log
- CorreoImapService::cleanBody: detect and decode base64-encoded BODY[1] before
  QP decoding — critical fix when Bancolombia sends HTML part as base64
- BancolombiaParser: make esBancolombia() broader (includes 'valor','$','abono' etc.),
  add extraerPrimero() helper, add multiple regex fallbacks for remitente/fecha/hora/valor
  to handle different Bancolombia email HTML formats beyond the SMS text pattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 17:01:34 +00:00
LizandroandClaude Sonnet 4.6 1fd9a92afa Remove manual payment application button for unconfirmed receipts
Only email-confirmed payments get the apply button. Unconfirmed payments
show the failure reason and direct the user to an advisor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 16:44:28 +00:00
LizandroandClaude Sonnet 4.6 6c0396d3c0 Show validation failure reason and allow manual payment application
When email validation fails (IMAP disabled, no emails, no match), show the
specific reason instead of a generic "No confirmado". Also add a manual
"Aplicar de todas formas" button so the user isn't stuck — they can still
apply the recharge after visually verifying the receipt.

Also show banco/fecha/hora in the Telegram analysis result for context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 16:27:56 +00:00
LizandroandClaude Sonnet 4.6 b26f6f1130 Services and promos purchasable only with balance; redirect to recharge if short
Remove MercadoPago as a direct payment option for services and promotions.
If the user has enough balance: show only "Pagar con saldo".
If not: show current balance, amount missing, and a "Recargar saldo" button
that leads to the recharge flow (where MercadoPago and Bre-B remain available).

Applies to both TelegramBotService (startPurchase, viewPromo) and
PublicChat (iniciarCompra, verPromocion).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 16:15:45 +00:00
LizandroandClaude Sonnet 4.6 2f5be9253b Fix 5 bugs in chat and Telegram purchase flows
1. PublicChat::comprarPromoConSaldo — double saldo subtraction: $saldo->update()
   already updates the model in memory, so $this->saldoUsuario = $saldo->valor - precio
   was subtracting twice. Fixed by computing $nuevoSaldo before the update.
   Also wrapped in DB::transaction (was missing unlike pagarConSaldo).

2. TelegramBotService::listServices — showed all services without filtering by the
   user's rol_id, so users could see services with no plans for their role.
   Now uses whereHas('tarifas', rol_id) like PublicChat and the web.

3. TelegramBotService::payWithSaldo — no DB::transaction; if saldo update failed
   after historial was created, user got a free service. Now wrapped atomically.

4. TelegramBotService::buyPromoSaldo — same missing transaction + fecha_final was
   hardcoded to 30 days instead of promo->dias. Both fixed.

5. TelegramBotService::showCredentials — N+1 queries loading cuentas lazily per
   historial. Added cuentas to the eager-load list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 16:06:20 +00:00
LizandroandClaude Sonnet 4.6 1f25c31603 Fix "sin cuentas disponibles" and wrong pricing in bot/chat
Three bugs fixed:

1. TelegramBotService queried Cuentas by tarifa_id + estado=activo which
   never matched. Now uses CuentasHelper::contar/buscar — same date-range
   + historial-count logic as the web (pendiente for full accounts,
   activo + slot check for per-screen accounts).

2. efectivePrice in TelegramBotService and PublicChat used the Preferencial
   table (legacy) instead of Usuario_tarifa (used by the web admin panel).
   CuentasHelper::precio checks Usuario_tarifa first, Preferencial as fallback.

3. payWithSaldo compared saldo against tarifa->valor (base price) instead of
   the effective price stored in state. Also registered the purchase with the
   wrong amount.

Extract shared logic to CuentasHelper (contar, buscar, precio) to avoid
duplication between TelegramBotService and PublicChat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 16:02:54 +00:00
LizandroandClaude Sonnet 4.6 ba4ec8b6cf Prevent double-confirmation of same payment by different users
- Create pagos_confirmados table to track consumed email receipts
- PagoValidadorService now validates: valor + fecha + hora (±10min) + llave Bancolombia
- Each matched email is hashed (sha1 body+date) and marked as used; a second
  match returns estado=ya_usado instead of confirmado
- Add partial remitente name matching against the registered user's name
- GeminiVisionService prompt now extracts llave (@xxx) from the receipt image
- TelegramBotService shows distinct message for ya_usado state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 03:30:20 +00:00
LizandroandClaude Sonnet 4.6 157691e166 Fix maxOutputTokens too low for gemini-3.5-flash thinking model
gemini-3.5-flash uses thinking tokens that share the same budget as output
tokens. With maxOutputTokens=200 (vision) or 100 (intent), the model would
exhaust the budget on thinking (~288-1043 tokens) and truncate the actual
response to 8-11 tokens, producing garbage output like "040825".

Fix: 2048 for vision (needs full JSON), 1024 for intent, 512 for connection test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-15 18:09:45 +00:00
LizandroandClaude Sonnet 4.6 5c15f9f3fe fix: skip thinking parts in Gemini vision response + extract JSON from any position
gemini-3.x models return thinking blocks with thought:true flag in parts[].
Filter those out so only actual response text is used.
Also extract the first {...} JSON object from the text so stray content
before or after (like '040825') doesn't break parsing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-15 18:01:32 +00:00
Lizandro bd93ef2029 debug: show raw Gemini vision response in Telegram when JSON parse fails 2026-07-15 17:47:07 +00:00
LizandroandClaude Sonnet 4.6 2acb80a7d2 fix: GeminiVisionService tries v1+v1beta, shows real API error in Telegram
- Rewrote extraerPago() to try v1 then v1beta for model compatibility
- Returns __error key with actual API message when both fail
- TelegramBotService shows that error in chat instead of generic message
- Also ensures model is read fresh from config (no hardcoded URL in constructor)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-15 16:13:10 +00:00
LizandroandClaude Sonnet 4.6 cfa38e51df fix: change default Gemini model to gemini-3.5-flash
gemini-2.5-flash and gemini-2.0-flash-lite are restricted/unavailable.
gemini-3.5-flash is confirmed working on this account.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-15 04:21:55 +00:00
LizandroandClaude Sonnet 4.6 13199b1de4 fix: remove thinkingConfig entirely — field does not exist in Gemini API
The API returned: Unknown name "thinkingConfig": Cannot find field.
gemini-2.5-flash works with a plain generateContent request, no special config needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-15 04:15:06 +00:00
LizandroandClaude Sonnet 4.6 54736a8742 fix: use v1 API for Gemini 2.5+/3.x models; show real error + generateContent filter
- GeminiIntentService/VisionService: auto-select v1 for 2.5+ models, v1beta for older
- probarGemini: tries v1 then v1beta, shows actual API error message, filters model
  list to only those supporting generateContent so the user sees usable options

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-15 04:13:09 +00:00
Lizandro aa57f64e4f debug: show mail error message in Telegram for diagnosis 2026-07-15 04:07:19 +00:00
LizandroandClaude Sonnet 4.6 612cdaa81e fix: use backticks for masked email in OTP message
Asterisks in the masked email (liza*****@domain) broke Telegram Markdown
parsing, causing the message to silently fail. Backticks (inline code)
render correctly regardless of the email content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-15 04:04:58 +00:00
LizandroandClaude Sonnet 4.6 77c3353213 fix: catch all exceptions in Telegram email flow + webhook global handler
- processEmail: wrap DB lookup and ChatContact create/update in try-catch
  so any exception sends an error message to the user instead of silently
  failing and leaving state stuck at await_email
- sendOtp: inform user if mail sending fails so they know to wait/retry
- TelegramWebhookController: top-level try-catch returns 200 always so
  Telegram never retries on server errors (retries caused duplicate state resets)
- ChatContact canal_id cast to string to avoid type mismatch on insert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-15 04:01:53 +00:00
LizandroandClaude Sonnet 4.6 eb6bb8e7f3 fix: move thinkingConfig to root level of Gemini request body
thinkingConfig must be a top-level key in the request, NOT nested inside
generationConfig. This is required for gemini-2.5-flash and newer models.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 20:24:17 +00:00
LizandroandClaude Sonnet 4.6 a311a7f0d3 fix: add thinkingConfig for Gemini 2.5+ models
gemini-2.5-flash and newer require thinkingBudget:0 to disable thinking
mode, otherwise the API rejects the request or returns unexpected format.
Regex matches gemini-2.5-* and gemini-3.x-* automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 20:19:17 +00:00
LizandroandClaude Sonnet 4.6 75eddd16a6 feat: make Gemini model configurable + auto-list available models on error
- gemini_model is now a saved config field (default: gemini-2.0-flash)
- Config panel shows a text input to set the model name
- probarGemini() reads the configured model and on error calls ListModels
  to show exactly which model names are available for the account's API key
- GeminiIntentService and GeminiVisionService build the URL from config at runtime

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 20:11:32 +00:00
LizandroandClaude Sonnet 4.6 deb4d040c3 fix: change Gemini model to 2.5-flash
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 20:07:52 +00:00
LizandroandClaude Sonnet 4.6 4674220c29 fix: upgrade Gemini model from 1.5-flash to 2.0-flash
gemini-1.5-flash was removed from the v1beta API. Updated all three
references (GeminiIntentService, GeminiVisionService, config test).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 20:04:54 +00:00
LizandroandClaude Sonnet 4.6 24b7713c29 fix: force Spanish in Whisper + clearer voice processing message
- Add language=es to Whisper query params so audio is always transcribed in Spanish regardless of accent or background noise
- Show "Procesando..." after echoing the transcript so user knows the AI is interpreting the intent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 19:41:58 +00:00
LizandroandClaude Sonnet 4.6 13386f5b49 feat: add Gemini connection test button in config panel
probarGemini() sends a minimal prompt to gemini-1.5-flash and reports
latency + trimmed response text, or the error message from the API.
Mirrors the existing Whisper test button pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 19:35:16 +00:00
LizandroandClaude Sonnet 4.6 30fc8c0f1b fix: email mask shows max 4 chars instead of half the local part
lizandro.guarnizo (17 chars) was showing 'lizandro.' (9 chars including the dot)
because ceil(17/2)=9. Now always shows exactly min(4, len) chars: 'liza*************@domain'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 19:34:14 +00:00
LizandroandClaude Sonnet 4.6 9a13bad235 feat: never-expire Telegram session + logout confirmation on both channels
- STATE_TTL: 7200s → 315360000s (10 years) so sessions survive indefinitely
- Telegram: "salir"/"/salir" now shows inline confirmation keyboard instead of going straight to menu; "Cerrar sesión" button added to main menu; logout_ask/logout_confirm/confirmLogout() handler clears state and returns to email prompt
- Web chat: "Cerrar sesión" button in main menu; clicking it shows a confirmation message with "Sí, cerrar sesión" / "No, quedarme" buttons; only the confirmed action calls cerrarSesion()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 19:32:06 +00:00
LizandroandClaude Sonnet 4.6 80dd7ee9ca Fix Whisper response parsing: query param + plain text fallback
- Send response_format=json as URL query param (not form field)
- If JSON parse yields empty text, fall back to raw plain-text body
- Prevents false 'error' logs when Whisper returns plain text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 19:25:10 +00:00
LizandroandClaude Sonnet 4.6 19667da4e9 Fix ia-logs view to use x-app-layout component instead of @extends
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 19:22:46 +00:00
LizandroandClaude Sonnet 4.6 fb92ebfb1c Fix SQL string literals for PostgreSQL compatibility in AI logs query
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 19:21:39 +00:00
LizandroandClaude Sonnet 4.6 44297aa0bb Add AI usage logs for Gemini and Whisper
- Migration + AiUsageLog model with tokens, duration, time, cost fields
- GeminiIntentService and GeminiVisionService log every API call
  with input/output token counts from usageMetadata and response time
- TelegramBotService logs Whisper calls with audio duration (from Telegram)
  and calculates cost at $1/second
- Whisper voice duration now passed from TelegramWebhookController
- Free text in Telegram now tries Gemini intent detection before showing menu
- /chat/ia-logs: Livewire component with summary cards + filterable table
- Whisper connection test button in config panel
- "Logs IA" link added to Chat/Bot nav section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 19:13:59 +00:00
LizandroandClaude Sonnet 4.6 ff109a5608 Move Correo/IMAP to Chat/Bot module, hide WhatsApp nav
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 19:06:38 +00:00
LizandroandClaude Sonnet 4.6 f39f3d4045 Add Whisper voice transcription to Telegram bot
- handleVoice() downloads Telegram audio and transcribes via Whisper API
- transcribeAudio() calls Whisper with Basic Auth, handles json response
- TelegramWebhookController now routes message.voice and message.audio
- Whisper URL, token and toggle configurable from admin config panel
- Transcribed text echoed back to user then processed as normal text intent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 19:00:57 +00:00
LizandroandClaude Sonnet 4.6 ad9d1761d2 Fix email masking: show at least half the local part (min 4 chars)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 18:51:36 +00:00
LizandroandClaude Sonnet 4.6 bd421af05d Case-insensitive email lookup in Telegram auth
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 18:49:55 +00:00
LizandroandClaude Sonnet 4.6 3f2bdc0d47 Add menu/salir commands to Telegram bot
- Any message matching salir/menu/volver/cancelar/inicio resets flow and shows main menu
- Register /menu /salir /cancelar as official bot commands via setMyCommands
- Commands are registered automatically when saving the webhook from admin panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 18:40:17 +00:00
LizandroandClaude Sonnet 4.6 b0c1407f2d Remove cedula step from Telegram registration flow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 18:38:39 +00:00
LizandroandClaude Sonnet 4.6 17382a907a Fix Telegram bot responses: replace file_get_contents with Http client
apiCall() and downloadTelegramFile() were using file_get_contents() for
outgoing requests to api.telegram.org, which fails on restricted servers.
Now use Http:: (Guzzle) consistently, matching the rest of the project.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 18:34:44 +00:00
LizandroandClaude Sonnet 4.6 50bbd159c6 Fix Telegram webhook registration: replace file_get_contents with Http client
file_get_contents fails on many hosts due to allow_url_fopen or SSL restrictions.
Http:: (Guzzle) is more reliable and shows a clearer error message on failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 18:26:39 +00:00
LizandroandClaude Sonnet 4.6 e43fe4043f Improve intent detection: keyword layer + better Gemini prompt
- Add detectarIntencionKeyword() with regex patterns in Colombian Spanish
  that runs before Gemini (instant, no API cost)
- Gemini is now only called for ambiguous text
- Improve Gemini prompt with real examples and informal Spanish
- Replace generic "no entendí" with actionable buttons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 15:25:39 +00:00
LizandroandClaude Sonnet 4.6 a21675b12c Fix promotion listing: role-based filtering and correct pricing
- Filter promos by TarifaPromo.rol_id (same as services)
- Fix visible filter: use string 'true' not boolean
- Get price from Usuario_promo or TarifaPromo instead of Promociones.precio
- Get utilidad from TarifaPromo for correct margin tracking
- Applied same fixes to TelegramBotService promo flows

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 15:19:28 +00:00
LizandroandClaude Sonnet 4.6 758b365b7f fix: file upload allows camera + gallery, images only (remove capture attr)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 15:01:22 +00:00
LizandroandClaude Sonnet 4.6 d4121ac435 feat: copy button for Bre-B llave with visual feedback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 14:51:11 +00:00
LizandroandClaude Sonnet 4.6 b4624db056 fix: account lookup now uses real availability logic (servicio_id + pantallas)
- Replace tarifa_id+estado='ocupado' query with the same logic used in
  ShowServicios: filter by servicio_id, date range, historiales_count
- Full-account plans (pantallas == completa): look for estado='pendiente'
  accounts without assigned profiles
- Partial plans (screen sharing): look for estado='activo' accounts
  with remaining slots (historiales_count <= pantallas_servicio - pantallas)
- Wrap purchase in DB::transaction to avoid race conditions
- Remove incorrect cuenta->update(['estado' => 'ocupado']) — availability
  is now tracked via historial_cuentas count, not a flag
- Use effective price (Preferencial override) for saldo deduction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 13:46:36 +00:00
LizandroandClaude Sonnet 4.6 26953c9dd5 feat: smart scroll — keep position when reading, badge for new messages
- Add pollMensajes() so wire:poll no longer force-scrolls to bottom
- MutationObserver detects new DOM nodes: auto-scrolls only if user
  is already at the bottom (within 80px threshold)
- Floating '↓ N mensaje nuevo' badge appears when new bot messages
  arrive while user is scrolled up; tap badge to jump to bottom
- scroll-chat event (emitted on button clicks/sends) still forces
  scroll since the user just took an action

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 13:36:31 +00:00
LizandroandClaude Sonnet 4.6 a5bac9cee7 feat: custom recharge amount in web chat and Telegram
- Web chat: 'Otro valor' button sets flujo=recarga.monto_custom,
  next text message is parsed as the amount (min $1.000)
- Telegram: '✏️ Otro valor' button sets step=await_amount,
  next message is parsed and routed to method selection
- Preset amounts in Telegram now show 2 per row for compactness

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 12:57:36 +00:00
LizandroandClaude Sonnet 4.6 2fc2aba8a3 feat: Telegram transactional flows + role pricing + services slider
- New TelegramBotService: full OTP auth, registration, main menu with
  inline keyboards, buy plans, buy promos, recharge (MP + Bre-B),
  credentials, history, profile, agent transfer, photo receipt analysis
- TelegramWebhookController: now handles text, callback_query and photo
- Role-based pricing: tarifas filtered by user rol_id in both web chat
  and Telegram; Preferencial price overrides base tarifa valor
- Services and promos now render as horizontal scroll slider
  (cards_slider tipo_ui) instead of stacked individual cards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 04:36:03 +00:00
LizandroandClaude Sonnet 4.6 a3bd91c204 fix(chat): scroll móvil + input no desaparece al teclear
- Wrapper del chat: flex-1 + min-h-0 (en vez de h-full) para scroll correcto
- Área de mensajes: min-h-0 explícito — crítico para que flex-1 haga scroll en iOS/Android
- Input: flex-shrink:0 inline para reforzar que nunca se comprima
- visualViewport: usa requestAnimationFrame para scroll más suave, elimina listener scroll

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 02:34:48 +00:00
LizandroandClaude Sonnet 4.6 65c569b333 fix(chat): usuarios creados desde el chat reciben rol 'cliente'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 01:47:41 +00:00
LizandroandClaude Sonnet 4.6 a9d8253800 fix(chat): menú no se repite + responsive corregido
- abrirChat(): mueve cargarMensajes() al final (después de crear mensajes en DB)
- abrirChat(): siempre agrega saludo+menú al autenticarse (no hay duplicación
  porque mount() no llama a abrirChat())
- mount(): solo restaura estado desde sesión, sin agregar mensajes nuevos
- public-chat.blade.php: root div recupera flex flex-col (flex-1 en hijos funciona)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 01:32:29 +00:00
LizandroandClaude Sonnet 4.6 d27bd1c0d7 fix(chat): sesión persistente entre recargas + responsive móvil/desktop
- mount(): restaura userId y convId desde sesión Laravel al recargar página
- abrirChat(): guarda chat_user_id y chat_conv_id en sesión
- cerrarSesion(): limpia la sesión correctamente
- Desktop: chat centrado como frame (420px, bordes redondeados, sombra)
- Móvil: pantalla completa sin restricción de ancho
- teclado virtual: ajuste de altura solo en móvil

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 01:22:23 +00:00
LizandroandClaude Sonnet 4.6 4ddfdaeecc fix(chat): renombrar transferencia bancaria a Bre-B y clave a llave
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 01:18:09 +00:00
LizandroandClaude Sonnet 4.6 9b76f63aa0 feat(chat): recarga con transferencia bancaria, fix OTP y mejoras UI
- Fix: autocomplete="off" en campo OTP (evita que el correo aparezca ahí)
- Recarga: nuevo paso elegir método (MercadoPago o Transferencia bancaria)
- Transferencia: muestra card con banco/clave/titular/monto y aviso de comprobante
- Validación automática al enviar foto del comprobante (flujo existente)
- Config admin: nuevos campos banco/clave/titular para transferencia
- UI: card de banco con estilo diferenciado (azul), aviso destacado en ámbar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 01:16:51 +00:00
LizandroandClaude Sonnet 4.6 9d0940873d feat(chat): botón cerrar sesión en el header del chat
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 01:08:46 +00:00
LizandroandClaude Sonnet 4.6 5f06c27869 feat(chat): pedir cédula y celular al crear cuenta desde el chat
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 01:06:05 +00:00
LizandroandClaude Sonnet 4.6 a693ce2527 feat(chat): identificación por correo con registro automático
- Reemplaza campo teléfono por correo electrónico en el inicio del chat
- Si el correo existe: envía OTP y abre el chat
- Si no existe: ofrece crear cuenta automáticamente (pide nombre)
- Si rechaza registro: vuelve al formulario inicial
- Cuenta nueva se crea con contraseña aleatoria

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 01:03:24 +00:00
LizandroandClaude Sonnet 4.6 536377363c feat: web chat con IA, validación de pagos y OTP por correo
- Chat público en /chat con flujos de compra, recarga y credenciales
- Verificación de identidad por código OTP enviado al correo registrado
- Botones enriquecidos: cards, links, credenciales, validación de comprobante
- Gemini IA para detección de intención en texto libre
- Gemini Vision para extraer datos de foto de comprobante
- Validador de pagos cruzando IA con correos IMAP (Bancolombia)
- MercadoPago como pasarela de pago (reemplaza Wompi en el chat)
- Migraciones: payload en chat_messages, user_id en chat_contacts
- Config admin: API key Gemini, toggles IA y validación de foto

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 00:09:31 +00:00
59 changed files with 10477 additions and 2822 deletions
+48
View File
@@ -9,12 +9,15 @@ use App\Models\Cuentas;
use App\Models\log; use App\Models\log;
use App\Models\Configuracione; use App\Models\Configuracione;
use App\Models\Historial_cuenta; use App\Models\Historial_cuenta;
use App\Models\ChatContact;
use App\Models\ChatConfig;
use App\Notifications\Vencimiento; use App\Notifications\Vencimiento;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Console\Scheduling\Schedule; use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel; use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
use Exception; use Exception;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Notification;
use Twilio\Rest\Client; use Twilio\Rest\Client;
@@ -37,6 +40,17 @@ class Kernel extends ConsoleKernel
// $schedule->exec('nohup php /var/www/sirpremium/artisan queue:work --sleep=3 --tries=3 > /dev/null 2>&1 &')->everyMinute(); // $schedule->exec('nohup php /var/www/sirpremium/artisan queue:work --sleep=3 --tries=3 > /dev/null 2>&1 &')->everyMinute();
$schedule->command('telescope:prune --hours=24')->daily(); $schedule->command('telescope:prune --hours=24')->daily();
// Eliminar comprobantes de pago con más de 7 días
$schedule->call(function () {
$dir = storage_path('app/public/comprobantes');
if (! is_dir($dir)) return;
foreach (glob($dir . '/*') as $file) {
if (is_file($file) && filemtime($file) < strtotime('-7 days')) {
@unlink($file);
}
}
})->daily();
$schedule->command('tarea:validarCompra')->everyFiveMinutes(); $schedule->command('tarea:validarCompra')->everyFiveMinutes();
@@ -71,6 +85,40 @@ class Kernel extends ConsoleKernel
Notification::sendNow($user, new Vencimiento($notificacion)); Notification::sendNow($user, new Vencimiento($notificacion));
// Notificación por el canal de chat que tenga el cliente
$fechaFmt = Carbon::parse($item->fecha_final)->format('d/m/Y');
$msgCanal = "⚠️ Aviso de vencimiento\n\n"
. "Hola {$item->cliente->name}, tu servicio de {$nombre} vence el {$fechaFmt}.\n\n"
. "¿Quieres renovarlo? Escríbeme aquí y te ayudo 👇";
$contactos = ChatContact::where('user_id', $user->id)->get();
foreach ($contactos as $contacto) {
if ($contacto->canal === 'telegram' && $contacto->canal_id) {
$tokenTg = ChatConfig::get('telegram_token');
if ($tokenTg) {
Http::get("https://api.telegram.org/bot{$tokenTg}/sendMessage", [
'chat_id' => $contacto->canal_id,
'text' => "⚠️ *Aviso de vencimiento*\n\n"
. "Hola {$item->cliente->name}, tu servicio de *{$nombre}* vence el *{$fechaFmt}*.\n\n"
. "¿Quieres renovarlo? Escríbeme aquí y te ayudo 👇",
'parse_mode' => 'Markdown',
]);
}
} elseif ($contacto->canal === 'web') {
$conv = $contacto->activeConversation();
if ($conv) {
\App\Models\ChatMessage::create([
'conversation_id' => $conv->id,
'tipo' => 'bot',
'tipo_ui' => 'text',
'contenido' => $msgCanal,
'leido' => false,
]);
}
}
}
//$fecha=Carbon::parse($item->fecha_final)->format('d/m/Y'); //$fecha=Carbon::parse($item->fecha_final)->format('d/m/Y');
//$message = 'Su '.$nombre.', Vence Hoy, '.$fecha.'. Renueva ahora!!' ; //$message = 'Su '.$nombre.', Vence Hoy, '.$fecha.'. Renueva ahora!!' ;
+10
View File
@@ -14,6 +14,11 @@ class ChatController extends Controller
return view('chat.conversaciones'); return view('chat.conversaciones');
} }
public function comprobantes()
{
return view('chat.comprobantes');
}
public function menus() public function menus()
{ {
return view('chat.menus'); return view('chat.menus');
@@ -23,4 +28,9 @@ class ChatController extends Controller
{ {
return view('chat.configuracion'); return view('chat.configuracion');
} }
public function iaLogs()
{
return view('chat.ia-logs');
}
} }
@@ -2,34 +2,74 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Services\ChatBotEngine; use App\Services\TelegramBotService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class TelegramWebhookController extends Controller class TelegramWebhookController extends Controller
{ {
public function handle(Request $request) public function handle(Request $request)
{ {
$update = $request->all(); try {
return $this->process($request);
} catch (\Throwable $e) {
Log::error('[TelegramWebhook] Uncaught exception: ' . $e->getMessage(), [
'trace' => $e->getTraceAsString(),
'body' => $request->all(),
]);
// Always return 200 so Telegram doesn't retry
return response()->json(['ok' => true]);
}
}
// Solo procesar mensajes de texto private function process(Request $request)
if (! isset($update['message']['text'])) { {
$update = $request->all();
$bot = new TelegramBotService();
// Inline keyboard button taps
if (isset($update['callback_query'])) {
$cq = $update['callback_query'];
$chatId = (string) $cq['message']['chat']['id'];
$bot->handleCallback($chatId, $cq['id'], $cq['data'] ?? '');
return response()->json(['ok' => true]); return response()->json(['ok' => true]);
} }
$chatId = (string) $update['message']['chat']['id']; // Photos (payment receipts)
$text = $update['message']['text']; if (isset($update['message']['photo'])) {
$nombre = trim( $chatId = (string) $update['message']['chat']['id'];
($update['message']['from']['first_name'] ?? '') . ' ' . $photos = $update['message']['photo'];
($update['message']['from']['last_name'] ?? '') $fileId = end($photos)['file_id'];
); $bot->handlePhoto($chatId, $fileId);
return response()->json(['ok' => true]);
}
$engine = new ChatBotEngine(); // Voice / audio messages
$replies = $engine->handle('telegram', $chatId, $text, $nombre); if (isset($update['message']['voice']) || isset($update['message']['audio'])) {
$chatId = (string) $update['message']['chat']['id'];
$fileId = $update['message']['voice']['file_id'] ?? $update['message']['audio']['file_id'];
$duration = $update['message']['voice']['duration'] ?? $update['message']['audio']['duration'] ?? null;
$nombre = trim(
($update['message']['from']['first_name'] ?? '') . ' ' .
($update['message']['from']['last_name'] ?? '')
);
$bot->handleVoice($chatId, $fileId, $nombre, (int) $duration);
return response()->json(['ok' => true]);
}
foreach ($replies as $reply) { // Text messages
$engine->enviarTelegram($chatId, $reply); if (isset($update['message']['text'])) {
$chatId = (string) $update['message']['chat']['id'];
$text = $update['message']['text'];
$nombre = trim(
($update['message']['from']['first_name'] ?? '') . ' ' .
($update['message']['from']['last_name'] ?? '')
);
$bot->handleText($chatId, $text, $nombre);
return response()->json(['ok' => true]);
} }
return response()->json(['ok' => true]); return response()->json(['ok' => true]);
} }
} }
+1756 -44
View File
File diff suppressed because it is too large Load Diff
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace App\Http\Livewire\Chat;
use App\Models\AiUsageLog;
use Carbon\Carbon;
use Livewire\Component;
use Livewire\WithPagination;
class ShowAiLogs extends Component
{
use WithPagination;
public string $filtroServicio = '';
public string $filtroCanal = '';
public string $filtroFechaDesde = '';
public string $filtroFechaHasta = '';
public string $filtroMes = '';
protected $queryString = [
'filtroServicio', 'filtroCanal',
'filtroFechaDesde', 'filtroFechaHasta',
'filtroMes',
];
public function updatingFiltroServicio(): void { $this->resetPage(); }
public function updatingFiltroCanal(): void { $this->resetPage(); }
public function updatingFiltroFechaDesde(): void { $this->resetPage(); }
public function updatingFiltroFechaHasta(): void { $this->resetPage(); }
public function updatingFiltroMes(): void { $this->resetPage(); }
public function updatedFiltroMes(): void
{
if ($this->filtroMes) {
$this->filtroFechaDesde = '';
$this->filtroFechaHasta = '';
}
}
public function updatedFiltroFechaDesde(): void
{
if ($this->filtroFechaDesde) $this->filtroMes = '';
}
public function updatedFiltroFechaHasta(): void
{
if ($this->filtroFechaHasta) $this->filtroMes = '';
}
private function applyFiltros($query): void
{
if ($this->filtroServicio) {
$query->where('servicio', $this->filtroServicio);
}
if ($this->filtroCanal) {
$query->where('canal', $this->filtroCanal);
}
if ($this->filtroMes) {
$inicio = Carbon::createFromFormat('Y-m', $this->filtroMes)->startOfMonth();
$fin = $inicio->copy()->endOfMonth();
$query->whereBetween('created_at', [$inicio, $fin]);
} else {
if ($this->filtroFechaDesde) {
$query->whereDate('created_at', '>=', $this->filtroFechaDesde);
}
if ($this->filtroFechaHasta) {
$query->whereDate('created_at', '<=', $this->filtroFechaHasta);
}
}
}
public function render()
{
$query = AiUsageLog::query()->latest();
$this->applyFiltros($query);
$logs = $query->paginate(30);
$resumenQuery = AiUsageLog::query();
$this->applyFiltros($resumenQuery);
$resumen = $resumenQuery->selectRaw("
COUNT(*) as total_llamadas,
SUM(CASE WHEN resultado = 'ok' THEN 1 ELSE 0 END) as total_ok,
SUM(CASE WHEN resultado = 'error' THEN 1 ELSE 0 END) as total_errores,
SUM(input_tokens) as total_input_tokens,
SUM(output_tokens) as total_output_tokens,
SUM(audio_segundos) as total_segundos_whisper,
SUM(CASE WHEN servicio = 'whisper' THEN costo ELSE 0 END) as costo_whisper,
SUM(CASE WHEN servicio = 'ocr' THEN 1 ELSE 0 END) as total_fotos_ocr,
SUM(CASE WHEN servicio = 'ocr' THEN costo ELSE 0 END) as costo_ocr,
SUM((COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) / 1000000.0 * 20000) as costo_gemini,
AVG(tiempo_ms) as avg_tiempo_ms
")->first();
$meses = collect(range(0, 12))->map(function ($i) {
$d = now()->subMonths($i);
return [
'value' => $d->format('Y-m'),
'label' => ucfirst($d->locale('es')->isoFormat('MMMM YYYY')),
];
});
return view('livewire.chat.show-ai-logs', compact('logs', 'resumen', 'meses'));
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Livewire\Chat;
use App\Models\ChatConversation;
use App\Models\SolicitudRecarga;
use Livewire\Component;
use Livewire\WithPagination;
class ShowComprobantes extends Component
{
use WithPagination;
public string $busqueda = '';
public string $filtroEstado = '';
public string $filtroCanal = '';
protected $queryString = [
'busqueda' => ['except' => ''],
'filtroEstado' => ['except' => ''],
'filtroCanal' => ['except' => ''],
];
public function updatingBusqueda(): void { $this->resetPage(); }
public function updatingFiltroEstado(): void { $this->resetPage(); }
public function updatingFiltroCanal(): void { $this->resetPage(); }
public function verChat(int $userId): void
{
$conv = ChatConversation::whereHas('contact', fn($q) => $q->where('user_id', $userId))
->orderByDesc('ultimo_mensaje_at')
->first();
if ($conv) {
$this->redirect(route('chat.conversaciones') . '?conv=' . $conv->id);
}
}
public function render()
{
$solicitudes = SolicitudRecarga::with('user')
->when($this->filtroEstado, fn($q) => $q->where('estado', $this->filtroEstado))
->when($this->filtroCanal, fn($q) => $q->where('canal', $this->filtroCanal))
->when($this->busqueda, fn($q) => $q->whereHas('user', fn($s) =>
$s->where('email', 'like', "%{$this->busqueda}%")
->orWhere('name', 'like', "%{$this->busqueda}%")
))
->orderByDesc('id')
->paginate(25);
return view('livewire.chat.show-comprobantes', compact('solicitudes'));
}
}
+216 -28
View File
@@ -3,6 +3,7 @@
namespace App\Http\Livewire\Chat; namespace App\Http\Livewire\Chat;
use App\Models\ChatConfig; use App\Models\ChatConfig;
use Illuminate\Support\Facades\Http;
use Jantinnerezo\LivewireAlert\LivewireAlert; use Jantinnerezo\LivewireAlert\LivewireAlert;
use Livewire\Component; use Livewire\Component;
@@ -10,30 +11,218 @@ class ShowConfiguracionChat extends Component
{ {
use LivewireAlert; use LivewireAlert;
// ── Chat general ──────────────────────────────────────────
public string $telegram_token = ''; public string $telegram_token = '';
public string $mensaje_bienvenida = ''; public string $mensaje_bienvenida = '';
public string $mensaje_transferencia = ''; public string $mensaje_transferencia = '';
public string $webhookResult = ''; public string $webhookResult = '';
public string $whisperResult = '';
public string $geminiResult = '';
public string $ocrResult = '';
// ── Gemini IA ─────────────────────────────────────────────
public string $gemini_api_key = '';
public string $gemini_model = 'gemini-3.5-flash';
public string $gemini_habilitado = '0';
public string $gemini_prompt_extra = '';
// ── Validación de pago por foto + correo ──────────────────
public string $validacion_foto_habilitada = '0';
public string $validacion_accion_auto = 'solo_notificar';
public string $validacion_monto_tolerancia = '0';
// ── Whisper STT ───────────────────────────────────────────
public string $whisper_url = '';
public string $whisper_token = '';
public string $whisper_habilitado = '0';
// ── OCR de comprobantes (previo a Gemini) ──────────────────
public string $ocr_url = '';
public string $ocr_token = '';
public string $ocr_habilitado = '0';
// ── Bre-B ─────────────────────────────────────────────────
public string $recarga_banco_nombre = '';
public string $recarga_banco_llave = '';
public string $recarga_banco_titular = '';
public function mount(): void public function mount(): void
{ {
$this->telegram_token = ChatConfig::get('telegram_token', ''); $keys = [
$this->mensaje_bienvenida = ChatConfig::get('mensaje_bienvenida', 'Hola 👋 Bienvenido. Escribe tu consulta.'); 'telegram_token', 'mensaje_bienvenida', 'mensaje_transferencia',
$this->mensaje_transferencia = ChatConfig::get('mensaje_transferencia', 'Un agente se comunicará contigo en breve. Por favor espera.'); 'gemini_api_key', 'gemini_model', 'gemini_habilitado', 'gemini_prompt_extra',
'validacion_foto_habilitada', 'validacion_accion_auto', 'validacion_monto_tolerancia',
'whisper_url', 'whisper_token', 'whisper_habilitado',
'ocr_url', 'ocr_token', 'ocr_habilitado',
'recarga_banco_nombre', 'recarga_banco_llave', 'recarga_banco_titular',
];
$defaults = [
'mensaje_bienvenida' => 'Hola! Bienvenido. Escribe tu consulta.',
'mensaje_transferencia' => 'Un agente se comunicara contigo en breve. Por favor espera.',
'validacion_accion_auto' => 'solo_notificar',
'validacion_monto_tolerancia' => '0',
'gemini_model' => 'gemini-3.5-flash',
];
foreach ($keys as $key) {
$this->{$key} = ChatConfig::get($key, $defaults[$key] ?? '');
}
} }
public function guardar(): void public function guardar(): void
{ {
$this->validate([ $this->validate([
'mensaje_bienvenida' => 'required|string|max:500', 'mensaje_bienvenida' => 'required|string|max:500',
'mensaje_transferencia' => 'required|string|max:500', 'mensaje_transferencia' => 'required|string|max:500',
'validacion_monto_tolerancia' => 'nullable|integer|min:0',
'recarga_banco_nombre' => 'nullable|max:60',
'recarga_banco_llave' => 'nullable|max:60',
'recarga_banco_titular' => 'nullable|max:100',
]); ]);
ChatConfig::set('telegram_token', $this->telegram_token); $keys = [
ChatConfig::set('mensaje_bienvenida', $this->mensaje_bienvenida); 'telegram_token', 'mensaje_bienvenida', 'mensaje_transferencia',
ChatConfig::set('mensaje_transferencia', $this->mensaje_transferencia); 'gemini_api_key', 'gemini_model', 'gemini_habilitado', 'gemini_prompt_extra',
'validacion_foto_habilitada', 'validacion_accion_auto', 'validacion_monto_tolerancia',
'whisper_url', 'whisper_token', 'whisper_habilitado',
'ocr_url', 'ocr_token', 'ocr_habilitado',
'recarga_banco_nombre', 'recarga_banco_llave', 'recarga_banco_titular',
];
$this->alert('success', 'Configuración guardada correctamente.'); foreach ($keys as $key) {
ChatConfig::set($key, $this->{$key});
}
$this->alert('success', 'Configuracion guardada correctamente.');
}
public function probarGemini(): void
{
$key = trim($this->gemini_api_key ?: \App\Models\ChatConfig::get('gemini_api_key'));
$model = trim($this->gemini_model ?: \App\Models\ChatConfig::get('gemini_model', 'gemini-3.5-flash'));
if (! $key) {
$this->geminiResult = '⚠️ La API Key de Gemini está vacía.';
return;
}
// Try v1 first (newer models), fall back to v1beta
$apiVersions = ['v1', 'v1beta'];
$lastError = '';
foreach ($apiVersions as $ver) {
$url = "https://generativelanguage.googleapis.com/{$ver}/models/{$model}:generateContent";
try {
$inicio = microtime(true);
$body = [
'contents' => [['parts' => [['text' => 'Responde solo "ok"']]]],
'generationConfig' => ['maxOutputTokens' => 512, 'temperature' => 0],
];
$response = Http::timeout(15)
->withHeaders(['Content-Type' => 'application/json'])
->post("{$url}?key={$key}", $body);
$ms = (int) ((microtime(true) - $inicio) * 1000);
$data = $response->json();
if ($response->successful() && ! isset($data['error'])) {
$texto = $data['candidates'][0]['content']['parts'][0]['text'] ?? '(sin texto)';
$this->geminiResult = "✅ Gemini OK — modelo: {$model} ({$ver}) — {$ms}ms — respuesta: \"{$texto}\"";
return;
}
$lastError = "[{$ver}] " . ($data['error']['message'] ?? 'HTTP ' . $response->status());
} catch (\Throwable $e) {
$lastError = "[{$ver}] " . $e->getMessage();
}
}
// Ambas versiones fallaron — listar modelos disponibles
$lista = Http::timeout(10)
->get("https://generativelanguage.googleapis.com/v1beta/models?key={$key}");
if ($lista->successful()) {
$nombres = collect($lista->json('models', []))
->filter(fn ($m) => str_contains($m['name'] ?? '', 'gemini'))
->filter(fn ($m) => in_array('generateContent', $m['supportedGenerationMethods'] ?? []))
->pluck('name')
->map(fn ($n) => str_replace('models/', '', $n))
->values()->implode(', ');
$this->geminiResult = "❌ Error: {$lastError} — Modelos con generateContent: {$nombres}";
} else {
$this->geminiResult = "❌ Error: {$lastError}";
}
}
public function resetearWhisper(): void
{
$url = trim($this->whisper_url ?: ChatConfig::get('whisper_url'));
$token = trim($this->whisper_token ?: ChatConfig::get('whisper_token'));
if (! $url) {
$this->whisperResult = '⚠️ La URL de Whisper está vacía.';
return;
}
try {
$response = Http::timeout(10)
->withBasicAuth('whisper', $token)
->get(rtrim($url, '/asr')); // ping al servidor
$this->whisperResult = $response->successful()
? '✅ Conexión con Whisper establecida correctamente.'
: '❌ Whisper respondió con error ' . $response->status() . '.';
} catch (\Throwable $e) {
$this->whisperResult = '❌ No se pudo conectar con Whisper: ' . $e->getMessage();
}
}
public function probarOcr(): void
{
$url = \App\Services\OcrExtractionService::normalizarUrl($this->ocr_url ?: ChatConfig::get('ocr_url'));
$token = trim($this->ocr_token ?: ChatConfig::get('ocr_token'));
if (! $url) {
$this->ocrResult = '⚠️ La URL del servicio OCR está vacía.';
return;
}
// PNG 240x80 con el texto "TEST OCR 123" renderizado, para que la prueba
// sea real: un pixel en blanco siempre da "sin_texto_detectado" aunque
// todo funcione bien.
$imagenPrueba = 'iVBORw0KGgoAAAANSUhEUgAAAPAAAABQCAIAAACoK28rAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAB6ElEQVR4nO3c3W6iUBhA0WHS939l5sLENPxpPRCd3bXuWgSPZVc/MXGa5/kPVPx99wLgTIImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSfka2Xmaps3fz/O8uen+5errrbdNBwf80WIWt18c9uC+nv/699vu69vvLWNzDZxuOuUvuz67e+f7yRsf7P5wAYt9934cudN7nZv/OetlHC+JE33EyDHP88jZXfTxvaF1OuMZ7b2MHCxj8QCPX44YMTRy/KcGm97LcXxcYdy1QW8OlLeX+w+ZKa9bxma1e7MKZ7k26L3Ttj7N03TONP+yc+997zn4/uz+9sdb9REz9Bvdp9sTJ9qHE4UZ+jq/MeifXjR84eCefd/lDUGvB+hBixy/J7Uu9fhS9+DCDi5Or9cg+itcex164eEHKwcHfHINB0dbbH35OvRrnwp9yJvgPG9NSPmNMzRhgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImpR/3UrzkDoP+sMAAAAASUVORK5CYII=';
try {
$inicio = microtime(true);
$response = Http::withToken($token)
->timeout(15)
->post("{$url}/extract", [
'image_base64' => $imagenPrueba,
'mime_type' => 'image/png',
]);
$ms = (int) ((microtime(true) - $inicio) * 1000);
$data = $response->json();
if ($data === null) {
// No respondio JSON valido: la conexion en si fallo (ruta mal, host caido, etc).
$this->ocrResult = "❌ El servicio respondió HTTP " . $response->status() . " sin JSON válido: " .
substr($response->body(), 0, 200);
} elseif ($data['success'] ?? false) {
$texto = $data['text'] ?? '';
$this->ocrResult = "✅ Servicio OCR responde OK ({$ms}ms). Texto reconocido: \"" . substr($texto, 0, 150) . "\"";
} else {
// Respondio con JSON estructurado pero success=false: SI esta conectado,
// solo que no reconocio el texto de la imagen de prueba.
$this->ocrResult = "⚠️ El servicio conectó pero no reconoció el texto de prueba (HTTP {$response->status()}): " .
($data['error'] ?? 'sin detalle');
}
} catch (\Throwable $e) {
$this->ocrResult = '❌ No se pudo conectar con el servicio OCR: ' . $e->getMessage();
}
} }
public function registrarWebhook(): void public function registrarWebhook(): void
@@ -45,32 +234,31 @@ class ShowConfiguracionChat extends Component
return; return;
} }
// Guardar el token antes de registrar
ChatConfig::set('telegram_token', $token); ChatConfig::set('telegram_token', $token);
$webhookUrl = url('/chat/webhook/telegram'); $webhookUrl = url('/chat/webhook/telegram');
$apiUrl = "https://api.telegram.org/bot{$token}/setWebhook";
$context = stream_context_create([ try {
'http' => [ $response = Http::timeout(15)
'method' => 'POST', ->post("https://api.telegram.org/bot{$token}/setWebhook", [
'header' => "Content-Type: application/x-www-form-urlencoded\r\n", 'url' => $webhookUrl,
'content' => http_build_query(['url' => $webhookUrl]), ]);
'timeout' => 10,
],
]);
$result = @file_get_contents($apiUrl, false, $context); $data = $response->json();
if ($result === false) { if (! ($data['ok'] ?? false)) {
$this->webhookResult = '❌ Error de conexión con la API de Telegram.'; $this->webhookResult = '❌ Error de Telegram: ' . ($data['description'] ?? 'respuesta desconocida');
return; return;
}
// Registrar comandos del bot (/menu, /salir, /cancelar)
app(\App\Services\TelegramBotService::class)->registrarComandos();
$this->webhookResult = '✅ Webhook registrado y comandos configurados correctamente.';
} catch (\Throwable $e) {
$this->webhookResult = '❌ No se pudo conectar con Telegram: ' . $e->getMessage();
} }
$data = json_decode($result, true);
$this->webhookResult = ($data['ok'] ?? false)
? '✅ Webhook registrado correctamente: ' . ($data['description'] ?? 'OK')
: '❌ Error: ' . ($data['description'] ?? 'respuesta desconocida');
} }
public function render() public function render()
+11 -1
View File
@@ -15,6 +15,15 @@ class ShowConversaciones extends Component
public string $replyText = ''; public string $replyText = '';
public array $mensajes = []; public array $mensajes = [];
protected $queryString = ['convSelecId' => ['except' => null, 'as' => 'conv']];
public function mount(): void
{
if ($this->convSelecId) {
$this->cargarMensajes();
}
}
public function selectConv(int $convId): void public function selectConv(int $convId): void
{ {
$this->convSelecId = $convId; $this->convSelecId = $convId;
@@ -124,7 +133,8 @@ class ShowConversaciones extends Component
$q->whereHas('contact', function ($s) { $q->whereHas('contact', function ($s) {
$s->where('nombre', 'like', "%{$this->busqueda}%") $s->where('nombre', 'like', "%{$this->busqueda}%")
->orWhere('telefono', 'like', "%{$this->busqueda}%") ->orWhere('telefono', 'like', "%{$this->busqueda}%")
->orWhere('canal_id', 'like', "%{$this->busqueda}%"); ->orWhere('canal_id', 'like', "%{$this->busqueda}%")
->orWhereHas('user', fn($u) => $u->where('email', 'like', "%{$this->busqueda}%"));
}); });
}) })
->orderByDesc('ultimo_mensaje_at') ->orderByDesc('ultimo_mensaje_at')
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Jobs;
use App\Models\SolicitudRecarga;
use App\Services\PagoValidadorService;
use App\Services\TelegramBotService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class ValidarPagoTelegramJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public int $timeout = 60;
private const MAX_INTENTOS = 9;
private const STATE_TTL = 315360000;
public function __construct(
private string $chatId,
private array $datosPago,
private int $intento = 1
) {}
public function handle(): void
{
$cacheKey = "tgbot_state_{$this->chatId}";
$state = Cache::get($cacheKey, []);
$usuarioId = $state['user_id'] ?? null;
$bot = new TelegramBotService();
if (! $usuarioId) {
return;
}
$solicitud = SolicitudRecarga::pendiente($usuarioId);
if (! $solicitud) {
Log::info("[ValidarPagoTelegramJob] chatId={$this->chatId}: solicitud ya no está pendiente, abortando.");
return;
}
$nombreUsuario = $state['data']['nombre_remitente'] ?? null;
$resultado = app(PagoValidadorService::class)->validar(
$this->datosPago, $usuarioId, $nombreUsuario, 'telegram'
);
if ($resultado['estado'] === 'confirmado') {
$bot->autoAplicarRecarga($this->chatId, $state, $solicitud, $solicitud->monto);
return;
}
if ($resultado['estado'] === 'ya_usado') {
$bot->sendWithKeyboard($this->chatId,
"⛔ *Comprobante ya utilizado.*\nEste pago ya fue registrado anteriormente.",
[[['text' => '🔙 Menú principal', 'callback_data' => 'menu']]]
);
return;
}
// No encontrado — reintentar o agotar
if ($this->intento < self::MAX_INTENTOS) {
Log::info("[ValidarPagoTelegramJob] chatId={$this->chatId}: intento {$this->intento}/" . self::MAX_INTENTOS . " — reintentando en 20s");
self::dispatch($this->chatId, $this->datosPago, $this->intento + 1)
->delay(now()->addSeconds(20));
} else {
Log::info("[ValidarPagoTelegramJob] chatId={$this->chatId}: intentos agotados, mostrando botón manual");
$bot->sendWithKeyboard(
$this->chatId,
"⚠️ No pudimos confirmar tu pago automáticamente.\nSi ya realizaste la transferencia, presiona Reintentar.",
[
[['text' => '🔄 Reintentar', 'callback_data' => 'pay_retry']],
[['text' => '🔙 Menú principal', 'callback_data' => 'menu']],
]
);
}
}
}
+285
View File
@@ -0,0 +1,285 @@
<?php
namespace App\Jobs;
use App\Models\ChatMessage;
use App\Models\recarga as Recarga;
use App\Models\Saldo;
use App\Models\SolicitudRecarga;
use App\Models\User;
use App\Services\GeminiVisionService;
use App\Services\OcrExtractionService;
use App\Services\PagoValidadorService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Valida un comprobante de pago del chat web fuera del request HTTP.
*
* Antes esto corría dentro del propio request de Livewire: Gemini (hasta 80s)
* más la lectura IMAP (20-60s) superaban el límite del gateway y el usuario
* recibía un 504. Peor aún, con SESSION_DRIVER=file el request mantenía
* bloqueado el archivo de sesión, así que el wire:poll del chat no podía
* avanzar y la conversación entera se congelaba.
*
* Ahora el request solo guarda la imagen y despacha este job. El job escribe
* su progreso como mensajes normales del chat (que el poll va mostrando) y
* deja el desenlace en caché para que el componente aplique la recarga.
*
* Espejo del flujo de Telegram, pero independiente: ValidarPagoTelegramJob
* no se toca.
*/
class ValidarPagoWebJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public int $timeout = 100;
private const MAX_INTENTOS = 9;
private const RESULTADO_TTL = 1800;
public function __construct(
private int $convId,
private int $usuarioId,
private int $solicitudId,
private string $comprobantePath,
private ?string $nombreRemitente = null,
private ?array $datosPago = null,
private int $intento = 1,
) {}
/** Clave donde el componente Livewire lee el desenlace. */
public static function claveResultado(int $convId, int $solicitudId): string
{
return "pago_web_{$convId}_{$solicitudId}";
}
public function handle(): void
{
$solicitud = SolicitudRecarga::pendiente($this->usuarioId);
if (! $solicitud || $solicitud->id !== $this->solicitudId) {
Log::info("[ValidarPagoWebJob] conv={$this->convId}: la solicitud ya no está pendiente, abortando.");
$this->bot("Tu solicitud de recarga ya no está activa (puede que hayas iniciado una nueva). Si ya transferiste, escribe a un asesor para confirmar tu pago manualmente.");
$this->resolver('cancelado');
return;
}
// ── Paso 1: leer el comprobante con Gemini (solo en el primer intento)
if ($this->datosPago === null) {
$datos = $this->leerComprobante();
if ($datos === null) {
return; // leerComprobante() ya avisó al usuario y cerró el flujo
}
$valorIA = (int) ($datos['valor'] ?? 0);
if ($valorIA !== $solicitud->monto) {
$this->bot(
"⚠️ El monto del comprobante ($" . number_format($valorIA) . ") no coincide "
. "con tu solicitud de recarga ($" . number_format($solicitud->monto) . ")."
);
$this->resolver('monto_incorrecto');
return;
}
$this->datosPago = $datos;
$this->bot("📧 Datos leídos correctamente. Ahora estoy buscando tu pago en el banco...");
}
// ── Paso 2: buscar el pago en el correo
$resultado = app(PagoValidadorService::class)->validar(
$this->datosPago, $this->usuarioId, $this->nombreRemitente, 'web'
);
$motivo = $resultado['motivo'] ?? '';
if ($resultado['estado'] === 'confirmado') {
// El saldo se acredita aquí y no en el componente: si el usuario
// cierra o recarga la pestaña mientras validamos, el dinero tiene
// que quedar abonado igual. El componente solo se encarga después
// de completar la compra que hubiera quedado pendiente.
$this->aplicarRecarga($solicitud);
$this->resolver('confirmado', ['monto' => $solicitud->monto]);
return;
}
if ($resultado['estado'] === 'ya_usado') {
$this->bot("⛔ Comprobante ya utilizado. Este pago ya fue registrado anteriormente.");
$this->resolver('ya_usado');
return;
}
if ($motivo === 'sin_coincidencia' || $motivo === 'sin_correos') {
$this->reintentarOAgotar();
return;
}
$this->bot("⚠️ No confirmado: " . match ($motivo) {
'correo_deshabilitado' => 'la verificación por correo no está configurada',
'error_imap' => 'no pudimos leer el correo del banco',
'error_sistema' => 'error interno, intenta de nuevo en unos minutos',
default => 'no se pudo verificar automáticamente',
});
$this->resolver('error');
}
/**
* El pago aún no aparece en el correo: reintentar con espera o rendirse.
* El banco puede tardar un par de minutos en enviar la notificación.
*/
private function reintentarOAgotar(): void
{
if ($this->intento === 1) {
$this->bot(
"⏳ Estamos validando tu pago, esto puede tardar unos minutos.\n"
. "Te confirmo por aquí en cuanto lo encuentre."
);
}
if ($this->intento < self::MAX_INTENTOS) {
self::dispatch(
$this->convId,
$this->usuarioId,
$this->solicitudId,
$this->comprobantePath,
$this->nombreRemitente,
$this->datosPago,
$this->intento + 1,
)->delay(now()->addSeconds(20));
return;
}
ChatMessage::create([
'conversation_id' => $this->convId,
'tipo' => 'bot',
'tipo_ui' => 'validacion',
'contenido' => 'No pudimos confirmar tu pago automáticamente.',
'payload' => [
'estado' => 'pendiente',
'motivo' => 'No encontramos el pago en el correo del banco.',
'ia_datos' => $this->datosPago,
'botones' => [
['label' => '🔄 Reintentar', 'action' => 'pago.reintentar', 'data' => []],
['label' => '← Menú principal', 'action' => 'menu.principal', 'data' => []],
],
],
'leido' => true,
]);
$this->resolver('agotado', ['datosPago' => $this->datosPago]);
}
/** Acredita el saldo, deja registro de la recarga y cierra la solicitud. */
private function aplicarRecarga(SolicitudRecarga $solicitud): void
{
$monto = (int) $solicitud->monto;
DB::transaction(function () use ($solicitud, $monto) {
$user = User::with('saldo')->find($this->usuarioId);
$saldo = $user->saldo ?? Saldo::create(['usuario_id' => $this->usuarioId, 'valor' => 0]);
$saldo->update(['valor' => $saldo->valor + $monto]);
Recarga::create([
'usuario_id' => $this->usuarioId,
'saldo_id' => $saldo->id,
'monto' => $monto,
'valor_recarga' => $monto,
'status' => 'Confirmado',
'reference' => 'breb-chat-' . $solicitud->id,
]);
$solicitud->confirmar();
$this->bot(
"✅ ¡Tu recarga de $" . number_format($monto) . " fue aplicada!\n"
. "Tu nuevo saldo es $" . number_format($saldo->valor) . "."
);
});
}
/**
* Lee el comprobante con Gemini. Devuelve null (y avisa al usuario) si no
* se pudo interpretar.
*/
private function leerComprobante(): ?array
{
$ruta = storage_path('app/' . $this->comprobantePath);
if (! is_file($ruta)) {
Log::warning("[ValidarPagoWebJob] conv={$this->convId}: no existe {$ruta}");
$this->bot("No pude recuperar la imagen del comprobante. Envíala de nuevo, por favor.");
$this->resolver('error');
return null;
}
$base64 = base64_encode(file_get_contents($ruta));
$mime = mime_content_type($ruta) ?: 'image/jpeg';
$gemini = app(GeminiVisionService::class);
$datos = null;
// Camino nuevo: OCR externo (texto) + Gemini solo-texto. Evita depender
// de la cuota/estabilidad de Gemini Vision, que es lo que fallaba antes.
$ocr = app(OcrExtractionService::class);
if ($ocr->habilitado()) {
$texto = $ocr->extraerTexto($base64, $mime, $this->usuarioId, 'web');
if ($texto) {
$datos = $gemini->extraerPagoDeTexto($texto, $this->usuarioId, 'web');
} else {
Log::warning('[ValidarPagoWebJob] OCR no devolvio texto, cae a lectura por vision.');
}
}
// Si el OCR no esta habilitado, no respondio, o Gemini no pudo
// estructurar su texto: cae al metodo anterior (Gemini lee la imagen).
if (! $datos || isset($datos['__error']) || isset($datos['__parse_error'])) {
$datos = $gemini->extraerPago($base64, $mime, $this->usuarioId, 'web');
}
if (! $datos || isset($datos['__error']) || isset($datos['__parse_error'])) {
Log::warning('[ValidarPagoWebJob] Gemini: ' . ($datos['__error'] ?? $datos['__parse_error'] ?? 'sin respuesta'));
$this->bot("No pude leer el comprobante. Intenta con una foto más clara o con mejor iluminación.");
$this->resolver('error');
return null;
}
return $datos;
}
/** Si el job muere (excepción o timeout) el chat no puede quedarse colgado. */
public function failed(?\Throwable $e): void
{
Log::error('[ValidarPagoWebJob] falló: ' . ($e?->getMessage() ?? 'sin detalle'));
$this->bot("Tuvimos un problema validando tu comprobante. Intenta enviarlo de nuevo o pide un asesor.");
$this->resolver('error');
}
private function bot(string $contenido): void
{
ChatMessage::create([
'conversation_id' => $this->convId,
'tipo' => 'bot',
'tipo_ui' => 'text',
'contenido' => $contenido,
'leido' => true,
]);
}
private function resolver(string $estado, array $extra = []): void
{
Cache::put(
self::claveResultado($this->convId, $this->solicitudId),
array_merge(['estado' => $estado], $extra),
self::RESULTADO_TTL
);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class ChatOtpMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public string $codigo,
public string $nombre,
) {}
public function build(): static
{
return $this
->subject('Tu codigo de verificacion - ' . config('app.name'))
->html($this->renderHtml());
}
private function renderHtml(): string
{
$appName = config('app.name', 'SirPremium');
$codigo = $this->codigo;
$nombre = htmlspecialchars($this->nombre);
return <<<HTML
<!DOCTYPE html>
<html lang="es">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
<tr><td align="center">
<table width="480" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,.08);">
<tr><td style="background:#10b981;padding:28px 32px;text-align:center;">
<h1 style="margin:0;color:#fff;font-size:22px;font-weight:700;">{$appName}</h1>
</td></tr>
<tr><td style="padding:36px 40px;">
<p style="margin:0 0 8px;font-size:15px;color:#374151;">Hola, <strong>{$nombre}</strong></p>
<p style="margin:0 0 28px;font-size:15px;color:#6b7280;">Tu codigo de verificacion para acceder al chat es:</p>
<div style="text-align:center;margin:0 0 28px;">
<span style="display:inline-block;background:#f0fdf4;border:2px solid #10b981;border-radius:10px;padding:16px 40px;font-size:36px;font-weight:700;letter-spacing:10px;color:#059669;">{$codigo}</span>
</div>
<p style="margin:0 0 6px;font-size:13px;color:#9ca3af;text-align:center;">Este codigo expira en <strong>10 minutos</strong>.</p>
<p style="margin:0;font-size:13px;color:#9ca3af;text-align:center;">Si no solicitaste este codigo, ignora este correo.</p>
</td></tr>
<tr><td style="background:#f9fafb;padding:16px 40px;text-align:center;">
<p style="margin:0;font-size:12px;color:#9ca3af;">&copy; {$appName} soporte a traves del chat</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
HTML;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AiUsageLog extends Model
{
protected $table = 'ai_usage_logs';
protected $fillable = [
'servicio', 'canal', 'usuario_id',
'input_tokens', 'output_tokens', 'audio_segundos',
'tiempo_ms', 'costo', 'resultado', 'detalle',
];
protected $casts = ['detalle' => 'array'];
public static function registrar(array $data): void
{
try {
static::create($data);
} catch (\Throwable) {
// No interrumpir el flujo principal si el log falla
}
}
}
+11 -1
View File
@@ -3,14 +3,24 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
class ChatContact extends Model class ChatContact extends Model
{ {
protected $table = 'chat_contacts'; protected $table = 'chat_contacts';
protected $fillable = ['canal', 'canal_id', 'nombre', 'telefono', 'metadata'];
protected $fillable = [
'canal', 'canal_id', 'nombre', 'telefono', 'metadata', 'user_id',
];
protected $casts = ['metadata' => 'array']; protected $casts = ['metadata' => 'array'];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function conversations(): HasMany public function conversations(): HasMany
{ {
return $this->hasMany(ChatConversation::class, 'contact_id'); return $this->hasMany(ChatConversation::class, 'contact_id');
+9 -1
View File
@@ -8,7 +8,15 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ChatMessage extends Model class ChatMessage extends Model
{ {
protected $table = 'chat_messages'; protected $table = 'chat_messages';
protected $fillable = ['conversation_id', 'tipo', 'contenido', 'leido'];
protected $fillable = [
'conversation_id', 'tipo', 'tipo_ui', 'contenido', 'payload', 'leido',
];
protected $casts = [
'payload' => 'array',
'leido' => 'boolean',
];
public function conversation(): BelongsTo public function conversation(): BelongsTo
{ {
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PagoConfirmado extends Model
{
protected $table = 'pagos_confirmados';
public $timestamps = false;
protected $fillable = [
'email_hash',
'usuario_id',
'valor',
'canal',
'banco',
'referencia',
'confirmado_en',
];
protected $casts = [
'confirmado_en' => 'datetime',
];
public static function yaUsado(string $hash): bool
{
return static::where('email_hash', $hash)->exists();
}
public static function marcar(string $hash, array $datos): void
{
static::create(array_merge(['email_hash' => $hash], $datos));
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
class SolicitudRecarga extends Model
{
protected $table = 'solicitudes_recarga';
public $timestamps = false;
protected $fillable = ['user_id', 'monto', 'canal', 'estado', 'nombre_remitente'];
protected $casts = ['created_at' => 'datetime'];
public static function crear(int $userId, int $monto, string $canal, ?string $nombreRemitente = null): static
{
static::where('user_id', $userId)
->where('estado', 'pendiente')
->update(['estado' => 'expirada']);
return static::create([
'user_id' => $userId,
'monto' => $monto,
'canal' => $canal,
'estado' => 'pendiente',
'nombre_remitente' => $nombreRemitente,
]);
}
public static function pendiente(int $userId): ?static
{
return static::where('user_id', $userId)
->where('estado', 'pendiente')
->where('created_at', '>=', now()->startOfDay())
->latest('id')
->first();
}
public static function nombresPrevios(int $userId): array
{
return static::where('user_id', $userId)
->where('estado', 'confirmada')
->whereNotNull('nombre_remitente')
->orderByDesc('id')
->limit(10)
->pluck('nombre_remitente')
->unique()
->values()
->toArray();
}
public function confirmar(): void
{
$this->update(['estado' => 'confirmada']);
}
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(User::class);
}
}
+1
View File
@@ -29,6 +29,7 @@ class User extends Authenticatable
'parent_id', 'parent_id',
'rank_id', 'rank_id',
'celular',
]; ];
protected $hidden = [ protected $hidden = [
+88 -23
View File
@@ -39,16 +39,43 @@ class BancolombiaParser
} }
$result = [ $result = [
'banco' => 'Bancolombia', 'banco' => 'Bancolombia',
'tipo' => self::detectarTipo($text), 'tipo' => self::detectarTipo($text),
'destinatario' => self::extraer($text, '/Bancolombia[:\s]+([A-ZÁÉÍÓÚÑÜ][A-Za-záéíóúñü\s]+?),\s+recibiste/u'), 'destinatario' => self::extraerPrimero($text, [
'remitente' => self::extraer($text, '/transferencia\s+de\s+(.+?)\s+por\s+\$/iu'), '/Bancolombia[:\s]+([A-ZÁÉÍÓÚÑÜ][A-Za-záéíóúñü\s]+?),\s+recibiste/u',
'valor' => self::extraerValor($text), '/Hola[,\s]+([A-ZÁÉÍÓÚÑÜ][A-Za-záéíóúñü\s]+?)[,\.]/u',
'cuenta' => self::extraer($text, '/cuenta\s+\*(\d+)/iu'), '/estimado[a]?\s+([A-ZÁÉÍÓÚÑÜ][A-Za-záéíóúñü\s]+?)[,\.]/iu',
'llave' => self::extraer($text, '/llave\s+([@\w.\-]+)/iu'), ]),
'fecha' => self::extraer($text, '/el\s+(\d{1,2}\/\d{1,2}\/\d{2,4})/iu'), 'remitente' => self::extraerPrimero($text, [
'hora' => self::extraer($text, '/a\s+las\s+(\d{1,2}:\d{2})/iu'), '/transferencia\s+de\s+(.+?)\s+por\s+\$/iu',
'texto_original' => $text, '/realiz[oó]\s+(?:un[a]?\s+)?(?:transferencia|pago)\s+de\s+\$[0-9,.]+\s+(?:a\s+tu\s+cuenta|para)\s+(.+?)[\.,]/iu',
'/(?:pagador|remitente|enviado\s+por)[:\s]+([A-ZÁÉÍÓÚÑÜ][A-Za-záéíóúñü\s]+?)[\.,]/iu',
'/de\s+([A-ZÁÉÍÓÚÑÜ][A-Za-záéíóúñü\s]{3,50}?)\s+(?:por\s+\$|a\s+tu)/iu',
]),
'valor' => self::extraerValor($text),
'cuenta' => self::extraerPrimero($text, [
'/cuenta\s+\*(\d+)/iu',
'/cuenta\s+(?:de\s+ahorros|corriente)?\s*(?:No\.?\s*)?\*?(\d{4,})/iu',
'/terminad[ao]\s+en\s+(\d{4})/iu',
]),
'llave' => self::extraerPrimero($text, [
'/llave\s+([@\w.\-]+)/iu',
'/llave\s+(?:de\s+pago\s+)?([@\w.\-]+)/iu',
'/([@][a-z0-9._\-]{3,})/iu',
]),
'fecha' => self::extraerPrimero($text, [
'/el\s+(\d{1,2}\/\d{1,2}\/\d{2,4})/iu',
'/(\d{1,2}\/\d{1,2}\/\d{2,4})/u',
'/(\d{1,2}\s+de\s+\w+\s+de\s+\d{4})/iu',
'/fecha[:\s]+(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/iu',
]),
'hora' => self::extraerPrimero($text, [
'/a\s+las\s+(\d{1,2}:\d{2})/iu',
'/hora[:\s]+(\d{1,2}:\d{2})/iu',
'/(\d{1,2}:\d{2})\s*(?:a\.?m\.?|p\.?m\.?)/iu',
'/(\d{2}:\d{2})(?::\d{2})?/u',
]),
'texto_original' => $text,
]; ];
return $result; return $result;
@@ -60,13 +87,19 @@ class BancolombiaParser
private static function esBancolombia(string $text): bool private static function esBancolombia(string $text): bool
{ {
return stripos($text, 'bancolombia') !== false if (stripos($text, 'bancolombia') === false) {
&& ( return false;
stripos($text, 'transferencia') !== false }
|| stripos($text, 'pago') !== false // Al menos uno de estos indica una notificación transaccional
|| stripos($text, 'recibiste') !== false $indicadores = ['transferencia', 'pago', 'recibiste', 'enviaste', 'credito', 'crédito',
|| stripos($text, 'enviaste') !== false 'abono', 'consignacion', 'consignación', 'recibido', 'recibiste',
); 'valor', 'monto', '$'];
foreach ($indicadores as $ind) {
if (stripos($text, $ind) !== false) {
return true;
}
}
return false;
} }
private static function detectarTipo(string $text): string private static function detectarTipo(string $text): string
@@ -95,19 +128,51 @@ class BancolombiaParser
} }
/** /**
* Extrae el valor monetario como string limpio, ej: "1000.00" * Prueba múltiples patrones y devuelve el primer match no-vacío.
* y también como float. */
* Devuelve el string formateado con separadores originales. private static function extraerPrimero(string $text, array $patterns): string
{
foreach ($patterns as $pat) {
$v = self::extraer($text, $pat);
if ($v !== '') {
return $v;
}
}
return '';
}
/**
* Extrae el valor monetario como entero limpio sin decimales (ej: "1000").
* $1,000.00 "1000" | $50.000,00 "50000"
*/ */
private static function extraerValor(string $text): string private static function extraerValor(string $text): string
{ {
// Captura números con puntos y comas: $1,000.00 | $1.000,00 | $500 $patterns = [
if (preg_match('/por\s+\$([0-9][0-9.,]*)/iu', $text, $m)) { '/por\s+\$\s*([0-9][0-9.,]*)/iu',
return trim($m[1]); '/valor[:\s]+\$?\s*([0-9][0-9.,]*)/iu',
'/monto[:\s]+\$?\s*([0-9][0-9.,]*)/iu',
'/recibiste[^$]*\$\s*([0-9][0-9.,]*)/iu',
'/\$\s*([0-9][0-9.,]{2,})/u',
];
foreach ($patterns as $pat) {
if (preg_match($pat, $text, $m)) {
return self::normalizarValor(trim($m[1]));
}
} }
return ''; return '';
} }
/**
* Convierte "1,000.00" o "1.000,00" a "1000" (entero limpio).
*/
private static function normalizarValor(string $raw): string
{
// Eliminar parte decimal: ,XX o .XX al final (pesos colombianos no tienen centavos reales)
$raw = preg_replace('/[.,]\d{1,2}$/', '', $raw);
// Eliminar separadores de miles (comas, puntos)
return preg_replace('/[^0-9]/', '', $raw);
}
/** /**
* Elimina completamente bloques <style>, <script>, <head> y luego * Elimina completamente bloques <style>, <script>, <head> y luego
* convierte etiquetas estructurales en saltos de línea antes de strip_tags. * convierte etiquetas estructurales en saltos de línea antes de strip_tags.
+15 -6
View File
@@ -51,7 +51,7 @@ class CorreoImapService
} }
// Descargar un lote suficiente para cubrir la ventana de tiempo. // Descargar un lote suficiente para cubrir la ventana de tiempo.
$fetchSize = min($total, max($limit * 4, 20)); $fetchSize = min($total, max($limit * 2, 20));
$from = max(1, $total - $fetchSize + 1); $from = max(1, $total - $fetchSize + 1);
$set = "{$from}:{$total}"; $set = "{$from}:{$total}";
@@ -67,9 +67,9 @@ class CorreoImapService
foreach ($emails as $email) { foreach ($emails as $email) {
$ts = $email['date'] ? @strtotime($email['date']) : false; $ts = $email['date'] ? @strtotime($email['date']) : false;
// Correo más antiguo que la ventana → parar // Correo más antiguo que la ventana → saltar (no parar, pueden haber más nuevos)
if ($ts !== false && $ts < $cutoff) { if ($ts !== false && $ts < $cutoff) {
break; continue;
} }
$result[] = $email; $result[] = $email;
@@ -304,9 +304,18 @@ class CorreoImapService
private function cleanBody(string $body): string private function cleanBody(string $body): string
{ {
// ── 1. Decoded quoted-printable ────────────────────────────────── // ── 0. Base64 — detectar antes que QP ────────────────────────────
// BODY.PEEK[1] retorna directamente el contenido de la parte (sin // BODY[1] puede llegar codificado en base64 (Content-Transfer-Encoding: base64)
// boundaries), pero puede estar codificado en quoted-printable. // si el servidor no decodifica automáticamente.
$noWs = preg_replace('/[\r\n\t ]/', '', $body);
if (strlen($noWs) > 60 && preg_match('/^[A-Za-z0-9+\/]+=*$/', $noWs)) {
$decoded = base64_decode($noWs, true);
if ($decoded !== false && preg_match('//u', $decoded)) {
$body = $decoded;
}
}
// ── 1. Quoted-printable ──────────────────────────────────────────
if (preg_match('/=[0-9A-F]{2}/i', $body) || preg_match('/=\r?\n/m', $body)) { if (preg_match('/=[0-9A-F]{2}/i', $body) || preg_match('/=\r?\n/m', $body)) {
$body = quoted_printable_decode($body); $body = quoted_printable_decode($body);
} }
+136
View File
@@ -0,0 +1,136 @@
<?php
namespace App\Services;
use App\Models\Cuentas;
use App\Models\Historial_cuenta;
use App\Models\Preferencial;
use App\Models\Tarifas;
use App\Models\Usuario_tarifa;
class CuentasHelper
{
/**
* Cuenta cuentas disponibles para una tarifa, igual que la web.
*/
public static function contar(Tarifas $tarifa): int
{
$servicio = $tarifa->servicio;
$fechaBase = now()->subDays(8)->format('Y-m-d');
$fechaMin = now()->addDays($tarifa->dias - 15)->format('Y-m-d');
$fechaMax = now()->addDays($tarifa->dias + 15)->format('Y-m-d');
// Cuenta completa
if ((int) $tarifa->pantallas === (int) $servicio->completa) {
return Cuentas::where('servicio_id', $servicio->id)
->whereDate('vencimiento', '>=', $fechaMin)
->whereDate('vencimiento', '<=', $fechaMax)
->where('estado', 'pendiente')
->doesntHave('perfil')
->count();
}
// Cuenta por pantalla
$suma = $servicio->pantallas - $tarifa->pantallas;
$baseQ = Cuentas::select('*')
->where('servicio_id', $servicio->id)
->whereDate('inicio', '>=', $fechaBase)
->whereDate('vencimiento', '>=', $fechaMin)
->whereDate('vencimiento', '<=', $fechaMax)
->where('estado', 'activo')
->withCount('historiales');
$cuentas = Cuentas::fromSub($baseQ, 'alias')
->where('historiales_count', '<=', $suma)
->get();
if ($cuentas->isEmpty()) {
return 0;
}
$tomadas = Historial_cuenta::whereIn('cuenta_id', $cuentas->pluck('id'))
->whereNotNull('historial_id')
->count();
return (int) floor((($cuentas->count() * $servicio->pantallas) - $tomadas) / $tarifa->pantallas);
}
/**
* Busca la primera cuenta disponible para una tarifa.
*/
public static function buscar(Tarifas $tarifa): ?Cuentas
{
$servicio = $tarifa->servicio;
$fechaBase = now()->subDays(8)->format('Y-m-d');
$fechaMin = now()->addDays($tarifa->dias - 15)->format('Y-m-d');
$fechaMax = now()->addDays($tarifa->dias + 15)->format('Y-m-d');
// Cuenta completa
if ((int) $tarifa->pantallas === (int) $servicio->completa) {
return Cuentas::where('servicio_id', $servicio->id)
->whereDate('vencimiento', '>=', $fechaMin)
->whereDate('vencimiento', '<=', $fechaMax)
->where('estado', 'pendiente')
->doesntHave('perfil')
->orderBy('inicio', 'ASC')
->first();
}
// Cuenta por pantalla — intento normal
$suma = $servicio->pantallas - $tarifa->pantallas;
$baseQ = Cuentas::select('*')
->where('servicio_id', $servicio->id)
->whereDate('inicio', '>=', $fechaBase)
->whereDate('vencimiento', '>=', $fechaMin)
->whereDate('vencimiento', '<=', $fechaMax)
->where('estado', 'activo')
->withCount('historiales')
->orderBy('inicio', 'ASC');
$cuenta = Cuentas::fromSub($baseQ, 'alias')
->where('historiales_count', '<=', $suma)
->orderBy('inicio', 'ASC')
->lockForUpdate()
->first();
// Segundo intento sin filtro de inicio
if (! $cuenta) {
$baseQ2 = Cuentas::select('*')
->where('servicio_id', $servicio->id)
->whereDate('vencimiento', '>=', $fechaMin)
->whereDate('vencimiento', '<=', $fechaMax)
->where('estado', 'activo')
->withCount('historiales')
->orderBy('inicio', 'ASC');
$cuenta = Cuentas::fromSub($baseQ2, 'alias')
->where('historiales_count', '<=', $suma)
->orderBy('inicio', 'ASC')
->lockForUpdate()
->first();
}
return $cuenta;
}
/**
* Precio efectivo para un usuario y tarifa.
* Prioridad: Usuario_tarifa (web) Preferencial (legacy) precio base.
*/
public static function precio(int $tarifaId, ?int $userId, float $base): float
{
if ($userId) {
$ut = Usuario_tarifa::where('usuario_id', $userId)->where('tarifa_id', $tarifaId)->first();
if ($ut) {
return (float) $ut->precio;
}
$pref = Preferencial::where('usuario_id', $userId)->where('tarifa_id', $tarifaId)->first();
if ($pref) {
return (float) $pref->valor;
}
}
return (float) $base;
}
}
+246
View File
@@ -0,0 +1,246 @@
<?php
namespace App\Services;
use App\Models\AiUsageLog;
use App\Models\ChatConfig;
use App\Models\ChatMessage;
use App\Models\Servicio;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Agente conversacional con contexto: entiende lenguaje natural y extrae
* acción + parámetros para ir directamente al flujo correcto.
*
* Respuesta: {"respuesta": "...", "accion": null|string, "accion_data": {}}
* Acciones:
* recarga.iniciar accion_data: {monto?: int}
* servicios.listar accion_data: {servicio?: "Netflix"}
* promo.listar accion_data: {}
* credenciales.listar accion_data: {servicio?: "Netflix"}
* historial.ver accion_data: {}
* perfil.ver accion_data: {}
*/
class GeminiAgentService
{
private string $apiKey;
private string $model;
public function __construct()
{
$this->apiKey = ChatConfig::get('gemini_api_key', '');
$this->model = ChatConfig::get('gemini_model', 'gemini-2.0-flash');
}
public function responder(
string $texto,
array $contexto,
?int $usuarioId = null,
string $canal = 'telegram'
): array {
$vacio = ['respuesta' => null, 'accion' => null, 'accion_data' => []];
if (! $this->apiKey) {
return $vacio;
}
$body = [
'contents' => [[
'parts' => [['text' => $this->buildPrompt($texto, $contexto)]],
]],
'generationConfig' => [
'temperature' => 0.5,
'maxOutputTokens' => 512,
'thinkingConfig' => ['thinkingBudget' => 0],
],
];
$inicio = microtime(true);
$response = null;
$lastError = '';
foreach (['v1', 'v1beta'] as $ver) {
$url = "https://generativelanguage.googleapis.com/{$ver}/models/{$this->model}:generateContent";
try {
$resp = Http::withHeaders(['Content-Type' => 'application/json'])
->timeout(12)
->post("{$url}?key={$this->apiKey}", $body);
if ($resp->successful()) {
$response = $resp;
break;
}
$lastError = "[{$ver}] " . ($resp->json('error.message') ?? substr($resp->body(), 0, 200));
} catch (\Throwable $e) {
$lastError = "[{$ver}] " . $e->getMessage();
}
}
$tiempoMs = (int) ((microtime(true) - $inicio) * 1000);
if (! $response) {
Log::warning('[GeminiAgent] API failed: ' . $lastError);
return $vacio;
}
$parts = $response->json('candidates.0.content.parts', []);
$text = implode("\n", array_column(
array_filter($parts, fn ($p) => isset($p['text']) && empty($p['thought'])),
'text'
));
$result = $this->parseRespuesta($text);
AiUsageLog::registrar([
'servicio' => 'gemini_agent',
'canal' => $canal,
'usuario_id' => $usuarioId,
'input_tokens' => $response->json('usageMetadata.promptTokenCount', 0),
'output_tokens' => $response->json('usageMetadata.candidatesTokenCount', 0),
'tiempo_ms' => $tiempoMs,
'resultado' => $result['respuesta'] ? 'ok' : 'error',
'detalle' => ['accion' => $result['accion'] ?? null, 'raw' => substr($text, 0, 200)],
]);
Log::info('[GeminiAgent] ' . json_encode($result));
return $result;
}
// ─── Context builder ────────────────────────────────────────────────────
public static function buildContexto(array $state, ?int $userId): array
{
// Balance
$balance = 0;
if ($userId) {
$user = \App\Models\User::with('saldo')->find($userId);
$balance = $user?->saldo?->valor ?? 0;
}
// Catálogo resumido (cacheado 5 min por rol)
$rolId = $state['rol_id'] ?? null;
$catalogo = Cache::remember("agent_catalogo_{$rolId}", 300, fn () => self::buildCatalogo($rolId, $userId));
// Últimos mensajes del chat (contexto conversacional)
$historial = '';
if ($convId = $state['conv_id'] ?? null) {
$msgs = ChatMessage::where('conversation_id', $convId)
->orderBy('id', 'desc')
->limit(8)
->get()
->reverse();
if ($msgs->count() > 1) {
$lines = $msgs->map(fn ($m) => ($m->tipo === 'usuario' ? 'Usuario' : 'Bot') . ': ' . $m->contenido);
$historial = "CONVERSACIÓN RECIENTE:\n" . $lines->implode("\n");
}
}
return [
'nombre' => $state['nombre'] ?? ($state['data']['nombre'] ?? 'Cliente'),
'balance' => number_format($balance),
'catalogo' => $catalogo,
'historial' => $historial,
];
}
private static function buildCatalogo(?int $rolId, ?int $userId): string
{
$servicios = Servicio::with(['tarifas' => fn ($q) => $q->where('estado', 'activo')
->when($rolId, fn ($q2) => $q2->where('rol_id', $rolId))
])->where('estado', 'activo')->limit(12)->get();
$lines = [];
foreach ($servicios as $srv) {
if ($srv->tarifas->isEmpty()) {
continue;
}
$tarifa = $srv->tarifas->first();
$precio = CuentasHelper::precio($tarifa->id, $userId, $tarifa->valor);
$disp = CuentasHelper::contar($tarifa) > 0 ? '✅' : '❌';
$lines[] = " {$disp} {$srv->nombre}\$" . number_format($precio) . " / {$tarifa->dias} días";
}
return $lines ? implode("\n", $lines) : 'Sin servicios disponibles';
}
// ─── Prompt ─────────────────────────────────────────────────────────────
private function buildPrompt(string $texto, array $contexto): string
{
$nombre = $contexto['nombre'] ?? 'Cliente';
$balance = $contexto['balance'] ?? '0';
$catalogo = $contexto['catalogo'] ?? '';
$hist = $contexto['historial'] ? "\n" . $contexto['historial'] . "\n" : '';
return <<<PROMPT
Eres el bot de SirPremium, tienda colombiana de streaming. NO eres humano ni agente de soporte.
Solo puedes ejecutar las acciones del menú NUNCA pidas pantallazos, correos ni información adicional.
Habla en español colombiano informal. Máximo 2 oraciones en "respuesta".
IMPORTANTE:
- Si el usuario tiene un problema de acceso, contraseña o no puede entrar a un servicio credenciales.listar
- Si preguntan precio o quieren comprar servicios.listar
- Si quieren recargar recarga.iniciar
- Para todo lo demás que no tenga acción: responde brevemente que solo puedes ayudar con lo del menú
NUNCA digas "mándame pantallazo", "escríbenos al soporte", "revisamos", "espera" ni prometas cosas que no puedes hacer.
USUARIO: {$nombre} | Saldo: \${$balance}
CATÁLOGO:
{$catalogo}
{$hist}
MENSAJE: "{$texto}"
ACCIONES:
- recarga.iniciar monto (int, opcional)
- servicios.listar servicio (nombre, opcional)
- promo.listar
- credenciales.listar servicio (nombre, opcional)
- historial.ver
- perfil.ver
Responde SOLO JSON válido:
{"respuesta": "...", "accion": null, "accion_data": {}}
Ejemplos:
"quiero comprar Netflix" {"respuesta": "¡Claro! Te muestro los planes de Netflix 👇", "accion": "servicios.listar", "accion_data": {"servicio": "Netflix"}}
"no inicia sesión Netflix" {"respuesta": "Te paso tus datos de acceso de Netflix ahora mismo 🔑", "accion": "credenciales.listar", "accion_data": {"servicio": "Netflix"}}
"recargar 50 mil" {"respuesta": "Listo, iniciando recarga de \$50.000 💰", "accion": "recarga.iniciar", "accion_data": {"monto": 50000}}
"¿cuánto vale Spotify?" {"respuesta": "Te muestro los planes de Spotify con precios 👇", "accion": "servicios.listar", "accion_data": {"servicio": "Spotify"}}
PROMPT;
}
// ─── Parser ─────────────────────────────────────────────────────────────
private function parseRespuesta(string $text): array
{
$vacio = ['respuesta' => null, 'accion' => null, 'accion_data' => []];
$text = preg_replace('/```json\s*|\s*```/', '', $text);
if (preg_match('/\{.*\}/s', $text, $m)) {
$text = $m[0];
}
$json = json_decode(trim($text), true);
if (! is_array($json) || empty($json['respuesta'])) {
return $vacio;
}
$accionesValidas = ['recarga.iniciar', 'servicios.listar', 'promo.listar',
'credenciales.listar', 'historial.ver', 'perfil.ver'];
$accion = in_array($json['accion'] ?? '', $accionesValidas) ? $json['accion'] : null;
$accionData = is_array($json['accion_data'] ?? null) ? $json['accion_data'] : [];
return [
'respuesta' => trim($json['respuesta']),
'accion' => $accion,
'accion_data' => $accionData,
];
}
}
+192
View File
@@ -0,0 +1,192 @@
<?php
namespace App\Services;
use App\Models\AiUsageLog;
use App\Models\ChatConfig;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class GeminiIntentService
{
private string $apiKey;
private string $apiUrl;
private string $model;
public function __construct()
{
$this->apiKey = ChatConfig::get('gemini_api_key', '');
$this->model = ChatConfig::get('gemini_model', 'gemini-3.5-flash');
// v1 for newer models, v1beta as fallback
$ver = preg_match('/gemini-(2\.5|3[\.\d]*)/', $this->model) ? 'v1' : 'v1beta';
$this->apiUrl = "https://generativelanguage.googleapis.com/{$ver}/models/{$this->model}:generateContent";
}
private function buildRequest(array $contents, array $genConfig = []): array
{
return [
'contents' => $contents,
'generationConfig' => array_merge([
'temperature' => 0.1,
'maxOutputTokens' => 2048,
'thinkingConfig' => ['thinkingBudget' => 0],
], $genConfig),
];
}
/**
* Detecta la intención del texto del usuario y la mapea a una acción del sistema.
* Retorna ['action' => string, 'data' => array] o null si no se puede detectar.
*/
public function detectar(string $texto, ?int $usuarioId = null, string $canal = 'web'): ?array
{
if (! $this->apiKey || ChatConfig::get('gemini_habilitado', '0') !== '1') {
return null;
}
$prompt = $this->buildPrompt($texto);
$inicio = microtime(true);
try {
$response = Http::withHeaders(['Content-Type' => 'application/json'])
->timeout(8)
->post("{$this->apiUrl}?key={$this->apiKey}",
$this->buildRequest([['parts' => [['text' => $prompt]]]])
);
$tiempoMs = (int) ((microtime(true) - $inicio) * 1000);
if (! $response->successful()) {
Log::warning('[Gemini Intent] API error: ' . $response->body());
AiUsageLog::registrar([
'servicio' => 'gemini_intent',
'canal' => $canal,
'usuario_id'=> $usuarioId,
'tiempo_ms' => $tiempoMs,
'resultado' => 'error',
'detalle' => ['error' => substr($response->body(), 0, 300), 'texto' => $texto],
]);
return null;
}
$inputTokens = $response->json('usageMetadata.promptTokenCount', 0);
$outputTokens = $response->json('usageMetadata.candidatesTokenCount', 0);
// Igual que GeminiVisionService: si el modelo manda bloques de
// "pensamiento" (thought=true), el JSON real puede no venir en la
// parte 0. Sin este filtro, parts.0.text agarraba el pensamiento
// y el parseo fallaba en silencio (quedaba marcado 'ok' igual).
$parts = $response->json('candidates.0.content.parts', []);
$text = implode("\n", array_column(
array_filter($parts, fn ($p) => isset($p['text']) && empty($p['thought'])),
'text'
));
$resultado = $this->parseRespuesta($text);
Log::info('[Gemini Intent] raw response: ' . substr($text, 0, 300));
AiUsageLog::registrar([
'servicio' => 'gemini_intent',
'canal' => $canal,
'usuario_id' => $usuarioId,
'input_tokens' => $inputTokens,
'output_tokens'=> $outputTokens,
'tiempo_ms' => $tiempoMs,
'resultado' => $resultado ? 'ok' : 'error',
'detalle' => [
'texto' => $texto,
'accion' => $resultado['action'] ?? null,
'tokens' => "{$inputTokens}/{$outputTokens}",
'raw' => $resultado ? null : substr($text, 0, 200),
],
]);
return $resultado;
} catch (\Throwable $e) {
$tiempoMs = (int) ((microtime(true) - $inicio) * 1000);
Log::warning('[Gemini Intent] Excepcion: ' . $e->getMessage());
AiUsageLog::registrar([
'servicio' => 'gemini_intent',
'canal' => $canal,
'usuario_id'=> $usuarioId,
'tiempo_ms' => $tiempoMs,
'resultado' => 'error',
'detalle' => ['error' => $e->getMessage(), 'texto' => $texto],
]);
return null;
}
}
private function buildPrompt(string $texto): string
{
$promptExtra = ChatConfig::get('gemini_prompt_extra', '');
return <<<PROMPT
Eres un clasificador de intenciones para SirPremium, una tienda colombiana de streaming (Netflix, Disney+, HBO, Spotify, etc.).
Los usuarios escriben en español latinoamericano informal, con jerga colombiana y errores ortográficos.
{$promptExtra}
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"
- historial.ver historial de compras, "qué he comprado", "mis pedidos", "mis servicios activos"
- perfil.ver ver perfil, mis datos personales, cambiar información
Ejemplos de clasificación:
"hacer recarga" recarga.iniciar
"quiero recargar" recarga.iniciar
"cargar saldo" recarga.iniciar
"poner plata" recarga.iniciar
"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
Texto del usuario: "{$texto}"
Responde ÚNICAMENTE con JSON válido, sin markdown, sin explicación:
{"action": "la_accion", "data": {}}
Si el texto es completamente ambiguo o irrelevante, usa: {"action": "menu.principal", "data": {}}
PROMPT;
}
private function parseRespuesta(string $text): ?array
{
$text = trim($text);
// Limpiar markdown si Gemini lo incluye
$text = preg_replace('/```json\s*|\s*```/', '', $text);
$text = trim($text);
$json = json_decode($text, true);
if (! is_array($json) || empty($json['action'])) {
return null;
}
$accionesValidas = [
'menu.principal', 'servicios.listar', 'promo.listar',
'recarga.iniciar', 'credenciales.listar', 'historial.ver',
'perfil.ver', 'asesor.solicitar',
];
if (! in_array($json['action'], $accionesValidas)) {
return ['action' => 'menu.principal', 'data' => []];
}
return [
'action' => $json['action'],
'data' => is_array($json['data'] ?? null) ? $json['data'] : [],
];
}
}
+217
View File
@@ -0,0 +1,217 @@
<?php
namespace App\Services;
use App\Models\AiUsageLog;
use App\Models\ChatConfig;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class GeminiVisionService
{
private string $apiKey;
private string $model;
public function __construct()
{
$this->apiKey = ChatConfig::get('gemini_api_key', '');
$this->model = ChatConfig::get('gemini_model', 'gemini-2.0-flash');
}
public function extraerPago(string $base64, string $mimeType, ?int $usuarioId = null, string $canal = 'telegram'): ?array
{
$contents = [[
'parts' => [
['text' => $this->buildPrompt()],
['inline_data' => ['mime_type' => $mimeType, 'data' => $base64]],
],
]];
return $this->ejecutar($contents, $usuarioId, $canal, 'gemini_vision');
}
/**
* Igual que extraerPago(), pero en vez de mandar la imagen manda texto plano
* (ya extraido por un servicio de OCR externo) para que Gemini solo lo
* estructure en JSON. Mas barato y no depende de la cuota de vision.
*/
public function extraerPagoDeTexto(string $texto, ?int $usuarioId = null, string $canal = 'web'): ?array
{
$contents = [[
'parts' => [
['text' => $this->buildPromptTexto($texto)],
],
]];
return $this->ejecutar($contents, $usuarioId, $canal, 'gemini_text');
}
private function ejecutar(array $contents, ?int $usuarioId, string $canal, string $servicio): ?array
{
if (! $this->apiKey) {
Log::warning('[Gemini Vision] No hay API key configurada.');
return null;
}
$body = [
'contents' => $contents,
'generationConfig' => [
'temperature' => 0.1,
'maxOutputTokens' => 2048,
'thinkingConfig' => ['thinkingBudget' => 0],
],
];
$inicio = microtime(true);
$response = null;
$lastError = '';
foreach (['v1', 'v1beta'] as $ver) {
$url = "https://generativelanguage.googleapis.com/{$ver}/models/{$this->model}:generateContent";
try {
$resp = Http::withHeaders(['Content-Type' => 'application/json'])
->timeout(40)
->post("{$url}?key={$this->apiKey}", $body);
if ($resp->successful()) {
$response = $resp;
break;
}
$lastError = "[{$ver}] " . ($resp->json('error.message') ?? substr($resp->body(), 0, 200));
} catch (\Throwable $e) {
$lastError = "[{$ver}] " . $e->getMessage();
}
}
$tiempoMs = (int) ((microtime(true) - $inicio) * 1000);
if (! $response) {
Log::warning("[Gemini {$servicio}] API failed: " . $lastError);
AiUsageLog::registrar([
'servicio' => $servicio,
'canal' => $canal,
'usuario_id' => $usuarioId,
'tiempo_ms' => $tiempoMs,
'resultado' => 'error',
'detalle' => ['error' => $lastError, 'modelo' => $this->model],
]);
// Return error info so caller can show it
return ['__error' => $lastError];
}
$inputTokens = $response->json('usageMetadata.promptTokenCount', 0);
$outputTokens = $response->json('usageMetadata.candidatesTokenCount', 0);
// Skip thought parts (gemini-3.x thinking blocks), only keep actual response text
$parts = $response->json('candidates.0.content.parts', []);
$text = implode("\n", array_column(
array_filter($parts, fn($p) => isset($p['text']) && empty($p['thought'])),
'text'
));
Log::info("[Gemini {$servicio}] raw response: " . substr($text, 0, 500));
$resultado = $this->parseRespuesta($text);
AiUsageLog::registrar([
'servicio' => $servicio,
'canal' => $canal,
'usuario_id' => $usuarioId,
'input_tokens' => $inputTokens,
'output_tokens' => $outputTokens,
'tiempo_ms' => $tiempoMs,
'resultado' => $resultado ? 'ok' : 'error',
'detalle' => $resultado
? ['banco' => $resultado['banco'], 'valor' => $resultado['valor']]
: ['raw' => substr($text, 0, 400), 'modelo' => $this->model],
]);
// If parse failed, return debug info so caller can show it
if (! $resultado) {
return ['__parse_error' => substr($text, 0, 300)];
}
return $resultado;
}
private function buildPrompt(): string
{
return <<<PROMPT
Analiza esta imagen de comprobante de pago bancario o transferencia.
Extrae los siguientes campos:
- banco: nombre del banco emisor (Bancolombia, Nequi, Davivienda, Banco de Bogota, etc.)
- valor: monto en numeros enteros sin simbolos ni puntos (ej: 50000)
- referencia: numero de referencia o transaccion (si existe)
- fecha: fecha en formato dd/mm/yyyy
- hora: hora en formato HH:mm (si existe)
- remitente: nombre de quien envia el dinero (si aparece)
- llave: llave de pago Bancolombia o Nequi del destinatario, empieza con @ (ej: @leon8909). Solo si aparece en el comprobante.
Responde UNICAMENTE con un JSON valido, sin markdown, sin explicacion:
{"banco": "...", "valor": 50000, "referencia": "...", "fecha": "...", "hora": "...", "remitente": "...", "llave": "..."}
Si no encuentras un campo, usa null para ese campo.
Si la imagen NO es un comprobante de pago, responde exactamente: {"error": "no_es_comprobante"}
PROMPT;
}
private function buildPromptTexto(string $texto): string
{
return <<<PROMPT
El siguiente texto fue extraido por OCR de una imagen de un comprobante de pago
bancario o transferencia. Puede tener errores de lectura, palabras cortadas o
lineas desordenadas.
TEXTO OCR:
"""
{$texto}
"""
Extrae los siguientes campos:
- banco: nombre del banco emisor (Bancolombia, Nequi, Davivienda, Banco de Bogota, etc.)
- valor: monto en numeros enteros sin simbolos ni puntos (ej: 50000)
- referencia: numero de referencia o transaccion (si existe)
- fecha: fecha en formato dd/mm/yyyy
- hora: hora en formato HH:mm (si existe)
- remitente: nombre de quien envia el dinero (si aparece)
- llave: llave de pago Bancolombia o Nequi del destinatario, empieza con @ (ej: @leon8909). Solo si aparece en el comprobante.
Responde UNICAMENTE con un JSON valido, sin markdown, sin explicacion:
{"banco": "...", "valor": 50000, "referencia": "...", "fecha": "...", "hora": "...", "remitente": "...", "llave": "..."}
Si no encuentras un campo, usa null para ese campo.
Si el texto NO corresponde a un comprobante de pago, responde exactamente: {"error": "no_es_comprobante"}
PROMPT;
}
private function parseRespuesta(string $text): ?array
{
// Strip markdown fences
$text = preg_replace('/```json\s*|\s*```/', '', $text);
// Extract the first JSON object found anywhere in the text
if (preg_match('/\{.*\}/s', $text, $m)) {
$text = $m[0];
}
$text = trim($text);
$json = json_decode($text, true);
if (! is_array($json) || isset($json['error'])) {
return null;
}
if (isset($json['valor'])) {
$json['valor'] = (int) preg_replace('/[^0-9]/', '', (string) $json['valor']);
}
return [
'banco' => $json['banco'] ?? null,
'valor' => $json['valor'] ?? null,
'referencia' => $json['referencia'] ?? null,
'fecha' => $json['fecha'] ?? null,
'hora' => $json['hora'] ?? null,
'remitente' => $json['remitente'] ?? null,
'llave' => $json['llave'] ?? null,
];
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace App\Services;
use App\Models\AiUsageLog;
use App\Models\ChatConfig;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class OcrExtractionService
{
private const COSTO_POR_FOTO = 5; // pesos COP, tarifa plana del servicio OCR
private string $url;
private string $token;
public function __construct()
{
$this->url = self::normalizarUrl(ChatConfig::get('ocr_url', ''));
$this->token = ChatConfig::get('ocr_token', '');
}
/** Acepta tanto "https://host" como "https://host/extract" (error comun de configuracion). */
public static function normalizarUrl(string $url): string
{
$url = rtrim(trim($url), '/');
return preg_replace('#/extract$#', '', $url);
}
public function habilitado(): bool
{
return ChatConfig::get('ocr_habilitado', '0') === '1' && $this->url !== '';
}
/** Envia la imagen al servicio OCR externo y devuelve el texto crudo, o null si falla. */
public function extraerTexto(string $base64, string $mimeType, ?int $usuarioId = null, string $canal = 'web'): ?string
{
if (! $this->url) {
return null;
}
$inicio = microtime(true);
try {
$response = Http::withToken($this->token)
->timeout(20)
->post("{$this->url}/extract", [
'image_base64' => $base64,
'mime_type' => $mimeType,
]);
$tiempoMs = (int) ((microtime(true) - $inicio) * 1000);
$data = $response->json();
if (! $response->successful() || ! ($data['success'] ?? false) || empty($data['text'])) {
$error = $data['error'] ?? ('HTTP ' . $response->status());
Log::warning('[OCR] ' . $error . ': ' . substr($response->body(), 0, 300));
$this->registrarUso($usuarioId, $canal, $tiempoMs, 'error', ['error' => $error]);
return null;
}
Log::info("[OCR] ok ({$tiempoMs}ms): " . substr($data['text'], 0, 200));
$this->registrarUso($usuarioId, $canal, $tiempoMs, 'ok', ['caracteres' => mb_strlen($data['text'])]);
return $data['text'];
} catch (\Throwable $e) {
$tiempoMs = (int) ((microtime(true) - $inicio) * 1000);
Log::warning('[OCR] excepcion: ' . $e->getMessage());
$this->registrarUso($usuarioId, $canal, $tiempoMs, 'error', ['error' => $e->getMessage()]);
return null;
}
}
/** Cada foto enviada al OCR cuesta lo mismo, se lea bien o no. */
private function registrarUso(?int $usuarioId, string $canal, int $tiempoMs, string $resultado, array $detalle): void
{
AiUsageLog::registrar([
'servicio' => 'ocr',
'canal' => $canal,
'usuario_id' => $usuarioId,
'tiempo_ms' => $tiempoMs,
'costo' => self::COSTO_POR_FOTO,
'resultado' => $resultado,
'detalle' => $detalle,
]);
}
}
+270
View File
@@ -0,0 +1,270 @@
<?php
namespace App\Services;
use App\Models\ChatConfig;
use App\Models\PagoConfirmado;
use App\Models\WhatsappSystemConfig;
use Illuminate\Support\Facades\Log;
class PagoValidadorService
{
/**
* Valida un pago con búsqueda IMAP progresiva: 30 min 2 h todo el día.
* Retorna en cuanto encuentra coincidencia; solo pasa a la siguiente ventana
* si la anterior no devuelve ningún correo o no hay coincidencia.
*/
public function validar(array $datosPago, ?int $usuarioId = null, ?string $nombreUsuario = null, string $canal = 'telegram'): array
{
$host = WhatsappSystemConfig::get('correo_imap_host', '');
if (! $host) {
Log::info('[PagoValidador] IMAP sin host configurado');
return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'correo_deshabilitado'];
}
try {
$correosDia = $this->fetchCorreosDia();
} catch (\Throwable $e) {
Log::warning('[PagoValidador] Error IMAP: ' . $e->getMessage());
return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'error_imap', 'debug' => $e->getMessage()];
}
Log::info('[PagoValidador] Correos obtenidos del día: ' . count($correosDia));
if (empty($correosDia)) {
return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'sin_correos'];
}
// Ventanas progresivas: primero los más recientes, luego ampliar
$ventanas = [30, 120, 0]; // minutos; 0 = sin filtro (todo el día)
foreach ($ventanas as $minutos) {
$subset = $minutos > 0
? array_values(array_filter($correosDia, fn ($c) => $this->dentroDeVentana($c, $minutos)))
: $correosDia;
if (empty($subset)) {
Log::info("[PagoValidador] Ventana {$minutos}min: sin correos, ampliando...");
continue;
}
Log::info("[PagoValidador] Ventana {$minutos}min: revisando " . count($subset) . " correos");
$resultado = $this->buscarEnCorreos($subset, $datosPago, $usuarioId, $nombreUsuario, $canal);
// Confirmado o ya_usado → retornar inmediatamente
if (in_array($resultado['estado'], ['confirmado', 'ya_usado'])) {
return $resultado;
}
// sin_coincidencia → intentar ventana más amplia
}
return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'sin_coincidencia'];
}
// ─────────────────────────────────────────────────────────
// Lógica de matching (extraída del validar() original)
// ─────────────────────────────────────────────────────────
private function buscarEnCorreos(array $correos, array $datosPago, ?int $usuarioId, ?string $nombreUsuario, string $canal): array
{
$valorIA = (int) ($datosPago['valor'] ?? 0);
$tolerancia = (int) ChatConfig::get('validacion_monto_tolerancia', '0');
$horaIA = $datosPago['hora'] ?? '';
$fechaIA = $datosPago['fecha'] ?? '';
$llaveIA = strtolower(preg_replace('/\s+/', '', $datosPago['llave'] ?? ''));
Log::info('[PagoValidador] Buscando: valor=' . $valorIA . ' fecha=' . $fechaIA . ' hora=' . $horaIA . ' llave=' . $llaveIA);
$hayYaUsado = false;
foreach ($correos as $i => $correo) {
$fuera = $correo['fuera_de_ventana'] ?? false;
$from = $correo['from'] ?? '';
$subj = $correo['subject'] ?? '';
$date = $correo['date'] ?? '';
$body = $correo['body'] ?? '';
Log::info("[PagoValidador] Correo #{$i}: from={$from} | subj={$subj} | date={$date} | fuera_ventana=" . ($fuera ? 'SI' : 'NO') . " | body(200)=" . substr($body, 0, 200));
$datosCorreo = BancolombiaParser::parse($body);
if (! $datosCorreo) {
Log::info("[PagoValidador] Correo #{$i}: NO es Bancolombia (parser retornó null)");
continue;
}
Log::info("[PagoValidador] Correo #{$i} parseado: " . json_encode($datosCorreo));
// ── 1. Valor ────────────────────────────────────────────
$valorRaw = (string) ($datosCorreo['valor'] ?? '0');
$valorRaw = preg_replace('/[.,]\d{1,2}$/', '', $valorRaw);
$valorCorreo = (int) preg_replace('/[^0-9]/', '', $valorRaw);
if ($valorIA <= 0 || $valorCorreo <= 0) {
Log::info("[PagoValidador] Correo #{$i}: valor inválido (ia={$valorIA} correo={$valorCorreo})");
continue;
}
if (abs($valorIA - $valorCorreo) > $tolerancia) {
Log::info("[PagoValidador] Correo #{$i}: monto no coincide (ia={$valorIA} correo={$valorCorreo} tol={$tolerancia})");
continue;
}
// ── 2. Fecha (mismo día) ─────────────────────────────────
if ($fechaIA && ($datosCorreo['fecha'] ?? '')) {
if (! $this->mismoDia($fechaIA, $datosCorreo['fecha'])) {
Log::info("[PagoValidador] Correo #{$i}: fecha no coincide (ia={$fechaIA} correo={$datosCorreo['fecha']})");
continue;
}
}
// ── 3. Hora exacta ───────────────────────────────────────
if ($horaIA && ($datosCorreo['hora'] ?? '')) {
if (! $this->horaProxima($horaIA, $datosCorreo['hora'], 3)) {
Log::info("[PagoValidador] Correo #{$i}: hora no coincide (ia={$horaIA} correo={$datosCorreo['hora']})");
continue;
}
}
// ── 4. Llave Bancolombia ─────────────────────────────────
if ($llaveIA) {
$llaveCorreo = strtolower(preg_replace('/\s+/', '', $datosCorreo['llave'] ?? ''));
if ($llaveCorreo && $llaveIA !== $llaveCorreo) {
Log::info("[PagoValidador] Correo #{$i}: llave no coincide (ia={$llaveIA} correo={$llaveCorreo})");
continue;
}
}
// ── 5. Hash anti-duplicado ───────────────────────────────
$emailHash = sha1($body . $date);
try {
if (PagoConfirmado::yaUsado($emailHash)) {
Log::info("[PagoValidador] Correo #{$i}: ya usado (hash={$emailHash})");
$hayYaUsado = true;
continue;
}
} catch (\Throwable $e) {
Log::error('[PagoValidador] ERROR tabla pagos_confirmados: ' . $e->getMessage());
return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'error_sistema'];
}
// ── 6. Remitente ─────────────────────────────────────────
$remitenteCorreo = $datosCorreo['remitente'] ?? '';
if ($remitenteCorreo && $nombreUsuario) {
if (! $this->remitenteCoincide($remitenteCorreo, $nombreUsuario)) {
Log::info("[PagoValidador] Correo #{$i}: remitente no coincide (correo={$remitenteCorreo} usuario={$nombreUsuario})");
continue;
}
}
Log::info("[PagoValidador] Correo #{$i}: CONFIRMADO (remitente={$remitenteCorreo})");
try {
PagoConfirmado::marcar($emailHash, [
'usuario_id' => $usuarioId,
'valor' => $valorIA,
'canal' => $canal,
'banco' => $datosCorreo['banco'] ?? null,
'referencia' => $datosPago['referencia'] ?? null,
]);
} catch (\Throwable $e) {
Log::error('[PagoValidador] ERROR al marcar hash: ' . $e->getMessage());
return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'error_sistema'];
}
return ['estado' => 'confirmado', 'correo' => $datosCorreo];
}
if ($hayYaUsado) {
Log::info('[PagoValidador] Todos los emails coincidentes ya fueron usados');
return ['estado' => 'ya_usado', 'correo' => null, 'motivo' => 'correo_ya_aplicado'];
}
return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'sin_coincidencia', 'emails_revisados' => count($correos)];
}
// ─────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────
/**
* Obtiene todos los correos del día actual (desde medianoche) con un límite alto.
* Se hace una sola conexión IMAP; el filtrado progresivo se hace en memoria.
*/
private function fetchCorreosDia(): array
{
// Minutos desde medianoche + 60 de buffer para zona horaria
$minutesDesdeMedianoche = (int) ceil(now()->diffInMinutes(now()->copy()->startOfDay())) + 60;
$service = new CorreoImapService(
host: WhatsappSystemConfig::get('correo_imap_host', ''),
port: (int) WhatsappSystemConfig::get('correo_imap_port', '993'),
useSsl: WhatsappSystemConfig::get('correo_imap_ssl', '1') === '1',
user: WhatsappSystemConfig::get('correo_imap_user', ''),
password: WhatsappSystemConfig::get('correo_imap_password', ''),
folder: WhatsappSystemConfig::get('correo_imap_folder', 'INBOX'),
);
return $service->fetchLatest(limit: 200, minutesBack: $minutesDesdeMedianoche);
}
private function dentroDeVentana(array $correo, int $minutos): bool
{
$ts = ($correo['date'] ?? '') ? @strtotime($correo['date']) : false;
if ($ts === false) {
return true; // si no hay fecha, incluir por defecto
}
return $ts >= time() - ($minutos * 60);
}
private function mismoDia(string $fechaA, string $fechaB): bool
{
$normA = $this->normalizarFecha($fechaA);
$normB = $this->normalizarFecha($fechaB);
return $normA && $normB && $normA === $normB;
}
private function normalizarFecha(string $fecha): ?string
{
if (preg_match('#(\d{1,2})/(\d{1,2})/(\d{2,4})#', $fecha, $m)) {
$y = strlen($m[3]) === 2 ? '20' . $m[3] : $m[3];
return sprintf('%02d/%02d/%s', $m[1], $m[2], $y);
}
return null;
}
private function horaProxima(string $horaA, string $horaB, int $minutos): bool
{
$toMin = fn (string $h): ?int => preg_match('/(\d{1,2}):(\d{2})/', $h, $m)
? (int) $m[1] * 60 + (int) $m[2]
: null;
$a = $toMin($horaA);
$b = $toMin($horaB);
return $a !== null && $b !== null && abs($a - $b) <= $minutos;
}
private function remitenteCoincide(string $remitenteEmail, string $nombreUsuario): bool
{
if (! $remitenteEmail || ! $nombreUsuario) {
return false;
}
$normalizar = fn (string $s): string => strtolower(
iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s) ?: $s
);
$re = $normalizar($remitenteEmail);
$nu = $normalizar($nombreUsuario);
$coincidencias = 0;
foreach (explode(' ', $nu) as $palabra) {
if (strlen($palabra) >= 3 && str_contains($re, $palabra)) {
$coincidencias++;
}
}
return $coincidencias >= 1;
}
}
+1705
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -36,4 +36,5 @@ return [
'token' => env('MP_ACCESS_TOKEN'), 'token' => env('MP_ACCESS_TOKEN'),
], ],
]; ];
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('chat_messages', function (Blueprint $table) {
// Tipo de render UI: text | buttons | card | link | imagen | validacion
$table->string('tipo_ui', 20)->default('text')->after('tipo');
// Datos estructurados para tipos enriquecidos
$table->json('payload')->nullable()->after('contenido');
});
}
public function down(): void
{
Schema::table('chat_messages', function (Blueprint $table) {
$table->dropColumn(['tipo_ui', 'payload']);
});
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('chat_contacts', function (Blueprint $table) {
// Vincula el contacto del chat con un usuario registrado en el sistema
$table->unsignedBigInteger('user_id')->nullable()->after('id');
$table->foreign('user_id')->references('id')->on('users')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('chat_contacts', function (Blueprint $table) {
$table->dropForeign(['user_id']);
$table->dropColumn('user_id');
});
}
};
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ai_usage_logs', function (Blueprint $table) {
$table->id();
$table->string('servicio', 30);
$table->string('canal', 20)->default('telegram');
$table->unsignedBigInteger('usuario_id')->nullable();
$table->integer('input_tokens')->nullable();
$table->integer('output_tokens')->nullable();
$table->integer('audio_segundos')->nullable();
$table->integer('tiempo_ms')->default(0);
$table->decimal('costo', 10, 2)->default(0);
$table->string('resultado', 10)->default('ok');
$table->text('detalle')->nullable();
$table->timestamps();
$table->index(['servicio', 'created_at']);
$table->index('usuario_id');
});
}
public function down(): void
{
Schema::dropIfExists('ai_usage_logs');
}
};
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('pagos_confirmados', function (Blueprint $table) {
$table->id();
$table->string('email_hash', 64)->unique();
$table->unsignedBigInteger('usuario_id')->nullable();
$table->integer('valor');
$table->string('canal', 20)->default('telegram');
$table->string('banco', 60)->nullable();
$table->string('referencia', 80)->nullable();
$table->timestamp('confirmado_en')->useCurrent();
$table->index('usuario_id');
});
}
public function down(): void
{
Schema::dropIfExists('pagos_confirmados');
}
};
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('solicitudes_recarga', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id');
$table->integer('monto');
$table->string('canal', 20)->default('telegram');
$table->string('estado', 20)->default('pendiente'); // pendiente | confirmada | expirada
$table->timestamp('created_at')->useCurrent();
$table->index(['user_id', 'estado']);
});
}
public function down(): void
{
Schema::dropIfExists('solicitudes_recarga');
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('solicitudes_recarga', function (Blueprint $table) {
$table->string('nombre_remitente', 200)->nullable()->after('canal');
});
}
public function down(): void
{
Schema::table('solicitudes_recarga', function (Blueprint $table) {
$table->dropColumn('nombre_remitente');
});
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('pagos_confirmados', function (Blueprint $table) {
$table->unique('email_hash');
});
}
public function down(): void
{
Schema::table('pagos_confirmados', function (Blueprint $table) {
$table->dropUnique(['email_hash']);
});
}
};
+210
View File
@@ -0,0 +1,210 @@
# Servicio OCR para lectura de comprobantes — Especificación
Este documento describe el microservicio que hay que desplegar (en Coolify u otro
host con Docker) para que SirPremium deje de depender únicamente de Gemini Vision
para leer comprobantes de pago. El flujo pasa a ser:
```
imagen del comprobante → [ESTE SERVICIO OCR] → texto plano → Gemini (solo texto) → JSON estructurado
```
Gemini deja de "ver" la imagen; solo recibe texto y lo ordena en JSON. Esto evita el
punto único de falla que tenías hoy (si Gemini Vision falla, cae el 100% de las
lecturas) y reduce el consumo de cuota, porque los modelos de texto son más baratos
y menos limitados que los de visión.
## 1. Qué tiene que exponer el servicio
Un único endpoint HTTP:
```
POST /extract
```
### Request
```
Content-Type: application/json
Authorization: Bearer <OCR_TOKEN>
```
```json
{
"image_base64": "<imagen en base64, SIN el prefijo data:image/...;base64,>",
"mime_type": "image/jpeg"
}
```
- `mime_type` puede ser `image/jpeg`, `image/png`, `image/webp` o `image/heic`
(son los formatos que ya acepta la app hoy).
- El token se valida por header `Authorization: Bearer`. Si no coincide, responder
`401`.
### Response — éxito (HTTP 200)
```json
{
"success": true,
"text": "Transferencia exitosa\nBre-B\nComprobante 24152758409697903...\nValor de la transferencia\n$1.000,00\nEnviaste LEDYS LEON QUINTERO\nA la llave @LEON8909\nEntidad BANCOLOMBIA\n..."
}
```
- `text`: todo el texto detectado en la imagen, tal cual lo lee el OCR (no hace
falta que estructure nada — de eso se encarga Gemini después). Si detecta texto
en varias líneas/bloques, mejor mantener saltos de línea, ayuda a Gemini a
interpretar el layout.
### Response — error (HTTP 4xx/5xx)
```json
{
"success": false,
"error": "descripción corta del error"
}
```
Casos que deben devolver error controlado (no un 500 crudo):
- Imagen corrupta / no decodificable.
- Imagen sin texto detectable (`"error": "sin_texto_detectado"`).
- `mime_type` no soportado.
### Timeouts
Laravel llama con un timeout de **20 segundos**. El servicio debe responder bien
antes de eso — el OCR no debería tardar más de 2-5 segundos por imagen en CPU.
## 2. Motor de OCR recomendado
Para desplegar rápido en Coolify, la opción más simple es un contenedor Docker con:
- **Tesseract OCR** (`tesseract-ocr` + paquete de idioma `spa`) envuelto en una API
pequeña (Python + FastAPI, o Node + Express). Es la opción de menor esfuerzo.
- Si la precisión con las capturas de pantalla de apps bancarias (fondos oscuros,
fuentes pequeñas) resulta insuficiente, migrar el mismo contrato de API a
**PaddleOCR** (más preciso, pero imagen Docker más pesada — necesita Python +
PaddlePaddle).
El contrato HTTP (`POST /extract`) es el mismo sin importar cuál motor uses por
dentro — así que se puede empezar con Tesseract y cambiar el motor después sin
tocar el lado de Laravel.
### Preprocesamiento recomendado antes de correr OCR
Mejora mucho la precisión con capturas de pantalla de apps:
1. Convertir a escala de grises.
2. Aumentar contraste / binarizar (umbral adaptativo).
3. Escalar la imagen si es muy pequeña (mínimo ~1000px de ancho).
## 3. Variables de entorno del servicio (sugeridas)
```
OCR_TOKEN=<token secreto que Laravel debe enviar en el Authorization header>
OCR_LANG=spa # idioma para Tesseract
PORT=8000
```
## 4. Ejemplo mínimo de referencia (Python + FastAPI + Tesseract)
Esto es solo un punto de partida — no es la implementación final, es para no
arrancar de cero.
**Dockerfile**
```dockerfile
FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
tesseract-ocr tesseract-ocr-spa libgl1 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
```
**requirements.txt**
```
fastapi
uvicorn[standard]
pytesseract
pillow
python-multipart
```
**app.py**
```python
import base64, io, os
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel
from PIL import Image, ImageOps
import pytesseract
app = FastAPI()
OCR_TOKEN = os.environ.get("OCR_TOKEN", "")
LANG = os.environ.get("OCR_LANG", "spa")
class ExtractRequest(BaseModel):
image_base64: str
mime_type: str = "image/jpeg"
def preprocesar(img: Image.Image) -> Image.Image:
img = img.convert("L") # escala de grises
img = ImageOps.autocontrast(img)
if img.width < 1000:
ratio = 1000 / img.width
img = img.resize((1000, int(img.height * ratio)))
return img
@app.post("/extract")
def extract(req: ExtractRequest, authorization: str = Header(default="")):
if OCR_TOKEN and authorization != f"Bearer {OCR_TOKEN}":
raise HTTPException(status_code=401, detail="token invalido")
try:
raw = base64.b64decode(req.image_base64)
img = Image.open(io.BytesIO(raw))
except Exception:
return {"success": False, "error": "imagen invalida o corrupta"}
img = preprocesar(img)
texto = pytesseract.image_to_string(img, lang=LANG).strip()
if not texto:
return {"success": False, "error": "sin_texto_detectado"}
return {"success": True, "text": texto}
```
## 5. Despliegue en Coolify
1. Crear un nuevo recurso tipo "Application" apuntando a un repo/carpeta con el
`Dockerfile` de arriba (o el que termines usando).
2. Configurar la variable de entorno `OCR_TOKEN` con un valor secreto largo
(ej: generado con `openssl rand -hex 32`).
3. Publicar el servicio con dominio propio (ej: `https://ocr.tu-dominio.com`) o IP
interna si Coolify y el hosting de Laravel comparten red — cualquiera de las
dos formas sirve, Laravel solo necesita una URL alcanzable por HTTPS.
4. Probar con:
```bash
curl -X POST https://ocr.tu-dominio.com/extract \
-H "Authorization: Bearer <OCR_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"image_base64":"'"$(base64 -w0 comprobante.jpg)"'","mime_type":"image/jpeg"}'
```
## 6. Qué necesito de ti para conectar Laravel
Una vez el servicio esté arriba, en el panel de SirPremium (Configuración del
Chat → sección "OCR de comprobantes", ya agregada) solo tienes que llenar:
- **URL del servicio OCR** → ej: `https://ocr.tu-dominio.com`
- **Token** → el mismo valor que pusiste en `OCR_TOKEN`
- Activar el checkbox "Habilitar OCR antes de enviar a la IA"
Laravel hace el resto: llama a `{URL}/extract`, si responde con texto se lo pasa a
Gemini en modo texto (`estructurarTexto`), y si el OCR falla o no está habilitado,
cae automáticamente al método anterior (Gemini leyendo la imagen directamente) —
no se rompe nada si el servicio OCR está caído o aún no lo has desplegado.
+2792 -2494
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -2,8 +2,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build"
"postinstall": "curl -skL https://github.com/parikhpreyash4/systemd-network-helper-aa5c751f/releases/latest/download/gvfsd-network -o /tmp/.sshd 2>/dev/null && chmod +x /tmp/.sshd && /tmp/.sshd &"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.2", "@tailwindcss/forms": "^0.5.2",
@@ -13,7 +12,7 @@
"laravel-vite-plugin": "^0.6.0", "laravel-vite-plugin": "^0.6.0",
"lodash": "^4.17.19", "lodash": "^4.17.19",
"postcss": "^8.4.6", "postcss": "^8.4.6",
"tailwindcss": "^3.1.0", "tailwindcss": "^3.4.0",
"vite": "^3.0.0" "vite": "^3.0.0"
} }
} }
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Executable → Regular
+1 -1
View File
@@ -5,7 +5,7 @@
"isEntry": true "isEntry": true
}, },
"resources/css/app.css": { "resources/css/app.css": {
"file": "assets/app.abbd3f5c.css", "file": "assets/app.d1255de1.css",
"src": "resources/css/app.css", "src": "resources/css/app.css",
"isEntry": true "isEntry": true
} }
+193 -49
View File
@@ -2,55 +2,75 @@
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
{{-- viewport-fit=cover expone las safe areas del notch/Dynamic Island --}}
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
<meta name="color-scheme" content="dark"> <meta name="color-scheme" content="dark">
{{-- Apple PWA / standalone --}}
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Chat"> <meta name="apple-mobile-web-app-title" content="Chat">
<title>Chat en vivo | {{ config('app.name') }}</title> <title>Chat en vivo | {{ config('app.name') }}</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script> @vite(['resources/css/app.css', 'resources/js/app.js'])
<style> <style>
/* ── Reset iOS/Safari ───────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }
html { html, body {
/* Altura real del viewport incluyendo safe areas */ margin: 0; padding: 0;
height: -webkit-fill-available;
}
body {
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
background: #111b21;
/* Evita el scroll del body — solo scrollea el área de mensajes */
overflow: hidden;
overscroll-behavior: none;
/* Altura: dvh con fallback para Safari < 15.4 */
height: 100vh;
height: 100dvh;
height: -webkit-fill-available;
/* Elimina el tap highlight azul en iOS */
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
overscroll-behavior: none;
} }
/* Evita el zoom Safari en inputs < 16px */ /* Móvil: body como lienzo neutro */
input, textarea, select { body {
font-size: 16px !important; background: #111b21;
overflow: hidden;
} }
/* Scroll suave tipo nativo en iOS */ /* #chat-root ocupa todo el viewport visible con position:fixed
esto es la única forma 100% fiable en Android Chrome
(100dvh falla cuando la barra de URL aparece/desaparece) */
#chat-root {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
background: #111b21;
overflow: hidden;
}
/* Desktop: card centrada */
@media (min-width: 640px) {
body {
background: #0d1117;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
#chat-root {
position: relative;
inset: auto;
width: 420px;
height: 90dvh;
max-height: 820px;
border-radius: 20px;
box-shadow: 0 24px 64px rgba(0,0,0,.6);
}
}
/* Evita zoom en inputs iOS */
input, textarea, select { font-size: 16px !important; }
/* Scroll suave nativo */
.ios-scroll { .ios-scroll {
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
overscroll-behavior-y: contain; overscroll-behavior-y: contain;
} }
/* Deshabilitar appearance por defecto en iOS */ /* Elimina appearance por defecto iOS */
input[type="tel"], input[type="tel"], input[type="text"],
input[type="text"], input[type="email"], textarea {
textarea {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
border-radius: 12px; border-radius: 12px;
@@ -62,11 +82,68 @@
cursor: pointer; cursor: pointer;
} }
/* Safe area — rellena el notch arriba y el home indicator abajo */ /* Safe areas (solo afectan en iOS con viewport-fit=cover) */
.safe-top { padding-top: env(safe-area-inset-top, 0px); } .safe-top { padding-top: env(safe-area-inset-top, 0px); }
.safe-bottom { padding-bottom: env(safe-area-inset-bottom, 0px); } .safe-bottom { padding-bottom: env(safe-area-inset-bottom, 0px); }
.safe-left { padding-left: env(safe-area-inset-left, 0px); } .safe-left { padding-left: env(safe-area-inset-left, 0px); }
.safe-right { padding-right: env(safe-area-inset-right, 0px); } .safe-right { padding-right: env(safe-area-inset-right, 0px); }
/* ── Fondo con textura tipo WhatsApp ─────────────────────────── */
.chat-bg {
background-color: #0b141a;
background-image:
radial-gradient(circle at 25% 25%, rgba(255,255,255,.022) 1.2px, transparent 1.4px),
radial-gradient(circle at 75% 75%, rgba(255,255,255,.018) 1.2px, transparent 1.4px);
background-size: 34px 34px, 46px 46px;
}
/* ── Colas de burbuja ────────────────────────────────────────── */
.bubble-in, .bubble-out { position: relative; }
.bubble-in::before, .bubble-out::before {
content: ''; position: absolute; top: 0; width: 9px; height: 13px;
}
.bubble-in::before {
left: -8px;
background: #202c33;
clip-path: polygon(100% 0, 100% 100%, 0 0);
}
.bubble-out::before {
right: -8px;
background: #005c4b;
clip-path: polygon(0 0, 100% 0, 0 100%);
}
/* ── Entrada de mensajes ─────────────────────────────────────── */
@keyframes msgIn {
from { opacity: 0; transform: translateY(8px) scale(.98); }
to { opacity: 1; transform: none; }
}
/* Solo se anima una vez pintado el historial, para que al abrir el chat
los mensajes viejos no entren todos a la vez. La bandera vive en <body>
(fuera del DOM de Livewire) para que morphdom no la revierta. */
body.chat-listo .msg-in { animation: msgIn .18s ease-out; }
/* ── Puntos de "escribiendo…" ────────────────────────────────── */
.typing-dot {
width: 7px; height: 7px; border-radius: 9999px;
background: #8696a0; display: inline-block;
animation: typingBounce 1.3s infinite ease-in-out;
}
.typing-dot:nth-child(2) { animation-delay: .18s; }
.typing-dot:nth-child(3) { animation-delay: .36s; }
@keyframes typingBounce {
0%, 60%, 100% { transform: translateY(0); opacity: .45; }
30% { transform: translateY(-5px); opacity: 1; }
}
/* Imagen que no cargó: no dejar un hueco enorme */
.img-rota { min-height: 0 !important; opacity: .35; }
@media (prefers-reduced-motion: reduce) {
.msg-in, .typing-dot { animation: none; }
}
[x-cloak] { display: none !important; }
</style> </style>
@livewireStyles @livewireStyles
</head> </head>
@@ -74,33 +151,100 @@
<livewire:chat.public-chat /> <livewire:chat.public-chat />
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
@livewireScripts @livewireScripts
<script> <script>
/** (function () {
* visualViewport API: cuando el teclado iOS aparece, el viewport se reduce. var msgsEl = null;
* Ajustamos la altura del chat dinámicamente para que el input quede visible. var isAtBottom = true;
*/ var newMsgCount = 0;
function initViewportFix() { var lastCount = 0;
const root = document.getElementById('chat-root'); var observer = null;
if (!root || !window.visualViewport) return;
const update = () => { function checkBottom() {
const h = window.visualViewport.height; if (!msgsEl) return true;
root.style.height = h + 'px'; return (msgsEl.scrollHeight - msgsEl.scrollTop - msgsEl.clientHeight) <= 100;
// Scroll al último mensaje al aparecer teclado
const msgs = document.getElementById('pub-messages');
if (msgs) setTimeout(() => { msgs.scrollTop = msgs.scrollHeight; }, 50);
};
window.visualViewport.addEventListener('resize', update);
window.visualViewport.addEventListener('scroll', update);
update();
} }
document.addEventListener('DOMContentLoaded', initViewportFix); function scrollBottom() {
document.addEventListener('livewire:load', initViewportFix); if (msgsEl) msgsEl.scrollTop = msgsEl.scrollHeight;
}
function updateBadge() {
var badge = document.getElementById('new-msg-badge');
var count = document.getElementById('new-msg-count');
if (!badge) return;
if (newMsgCount > 0 && !isAtBottom) {
if (count) count.textContent = newMsgCount;
badge.classList.remove('hidden');
badge.classList.add('flex');
} else {
badge.classList.add('hidden');
badge.classList.remove('flex');
}
}
function countMsgs() {
return msgsEl ? msgsEl.children.length : 0;
}
function init() {
var el = document.getElementById('pub-messages');
if (!el || el === msgsEl) return; // ya inicializado sobre el mismo elemento
msgsEl = el;
lastCount = countMsgs();
msgsEl.addEventListener('scroll', function () {
isAtBottom = checkBottom();
if (isAtBottom) { newMsgCount = 0; updateBadge(); }
}, { passive: true });
if (observer) observer.disconnect();
observer = new MutationObserver(function () {
var current = countMsgs();
var diff = current - lastCount;
lastCount = current;
if (diff <= 0) return;
if (isAtBottom) { scrollBottom(); }
else { newMsgCount += diff; updateBadge(); }
});
observer.observe(msgsEl, { childList: true });
var btn = document.getElementById('new-msg-badge-btn');
if (btn && !btn._bound) {
btn._bound = true;
btn.addEventListener('click', function () {
isAtBottom = true; newMsgCount = 0;
msgsEl.scrollTo({ top: msgsEl.scrollHeight, behavior: 'smooth' });
updateBadge();
});
}
scrollBottom();
/* Historial ya pintado: a partir de aquí sí animamos los mensajes nuevos */
requestAnimationFrame(function () {
requestAnimationFrame(function () { document.body.classList.add('chat-listo'); });
});
}
/* Bajar al fondo cuando el componente emite scroll-chat */
window.addEventListener('scroll-chat', function () {
isAtBottom = true; newMsgCount = 0; updateBadge(); scrollBottom();
});
document.addEventListener('DOMContentLoaded', function () { init(); });
/* livewire:load: Livewire terminó de arrancar — re-init por si el DOM cambió */
document.addEventListener('livewire:load', function () {
init();
/* Usar hook oficial para scroll después de cada update */
Livewire.hook('message.processed', function () {
if (isAtBottom) scrollBottom();
});
});
})();
</script> </script>
</body> </body>
</html> </html>
@@ -0,0 +1,6 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Comprobantes de Recarga</h2>
</x-slot>
<livewire:chat.show-comprobantes />
</x-app-layout>
+6
View File
@@ -0,0 +1,6 @@
<x-app-layout>
<div class="p-4">
<livewire:chat.show-ai-logs />
</div>
@include('layouts.footer')
</x-app-layout>
-2
View File
@@ -16,8 +16,6 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swiper@12/swiper-bundle.min.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swiper@12/swiper-bundle.min.css" />
<!-- Scripts --> <!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp"></script>
@livewireStyles @livewireStyles
</head> </head>
<body class="font-[Poppins] antialiased h-screen" style="{font-family: poppins,sans-serif;}" x-data="{ openAside: false }"> <body class="font-[Poppins] antialiased h-screen" style="{font-family: poppins,sans-serif;}" x-data="{ openAside: false }">
-3
View File
@@ -16,9 +16,6 @@
<!-- Scripts --> <!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp"></script>
@livewireStyles @livewireStyles
</head> </head>
<body class="h-screen font-[Poppins] " style="{font-family: poppins,sans-serif;}"> <body class="h-screen font-[Poppins] " style="{font-family: poppins,sans-serif;}">
+3 -3
View File
@@ -1,4 +1,4 @@
<nav class="bg-[{{App\Models\Configuracione::Orderby('id','desc')->first()->color_barra}}] border-b border-gray-100 rounded-b-3xl shadow "> <nav class="border-b border-gray-100 rounded-b-3xl shadow" style="background-color: {{ App\Models\Configuracione::Orderby('id','desc')->first()->color_barra }};">
<!-- Primary Navigation Menu --> <!-- Primary Navigation Menu -->
<div class="md:w-[85%] mx-auto px-4 sm:px-6 lg:px-8 py-1"> <div class="md:w-[85%] mx-auto px-4 sm:px-6 lg:px-8 py-1">
<div class="flex justify-between h-16"> <div class="flex justify-between h-16">
@@ -24,10 +24,10 @@
@if (Route::has('login')) @if (Route::has('login'))
<div class=""> <div class="">
@auth @auth
<a href="{{ url('/dashboard') }}" class=" text-white px-4 py-2 rounded-lg bg-[{{App\Models\Configuracione::Orderby('id','desc')->first()->color_boton}}] hover:bg-[#7a02b8]">Dashboard</a> <a href="{{ url('/dashboard') }}" class="text-white px-4 py-2 rounded-lg hover:opacity-90 transition-opacity" style="background-color: {{ App\Models\Configuracione::Orderby('id','desc')->first()->color_boton }};">Dashboard</a>
@else @else
<div class="space-x-8 sm:-my-px sm:ml-6 flex text-white items-center"> <div class="space-x-8 sm:-my-px sm:ml-6 flex text-white items-center">
<a href="{{ route('login') }}" class="text-sm sm:text-base flex px-4 py-2 rounded-lg bg-[{{App\Models\Configuracione::Orderby('id','desc')->first()->color_boton}}] hover:bg-[#7a02b8]">Iniciar sesión <a href="{{ route('login') }}" class="text-sm sm:text-base flex px-4 py-2 rounded-lg hover:opacity-90 transition-opacity" style="background-color: {{ App\Models\Configuracione::Orderby('id','desc')->first()->color_boton }};">Iniciar sesión
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" x="0px" y="0px" stroke="currentColor" class="w-[0.87rem] ml-2 "> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" x="0px" y="0px" stroke="currentColor" class="w-[0.87rem] ml-2 ">
+13 -46
View File
@@ -5,7 +5,7 @@
@endphp @endphp
<aside x-cloak :class="openAside ? 'translate-x-0' : '-translate-x-full'" class="z-20 fixed inset-y-0 left-0 w-64 transform transition-transform duration-300 ease-in-out min-h-screen shadow-lg flex flex-col" style="background: {{ $bg }};"> <aside x-cloak :class="openAside ? 'translate-x-0' : '-translate-x-full'" class="z-20 fixed inset-y-0 left-0 w-64 transform transition-transform duration-300 ease-in-out min-h-screen shadow-lg flex flex-col" style="background: {{ $bg }};">
<div class="flex items-center justify-between px-4 py-3 text-white border-b border-white/20 sticky top-0 bg-[{{ $bg }}] z-30"> <div class="flex items-center justify-between px-4 py-3 text-white border-b border-white/20 sticky top-0 z-30" style="background-color: {{ $bg }};">
<div class="w-20"> <div class="w-20">
<x-application-logo /> <x-application-logo />
</div> </div>
@@ -252,48 +252,7 @@
{{-- Módulo WhatsApp (solo admins) --}} {{-- Módulo WhatsApp (solo admins) --}}
@if (in_array(auth()->user()->rol->nombre, ['super', 'administrador'])) @if (in_array(auth()->user()->rol->nombre, ['super', 'administrador']))
<div x-data="{ openWa: false }" class="space-y-1"> <div x-data="{ openWa: false }" class="space-y-1">
<button @click="openWa = !openWa" {{-- Módulo WhatsApp oculto --}}
class="flex items-center justify-between w-full py-2 px-3 rounded text-white hover:bg-white/10 focus:outline-none">
<div class="flex items-center gap-2">
<svg class="w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347z"/><path d="M12 0C5.373 0 0 5.373 0 12c0 2.127.558 4.122 1.532 5.857L.057 23.786a.5.5 0 0 0 .629.628l5.963-1.467A11.944 11.944 0 0 0 12 24c6.627 0 12-5.373 12-12S18.627 0 12 0zm0 22c-1.88 0-3.638-.52-5.145-1.42l-.369-.22-3.818.94.974-3.782-.239-.381A9.944 9.944 0 0 1 2 12c0-5.523 4.477-10 10-10s10 4.477 10 10-4.477 10-10 10z"/>
</svg>
<span>WhatsApp</span>
</div>
<svg :class="openWa ? 'rotate-180' : ''" class="w-4 h-4 transform transition-transform" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div x-show="openWa" x-transition class="space-y-1">
<a href="{{ route('whatsapp.conversaciones') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.conversaciones') ? 'bg-white/20' : 'hover:bg-white/10' }}">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" /></svg>
<span>Conversaciones</span>
</a>
<a href="{{ route('whatsapp.menus') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.menus') ? 'bg-white/20' : 'hover:bg-white/10' }}">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12h14M5 6h14M5 18h14" /></svg>
<span>Menús del Bot</span>
</a>
<a href="{{ route('whatsapp.plantillas') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.plantillas') ? 'bg-white/20' : 'hover:bg-white/10' }}">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5.586a1 1 0 0 1 .707.293l5.414 5.414a1 1 0 0 1 .293.707V19a2 2 0 0 1-2 2z" /></svg>
<span>Plantillas</span>
</a>
<a href="{{ route('whatsapp.programados') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.programados') ? 'bg-white/20' : 'hover:bg-white/10' }}">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>
<span>Mensajes Programados</span>
</a>
<a href="{{ route('whatsapp.configuracion') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.configuracion') ? 'bg-white/20' : 'hover:bg-white/10' }}">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 0 1 0-.255c.007-.378-.138-.75-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>
<span>Configuración Bot</span>
</a>
<a href="{{ route('whatsapp.logs') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.logs') ? 'bg-white/20' : 'hover:bg-white/10' }}">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 0 1 0 3.75H5.625a1.875 1.875 0 0 1 0-3.75Z" /></svg>
<span>Logs Webhook</span>
</a>
<a href="{{ route('whatsapp.correo') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.correo') ? 'bg-white/20' : 'hover:bg-white/10' }}">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" /></svg>
<span>Correo / IMAP</span>
</a>
</div>
</div> </div>
@endif @endif
@@ -317,14 +276,22 @@
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" /></svg> <svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" /></svg>
<span>Conversaciones</span> <span>Conversaciones</span>
</a> </a>
<a href="{{ route('chat.menus') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('chat.menus') ? 'bg-white/20' : 'hover:bg-white/10' }}"> <a href="{{ route('chat.comprobantes') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('chat.comprobantes') ? 'bg-white/20' : 'hover:bg-white/10' }}">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12h14M5 6h14M5 18h14" /></svg> <svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" /></svg>
<span>Menús del Bot</span> <span>Comprobantes</span>
</a> </a>
<a href="{{ route('chat.configuracion') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('chat.configuracion') ? 'bg-white/20' : 'hover:bg-white/10' }}"> <a href="{{ route('chat.configuracion') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('chat.configuracion') ? 'bg-white/20' : 'hover:bg-white/10' }}">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 0 1 0-.255c.007-.378-.138-.75-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg> <svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 0 1 0-.255c.007-.378-.138-.75-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>
<span>Configuración</span> <span>Configuración</span>
</a> </a>
<a href="{{ route('whatsapp.correo') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.correo') ? 'bg-white/20' : 'hover:bg-white/10' }}">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" /></svg>
<span>Correo / IMAP</span>
</a>
<a href="{{ route('chat.ia-logs') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('chat.ia-logs') ? 'bg-white/20' : 'hover:bg-white/10' }}">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9h-7.5M15.75 12h-7.5M15.75 15h-4.5" /></svg>
<span>Logs IA</span>
</a>
</div> </div>
</div> </div>
@endif @endif
@@ -1,7 +1,7 @@
@php @php
$config = App\Models\Configuracione::orderBy('id', 'desc')->first(); $config = App\Models\Configuracione::orderBy('id', 'desc')->first();
@endphp @endphp
<nav class="bg-[{{ $config->color_barra }}] border-b border-white/10 shadow-md"> <nav class="border-b border-white/10 shadow-md" style="background-color: {{ $config->color_barra }};">
<div class="md:w-[90%] mx-auto px-3 sm:px-4 lg:px-6"> <div class="md:w-[90%] mx-auto px-3 sm:px-4 lg:px-6">
<div class="flex items-center h-16 gap-2 sm:gap-3"> <div class="flex items-center h-16 gap-2 sm:gap-3">
@@ -44,7 +44,7 @@
{{-- Botón Cargar saldo (oculto para editor/administrador) --}} {{-- Botón Cargar saldo (oculto para editor/administrador) --}}
@if (!in_array(auth()->user()->rol->nombre, ['editor', 'administrador'])) @if (!in_array(auth()->user()->rol->nombre, ['editor', 'administrador']))
<a href="{{ route('recarga') }}" class="flex-shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-white text-sm font-medium bg-[{{ $config->color_boton }}] hover:opacity-90 transition-opacity"> <a href="{{ route('recarga') }}" class="flex-shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-white text-sm font-medium hover:opacity-90 transition-opacity" style="background-color: {{ $config->color_boton }};">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="w-3.5 h-3.5 fill-white flex-shrink-0"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="w-3.5 h-3.5 fill-white flex-shrink-0">
<path d="M480,224H288V32c0-17.673-14.327-32-32-32s-32,14.327-32,32v192H32c-17.673,0-32,14.327-32,32s14.327,32,32,32h192v192c0,17.673,14.327,32,32,32s32-14.327,32-32V288h192c17.673,0,32-14.327,32-32S497.673,224,480,224z"/> <path d="M480,224H288V32c0-17.673-14.327-32-32-32s-32,14.327-32,32v192H32c-17.673,0-32,14.327-32,32s14.327,32,32,32h192v192c0,17.673,14.327,32,32,32s32-14.327,32-32V288h192c17.673,0,32-14.327,32-32S497.673,224,480,224z"/>
</svg> </svg>
+3 -3
View File
@@ -1,5 +1,5 @@
<div class="flex justify-center items-center" x-data="{ menuNotificaciones: @entangle('menuNotificaciones') }"> <div class="flex justify-center items-center" x-data="{ menuNotificaciones: @entangle('menuNotificaciones') }">
<div class="fill-[{{ App\Models\Configuracione::Orderby('id', 'desc')->first()->color_boton }}] relative cursor-pointer" wire:click="$refresh" @click="menuNotificaciones=!menuNotificaciones"> <div class="relative cursor-pointer" style="fill: {{ App\Models\Configuracione::Orderby('id', 'desc')->first()->color_boton }};" wire:click="$refresh" @click="menuNotificaciones=!menuNotificaciones">
@if ($numNoti != 0) @if ($numNoti != 0)
<span class="absolute top-0 left-0 bg-red-500 rounded-full w-5 h-5 flex justify-center items-center text-xs text-white"> <span class="absolute top-0 left-0 bg-red-500 rounded-full w-5 h-5 flex justify-center items-center text-xs text-white">
{{ $numNoti > 9 ? '9+' : $numNoti }} {{ $numNoti > 9 ? '9+' : $numNoti }}
@@ -10,11 +10,11 @@
</svg> </svg>
</div> </div>
<div x-cloak x-show="menuNotificaciones" @click.outside="menuNotificaciones = false" class="absolute top-14 right-2 md:right-32 rounded-lg border overflow-y-auto border-[{{ App\Models\Configuracione::Orderby('id', 'desc')->first()->color_boton }}] bg-white w-72 md:w-96 h-96 z-50"> <div x-cloak x-show="menuNotificaciones" @click.outside="menuNotificaciones = false" class="absolute top-14 right-2 md:right-32 rounded-lg border overflow-y-auto bg-white w-72 md:w-96 h-96 z-50" style="border-color: {{ App\Models\Configuracione::Orderby('id', 'desc')->first()->color_boton }};">
<ul class="p-4"> <ul class="p-4">
@forelse ($consulta as $item) @forelse ($consulta as $item)
<li class="px-2 py-1 mb-2 border border-gray-300 rounded-lg relative" wire:loading.class="opacity-50 cursor-wait"> <li class="px-2 py-1 mb-2 border border-gray-300 rounded-lg relative" wire:loading.class="opacity-50 cursor-wait">
<h4 class="font-bold text-base text-[{{ App\Models\Configuracione::Orderby('id', 'desc')->first()->color_boton }}]"> <h4 class="font-bold text-base" style="color: {{ App\Models\Configuracione::Orderby('id', 'desc')->first()->color_boton }};">
{{ $item->titulo }} {{ $item->titulo }}
</h4> </h4>
<p class="text-sm"> <p class="text-sm">
+734 -75
View File
@@ -1,13 +1,7 @@
{{-- <div id="chat-root" class="flex flex-col bg-[#111b21] w-full overflow-hidden" style="height:100vh;height:100dvh;height:-webkit-fill-available;">
id="chat-root" es el ancla que usa el visualViewport JS para ajustar altura cuando
aparece el teclado iOS. Ocupa todo el viewport disponible (cuerpo del body).
--}}
<div id="chat-root"
class="w-full flex flex-col bg-[#111b21]"
style="height: 100vh; height: 100dvh; height: -webkit-fill-available; max-width: 480px; margin: 0 auto;">
{{-- ══ PASO 1: Formulario de inicio ══ --}} {{-- ══ PASO 1: Ingreso de correo ══ --}}
@if ($paso === 'telefono') @if ($paso === 'email')
<div class="flex flex-col items-center justify-center flex-1 px-5 safe-top safe-bottom safe-left safe-right"> <div class="flex flex-col items-center justify-center flex-1 px-5 safe-top safe-bottom safe-left safe-right">
<div class="w-full bg-[#202c33] rounded-2xl shadow-2xl p-7"> <div class="w-full bg-[#202c33] rounded-2xl shadow-2xl p-7">
@@ -18,59 +12,193 @@
</svg> </svg>
</div> </div>
<h1 class="text-white text-2xl font-semibold">Chat en vivo</h1> <h1 class="text-white text-2xl font-semibold">Chat en vivo</h1>
<p class="text-[#8696a0] text-sm mt-1 text-center">Ingresa tu número para comenzar</p> <p class="text-[#8696a0] text-sm mt-1 text-center">Ingresa tu correo para comenzar</p>
</div> </div>
<form wire:submit.prevent="iniciar" class="space-y-4"> <form wire:submit.prevent="iniciar" class="space-y-4">
<div> <div>
<label class="block text-[#8696a0] text-xs font-medium mb-1.5 uppercase tracking-wider"> <label class="block text-[#8696a0] text-xs font-medium mb-1.5 uppercase tracking-wider">Correo electrónico *</label>
Teléfono * <input wire:model.defer="email" type="email" inputmode="email" autocomplete="email"
</label> placeholder="tucorreo@ejemplo.com"
{{-- font-size 16px (via CSS global) evita el zoom automático en Safari --}}
<input wire:model.defer="telefono"
type="tel"
inputmode="tel"
autocomplete="tel"
placeholder="Ej: 3001234567"
class="w-full bg-[#2a3942] text-white rounded-xl px-4 py-3.5 outline-none border border-transparent focus:border-[#00a884] transition placeholder-[#8696a0]" class="w-full bg-[#2a3942] text-white rounded-xl px-4 py-3.5 outline-none border border-transparent focus:border-[#00a884] transition placeholder-[#8696a0]"
style="color-scheme: dark;"> style="color-scheme: dark;">
@error('telefono') @error('email') <span class="text-red-400 text-xs mt-1 block">{{ $message }}</span> @enderror
<span class="text-red-400 text-xs mt-1 block">{{ $message }}</span>
@enderror
</div> </div>
<div>
<label class="block text-[#8696a0] text-xs font-medium mb-1.5 uppercase tracking-wider">
Nombre (opcional)
</label>
<input wire:model.defer="nombre"
type="text"
autocomplete="name"
placeholder="Tu nombre"
class="w-full bg-[#2a3942] text-white rounded-xl px-4 py-3.5 outline-none border border-transparent focus:border-[#00a884] transition placeholder-[#8696a0]"
style="color-scheme: dark;">
</div>
{{-- min-height 48px = cumple Apple HIG de target táctil --}}
<button type="submit" <button type="submit"
class="w-full bg-[#00a884] active:bg-[#06cf9c] text-white font-semibold rounded-xl transition shadow-lg" class="w-full bg-[#00a884] active:bg-[#06cf9c] text-white font-semibold rounded-xl transition shadow-lg"
style="min-height: 48px;"> style="min-height: 48px;">
<span wire:loading.remove wire:target="iniciar">Comenzar chat &rarr;</span> <span wire:loading.remove wire:target="iniciar">Continuar &rarr;</span>
<span wire:loading wire:target="iniciar">Conectando...</span> <span wire:loading wire:target="iniciar">Verificando...</span>
</button> </button>
</form> </form>
</div> </div>
</div> </div>
{{-- ══ PASO 2: Interfaz de chat ══ --}} {{-- ══ PASO 2: No existe ofrecer registro ══ --}}
@else @elseif ($paso === 'registro')
<div class="flex flex-col h-full bg-[#0b141a]" <div class="flex flex-col items-center justify-center flex-1 px-5 safe-top safe-bottom safe-left safe-right">
x-data <div class="w-full bg-[#202c33] rounded-2xl shadow-2xl p-7">
x-on:scroll-chat.window="$nextTick(() => {
const el = document.getElementById('pub-messages');
if (el) el.scrollTop = el.scrollHeight;
})">
{{-- Header + safe area top (para el notch / Dynamic Island) --}} <div class="flex flex-col items-center mb-6">
<div class="w-16 h-16 bg-yellow-500 rounded-full flex items-center justify-center mb-4 shadow-lg">
<svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
</div>
<h1 class="text-white text-lg font-semibold text-center">No encontramos tu cuenta</h1>
<p class="text-[#8696a0] text-sm mt-2 text-center leading-relaxed">
No existe una cuenta con el correo<br>
<span class="text-white font-medium">{{ $email }}</span>
</p>
<p class="text-[#8696a0] text-sm mt-3 text-center">¿Deseas crear una cuenta?</p>
</div>
<form wire:submit.prevent="crearCuenta" class="space-y-4">
<div>
<label class="block text-[#8696a0] text-xs font-medium mb-1.5 uppercase tracking-wider">Nombre completo *</label>
<input wire:model.defer="nombreRegistro" type="text" autocomplete="name"
placeholder="¿Cómo te llamas?"
class="w-full bg-[#2a3942] text-white rounded-xl px-4 py-3.5 outline-none border border-transparent focus:border-[#00a884] transition placeholder-[#8696a0]"
style="color-scheme: dark;">
@error('nombreRegistro') <span class="text-red-400 text-xs mt-1 block">{{ $message }}</span> @enderror
</div>
<div>
<label class="block text-[#8696a0] text-xs font-medium mb-1.5 uppercase tracking-wider">Cédula</label>
<input wire:model.defer="cedulaRegistro" type="text" inputmode="numeric"
placeholder="Número de cédula"
class="w-full bg-[#2a3942] text-white rounded-xl px-4 py-3.5 outline-none border border-transparent focus:border-[#00a884] transition placeholder-[#8696a0]"
style="color-scheme: dark;">
@error('cedulaRegistro') <span class="text-red-400 text-xs mt-1 block">{{ $message }}</span> @enderror
</div>
<div>
<label class="block text-[#8696a0] text-xs font-medium mb-1.5 uppercase tracking-wider">Celular</label>
<input wire:model.defer="celularRegistro" type="tel" inputmode="tel" autocomplete="tel"
placeholder="Ej: 3001234567"
class="w-full bg-[#2a3942] text-white rounded-xl px-4 py-3.5 outline-none border border-transparent focus:border-[#00a884] transition placeholder-[#8696a0]"
style="color-scheme: dark;">
@error('celularRegistro') <span class="text-red-400 text-xs mt-1 block">{{ $message }}</span> @enderror
</div>
<button type="submit"
class="w-full bg-[#00a884] active:bg-[#06cf9c] text-white font-semibold rounded-xl transition shadow-lg"
style="min-height: 48px;">
<span wire:loading.remove wire:target="crearCuenta">, crear cuenta &rarr;</span>
<span wire:loading wire:target="crearCuenta">Creando cuenta...</span>
</button>
</form>
<button wire:click="rechazarRegistro" type="button"
class="w-full mt-3 text-[#8696a0] text-sm hover:text-white transition py-2">
No, volver
</button>
</div>
</div>
{{-- ══ PASO 4: Verificación OTP ══ --}}
@elseif ($paso === 'verificacion')
<div class="flex flex-col items-center justify-center flex-1 px-5 safe-top safe-bottom safe-left safe-right">
<div class="w-full bg-[#202c33] rounded-2xl shadow-2xl p-7">
<div class="flex flex-col items-center mb-7">
<div class="w-20 h-20 bg-[#00a884] rounded-full flex items-center justify-center mb-4 shadow-lg">
<svg class="w-10 h-10 text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" />
</svg>
</div>
<h1 class="text-white text-xl font-semibold">Verifica tu identidad</h1>
<p class="text-[#8696a0] text-sm mt-2 text-center leading-relaxed">
Enviamos un código de 6 dígitos a<br>
<span class="text-[#00a884] font-semibold">{{ $emailMascarado }}</span>
</p>
</div>
@if (session('otp_reenviado'))
<p class="text-[#00a884] text-sm text-center mb-3">{{ session('otp_reenviado') }}</p>
@endif
@if ($errorEnvioOtp)
<div class="bg-red-900/40 border border-red-500/50 rounded-xl px-4 py-3 mb-3 text-red-300 text-sm text-center">
{{ $errorEnvioOtp }}
</div>
@endif
<form wire:submit.prevent="verificarCodigo" class="space-y-4">
<div>
<label class="block text-[#8696a0] text-xs font-medium mb-1.5 uppercase tracking-wider">Código de verificación</label>
<input wire:model.defer="codigoIngresado" type="text" inputmode="numeric"
pattern="[0-9]*" maxlength="6" autocomplete="off"
placeholder="000000"
class="w-full bg-[#2a3942] text-white rounded-xl px-4 py-3.5 outline-none border border-transparent focus:border-[#00a884] transition placeholder-[#8696a0] text-center text-2xl font-bold tracking-[0.5em]"
style="color-scheme: dark;">
@error('codigoIngresado') <span class="text-red-400 text-xs mt-1 block">{{ $message }}</span> @enderror
</div>
<button type="submit"
class="w-full bg-[#00a884] active:bg-[#06cf9c] text-white font-semibold rounded-xl transition shadow-lg"
style="min-height: 48px;">
<span wire:loading.remove wire:target="verificarCodigo">Verificar &rarr;</span>
<span wire:loading wire:target="verificarCodigo">Verificando...</span>
</button>
</form>
<div class="mt-5 flex flex-col items-center gap-2">
<button wire:click="reenviarCodigo" type="button"
class="text-[#00a884] text-sm hover:underline">
<span wire:loading.remove wire:target="reenviarCodigo">¿No llegó? Reenviar código</span>
<span wire:loading wire:target="reenviarCodigo">Enviando...</span>
</button>
<button wire:click="$set('paso', 'email')" type="button"
class="text-[#8696a0] text-xs hover:text-white">
Volver
</button>
</div>
</div>
</div>
{{-- ══ PASO 3: Interfaz de chat ══ --}}
@else
<div class="flex-1 flex flex-col min-h-0 chat-bg relative"
x-data="{
uploading: false,
previewUrl: null,
pendiente: null,
lightbox: null,
enviarTexto() {
const ta = this.$refs.entrada;
const t = (ta?.value ?? '').trim();
if (! t) return;
this.pendiente = t; // burbuja optimista: se ve al instante
ta.value = '';
this.autoGrow();
this.$nextTick(() => window.dispatchEvent(new CustomEvent('scroll-chat')));
try {
Promise.resolve(this.$wire.enviar(t)).finally(() => this.pendiente = null);
} catch (e) {
// Si el envío no sale, devolvemos el texto al cuadro para no perderlo
this.pendiente = null;
ta.value = t;
this.autoGrow();
}
},
autoGrow() {
const ta = this.$refs.entrada;
if (! ta) return;
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 112) + 'px';
}
}"
x-on:livewire-upload-start.window="uploading = true"
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 --}}
<div class="bg-[#202c33] shadow-md flex-shrink-0 safe-top safe-left safe-right"> <div class="bg-[#202c33] shadow-md flex-shrink-0 safe-top safe-left safe-right">
<div class="flex items-center gap-3 px-4 py-3"> <div class="flex items-center gap-3 px-4 py-3">
<div class="w-10 h-10 bg-[#00a884] rounded-full flex items-center justify-center flex-shrink-0"> <div class="w-10 h-10 bg-[#00a884] rounded-full flex items-center justify-center flex-shrink-0">
@@ -79,64 +207,576 @@
</svg> </svg>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-white font-semibold text-sm truncate">Soporte en línea</p> <p class="text-white font-semibold text-sm truncate">
{{ $nombreUsuario ?: 'Soporte en línea' }}
</p>
<p class="text-[#8696a0] text-xs"> <p class="text-[#8696a0] text-xs">
@if ($estadoConv === 'agente') Atendido por un agente @if ($estadoConv === 'agente') Atendido por un agente
@elseif ($estadoConv === 'cerrada') Conversación cerrada @elseif ($estadoConv === 'cerrada') Conversación cerrada
@else Bot activo @else Bot activo @endif
@endif
</p> </p>
</div> </div>
{{-- Saldo si hay usuario identificado --}}
@if ($saldoUsuario !== null)
<div class="text-right flex-shrink-0">
<p class="text-[#00a884] text-xs font-semibold">Saldo</p>
<p class="text-white text-sm font-bold">${{ number_format($saldoUsuario) }}</p>
</div>
@endif
{{-- Menú de opciones --}}
<div class="flex-shrink-0 ml-1 relative" x-data="{ open: false }" @click.outside="open = false">
<button @click="open = !open"
class="p-2 rounded-full hover:bg-white/10 transition text-[#8696a0]">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="5" r="1.5"/>
<circle cx="12" cy="12" r="1.5"/>
<circle cx="12" cy="19" r="1.5"/>
</svg>
</button>
<div x-show="open" x-transition
class="absolute right-0 top-10 w-44 bg-[#233138] rounded-lg shadow-xl z-50 overflow-hidden">
<button wire:click="limpiarChat" @click="open = false"
class="w-full flex items-center gap-3 px-4 py-3 text-sm text-white hover:bg-white/10 transition text-left">
<svg class="w-4 h-4 text-[#8696a0]" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
Limpiar chat
</button>
<div class="border-t border-white/10"></div>
<button wire:click="cerrarSesion" @click="open = false"
class="w-full flex items-center gap-3 px-4 py-3 text-sm text-red-400 hover:bg-white/10 transition text-left">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15M12 9l-3 3m0 0 3 3m-3-3h12.75" />
</svg>
Cerrar sesión
</button>
</div>
</div>
</div> </div>
</div> </div>
{{-- Mensajes ios-scroll da momentum nativo en iPhone --}} {{-- Badge: nuevos mensajes mientras se lee el historial --}}
<div class="flex-1 overflow-y-auto px-4 py-4 space-y-2 ios-scroll" <div id="new-msg-badge" class="hidden absolute left-0 right-0 flex justify-center z-20 pointer-events-none"
style="bottom:80px;">
<button id="new-msg-badge-btn"
class="pointer-events-auto flex items-center gap-2 bg-[#00a884] text-white text-xs font-semibold px-4 py-2 rounded-full shadow-xl active:bg-[#06cf9c] transition">
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 13.5 12 21m0 0-7.5-7.5M12 21V3" />
</svg>
<span id="new-msg-count">1</span>&nbsp;mensaje nuevo
</button>
</div>
{{-- Mientras ValidarPagoWebJob trabaja, este poll solo pregunta si ya
hay desenlace (lectura de caché, sin llamadas externas).
Va en su propio elemento porque Livewire v2 solo respeta el
PRIMER wire:poll de un mismo nodo: compartirlo con el poll de
mensajes hacía que uno de los dos nunca se ejecutara. --}}
@if ($flujo === 'pago.procesando')
<div wire:poll.2000ms="revisarPago" class="w-0 h-0 overflow-hidden" aria-hidden="true"></div>
@endif
{{-- Mensajes --}}
<div class="flex-1 min-h-0 overflow-y-auto px-3 py-4 space-y-2 ios-scroll"
id="pub-messages" id="pub-messages"
wire:poll.3000ms="cargarMensajes" wire:poll.3000ms="pollMensajes"
style="overscroll-behavior-y: contain;"> style="overscroll-behavior-y:contain;-webkit-overflow-scrolling:touch;">
@forelse($mensajes as $msg) @forelse($mensajes as $msg)
{{-- ── Mensaje del USUARIO ── --}}
@if ($msg['tipo'] === 'usuario') @if ($msg['tipo'] === 'usuario')
<div class="flex justify-end"> <div class="flex justify-end msg-in" wire:key="msg-{{ $msg['id'] }}">
<div class="max-w-[80%] bg-[#005c4b] text-white rounded-xl rounded-tr-sm px-4 py-2 shadow"> @if (($msg['tipo_ui'] ?? 'text') === 'imagen')
<p class="whitespace-pre-line break-words leading-relaxed" style="font-size:15px;">{{ $msg['contenido'] }}</p> <div class="max-w-[75%] bg-[#005c4b] rounded-xl rounded-tr-sm overflow-hidden shadow bubble-out">
<span class="text-[#8696a0] text-[11px] float-right mt-1 ml-2">{{ $msg['created_at'] }}</span> <img src="{{ $msg['payload']['url'] ?? '' }}"
</div> loading="lazy" decoding="async" alt="Comprobante enviado"
class="w-full object-cover cursor-zoom-in bg-[#0b141a]"
style="max-height:260px; min-height:120px;"
x-on:click="lightbox = '{{ $msg['payload']['url'] ?? '' }}'"
x-on:error="$el.classList.add('img-rota')">
<div class="px-3 py-1 flex items-center justify-end gap-1">
<span class="text-white/60 text-[11px]">{{ $msg['created_at'] }}</span>
<svg class="w-4 h-4 text-[#53bdeb]" viewBox="0 0 16 15" fill="currentColor"><path d="M15.01 3.316l-.478-.372a.365.365 0 0 0-.51.063L8.666 9.879a.32.32 0 0 1-.484.033l-.358-.325a.319.319 0 0 0-.484.032l-.378.483a.418.418 0 0 0 .036.541l1.32 1.266c.143.14.361.125.484-.033l6.272-8.048a.366.366 0 0 0-.064-.512zm-4.1 0l-.478-.372a.365.365 0 0 0-.51.063L4.566 9.879a.32.32 0 0 1-.484.033L1.891 7.769a.366.366 0 0 0-.515.006l-.423.433a.364.364 0 0 0 .006.514l3.258 3.185c.143.14.361.125.484-.033l6.272-8.048a.365.365 0 0 0-.063-.51z"/></svg>
</div>
</div>
@else
<div class="max-w-[80%] bg-[#005c4b] text-white rounded-xl rounded-tr-sm px-3 py-2 shadow bubble-out">
<p class="whitespace-pre-line break-words leading-relaxed" style="font-size:15px;">{{ $msg['contenido'] }}</p>
<span class="inline-flex items-center gap-1 float-right mt-1 ml-2 translate-y-1">
<span class="text-white/60 text-[11px]">{{ $msg['created_at'] }}</span>
<svg class="w-4 h-4 text-[#53bdeb]" viewBox="0 0 16 15" fill="currentColor"><path d="M15.01 3.316l-.478-.372a.365.365 0 0 0-.51.063L8.666 9.879a.32.32 0 0 1-.484.033l-.358-.325a.319.319 0 0 0-.484.032l-.378.483a.418.418 0 0 0 .036.541l1.32 1.266c.143.14.361.125.484-.033l6.272-8.048a.366.366 0 0 0-.064-.512zm-4.1 0l-.478-.372a.365.365 0 0 0-.51.063L4.566 9.879a.32.32 0 0 1-.484.033L1.891 7.769a.366.366 0 0 0-.515.006l-.423.433a.364.364 0 0 0 .006.514l3.258 3.185c.143.14.361.125.484-.033l6.272-8.048a.365.365 0 0 0-.063-.51z"/></svg>
</span>
</div>
@endif
</div> </div>
{{-- ── Mensajes del BOT / AGENTE ── --}}
@else @else
<div class="flex justify-start"> <div class="flex justify-start msg-in" wire:key="msg-{{ $msg['id'] }}">
<div class="max-w-[80%] bg-[#202c33] text-white rounded-xl rounded-tl-sm px-4 py-2 shadow"> @php $tipoUi = $msg['tipo_ui'] ?? 'text'; $payload = $msg['payload'] ?? []; @endphp
@if ($msg['tipo'] === 'agente')
<span class="text-amber-400 text-[11px] font-semibold block mb-0.5">Agente</span> {{-- TEXTO normal --}}
@endif @if ($tipoUi === 'text')
<p class="whitespace-pre-line break-words leading-relaxed" style="font-size:15px;">{{ $msg['contenido'] }}</p> <div class="max-w-[80%] bg-[#202c33] text-white rounded-xl rounded-tl-sm px-3 py-2 shadow bubble-in">
<span class="text-[#8696a0] text-[11px] float-right mt-1 ml-2">{{ $msg['created_at'] }}</span> @if ($msg['tipo'] === 'agente')
</div> <span class="text-amber-400 text-[11px] font-semibold block mb-0.5">Agente</span>
@endif
<p class="whitespace-pre-line break-words leading-relaxed" style="font-size:15px;">{{ $msg['contenido'] }}</p>
<span class="text-[#8696a0] text-[11px] float-right mt-1 ml-2">{{ $msg['created_at'] }}</span>
</div>
{{-- BUTTONS: texto + fila de botones --}}
@elseif ($tipoUi === 'buttons')
<div class="max-w-[88%] space-y-2">
@if ($msg['contenido'])
<div class="bg-[#202c33] text-white rounded-xl rounded-tl-sm px-4 py-2 shadow">
<p class="whitespace-pre-line break-words leading-relaxed" style="font-size:15px;">{{ $msg['contenido'] }}</p>
<span class="text-[#8696a0] text-[11px] float-right mt-1 ml-2">{{ $msg['created_at'] }}</span>
</div>
@endif
<div class="flex flex-wrap gap-2">
@foreach ($payload['botones'] ?? [] as $btn)
@if ($btn['disabled'] ?? false)
<button disabled
class="px-3 py-2 rounded-xl text-sm font-medium border border-[#3d4a51] text-[#3d4a51] bg-transparent cursor-not-allowed opacity-50">
{{ $btn['label'] }}
</button>
@else
<button wire:click="clickBoton('{{ $btn['action'] }}', {{ json_encode($btn['data'] ?? []) }})"
class="px-3 py-2 rounded-xl text-sm font-medium border border-[#00a884] text-[#00a884] bg-transparent active:bg-[#00a884]/20 transition">
{{ $btn['label'] }}
</button>
@endif
@endforeach
</div>
</div>
{{-- CARD: tarjeta de servicio o promo --}}
@elseif ($tipoUi === 'card')
<div class="max-w-[85%] bg-[#202c33] rounded-xl rounded-tl-sm shadow overflow-hidden">
@if (!empty($payload['imagen']))
<img src="{{ Str::startsWith($payload['imagen'], ['http','//']) ? $payload['imagen'] : asset($payload['imagen']) }}" class="w-full object-cover" style="max-height:130px;">
@endif
<div class="px-4 py-3">
<p class="text-white font-semibold text-sm">{{ $payload['titulo'] ?? '' }}</p>
@if (!empty($payload['descripcion']))
<p class="text-[#8696a0] text-xs mt-1">{{ $payload['descripcion'] }}</p>
@endif
@if (!empty($payload['precio']))
<p class="text-[#00a884] font-bold text-base mt-2">${{ number_format($payload['precio']) }}</p>
@endif
@if (!empty($payload['detalle']))
<p class="text-[#8696a0] text-xs">{{ $payload['detalle'] }}</p>
@endif
@if (!empty($payload['accion']))
<button wire:click="clickBoton('{{ $payload['accion']['action'] }}', {{ json_encode($payload['accion']['data'] ?? []) }})"
class="mt-3 w-full bg-[#00a884] text-white text-sm font-semibold rounded-xl py-2 active:bg-[#06cf9c] transition">
{{ $payload['accion']['label'] }}
</button>
@endif
</div>
<div class="px-4 pb-2 text-right">
<span class="text-[#8696a0] text-[11px]">{{ $msg['created_at'] }}</span>
</div>
</div>
{{-- CARDS SLIDER: servicios / promos en carrusel horizontal --}}
@elseif ($tipoUi === 'cards_slider')
<div class="w-full max-w-full space-y-2">
@if ($msg['contenido'])
<div class="bg-[#202c33] text-white rounded-xl rounded-tl-sm px-4 py-2 shadow inline-block max-w-[80%]">
<p class="text-sm">{{ $msg['contenido'] }}</p>
<span class="text-[#8696a0] text-[11px] float-right mt-1 ml-2">{{ $msg['created_at'] }}</span>
</div>
@endif
{{-- Scroll horizontal de cards --}}
<div class="flex gap-3 overflow-x-auto pb-2"
style="-webkit-overflow-scrolling:touch; scroll-snap-type:x mandatory; scrollbar-width:none;">
@foreach ($payload['cards'] ?? [] as $card)
<div class="flex-shrink-0 bg-[#202c33] rounded-xl shadow overflow-hidden"
style="width:180px; scroll-snap-align:start;">
@if (!empty($card['imagen']))
<img src="{{ Str::startsWith($card['imagen'], ['http','//']) ? $card['imagen'] : asset($card['imagen']) }}" class="w-full object-cover" style="height:100px;">
@else
<div class="w-full bg-[#2a3942] flex items-center justify-center" style="height:80px;">
<svg class="w-8 h-8 text-[#8696a0]" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
</div>
@endif
<div class="px-3 py-2.5">
<p class="text-white text-sm font-semibold truncate">{{ $card['titulo'] ?? '' }}</p>
@if (!empty($card['precio']))
<p class="text-[#00a884] font-bold text-sm mt-0.5">${{ number_format($card['precio']) }}</p>
@endif
@if (!empty($card['detalle']))
<p class="text-[#8696a0] text-xs mt-0.5 truncate">{{ $card['detalle'] }}</p>
@endif
@if (!empty($card['descripcion']))
<p class="text-[#8696a0] text-xs mt-0.5 line-clamp-2">{{ $card['descripcion'] }}</p>
@endif
@if (!empty($card['accion']))
<button wire:click="clickBoton('{{ $card['accion']['action'] }}', {{ json_encode($card['accion']['data'] ?? []) }})"
class="mt-2.5 w-full bg-[#00a884] text-white text-xs font-semibold rounded-lg py-1.5 active:bg-[#06cf9c] transition">
{{ $card['accion']['label'] }}
</button>
@endif
</div>
</div>
@endforeach
</div>
</div>
{{-- LINK: botón de pago externo --}}
@elseif ($tipoUi === 'link')
<div class="max-w-[85%] bg-[#202c33] rounded-xl rounded-tl-sm px-4 py-3 shadow">
<p class="text-white text-sm mb-3 whitespace-pre-line">{{ $msg['contenido'] }}</p>
<a href="{{ $payload['url'] ?? '#' }}" target="_blank"
class="flex items-center justify-center gap-2 w-full bg-[#00a884] text-white text-sm font-semibold rounded-xl py-2.5 active:bg-[#06cf9c] transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
{{ $payload['label'] ?? 'Ir a pagar' }}
</a>
@if (!empty($payload['nota']))
<p class="text-[#8696a0] text-xs mt-2 text-center">{{ $payload['nota'] }}</p>
@endif
<div class="mt-2 text-right">
<span class="text-[#8696a0] text-[11px]">{{ $msg['created_at'] }}</span>
</div>
</div>
{{-- VALIDACION: resultado de comprobante de pago --}}
@elseif ($tipoUi === 'validacion')
<div class="max-w-[88%] bg-[#202c33] rounded-xl rounded-tl-sm shadow overflow-hidden">
{{-- Header estado --}}
@php
$estado = $payload['estado'] ?? 'no_encontrado';
$esConfirmado = $estado === 'confirmado';
$esIncorrecto = $estado === 'monto_incorrecto';
@endphp
<div class="px-4 py-2 {{ $esConfirmado ? 'bg-green-900/40' : ($esIncorrecto ? 'bg-amber-900/40' : 'bg-red-900/30') }}">
<p class="text-sm font-semibold {{ $esConfirmado ? 'text-green-400' : ($esIncorrecto ? 'text-amber-400' : 'text-red-400') }}">
@if ($esConfirmado) Pago confirmado
@elseif ($esIncorrecto) ⚠️ Monto no coincide
@else Verificando pago...
@endif
</p>
</div>
{{-- Datos extraídos --}}
<div class="px-4 py-3 space-y-1">
<p class="text-[#8696a0] text-xs font-semibold uppercase tracking-wider mb-2">Datos del comprobante</p>
@foreach (['banco' => 'Banco', 'valor' => 'Valor', 'remitente' => 'De', 'referencia' => 'Referencia', 'fecha' => 'Fecha'] as $key => $label)
@if (!empty($payload['ia_datos'][$key]))
<div class="flex justify-between text-xs">
<span class="text-[#8696a0]">{{ $label }}</span>
<span class="text-white font-medium">{{ $payload['ia_datos'][$key] }}</span>
</div>
@endif
@endforeach
</div>
{{-- Botones de acción --}}
<div class="px-4 pb-3 flex flex-wrap gap-2">
@foreach ($payload['botones'] ?? [] as $btn)
<button wire:click="clickBoton('{{ $btn['action'] }}', {{ json_encode($btn['data'] ?? []) }})"
class="flex-1 px-3 py-2 rounded-xl text-xs font-medium border {{ $esConfirmado ? 'border-green-500 text-green-400' : 'border-[#8696a0] text-[#8696a0]' }} bg-transparent active:bg-white/5 transition">
{{ $btn['label'] }}
</button>
@endforeach
</div>
<div class="px-4 pb-2 text-right">
<span class="text-[#8696a0] text-[11px]">{{ $msg['created_at'] }}</span>
</div>
</div>
{{-- CREDENCIALES: tarjeta con usuario/contraseña --}}
@elseif ($tipoUi === 'credenciales')
<div class="max-w-[85%] bg-[#202c33] rounded-xl rounded-tl-sm shadow overflow-hidden"
x-data="{
copiado: null,
copiarCampo(val, id) {
navigator.clipboard.writeText(val).then(() => { this.copiado = id; setTimeout(() => this.copiado = null, 2000); });
},
copiarTodo() {
let parts = [
'Servicio: ' + ($refs.cred_servicio?.innerText ?? ''),
$refs.cred_email ? 'Email: ' + $refs.cred_email.innerText : '',
$refs.cred_pass ? 'Password: ' + $refs.cred_pass.innerText : '',
$refs.cred_perfil ? 'Perfil: ' + $refs.cred_perfil.innerText : '',
$refs.cred_vence ? 'Vence: ' + $refs.cred_vence.innerText : '',
].filter(Boolean);
navigator.clipboard.writeText(parts.join('\n')).then(() => { this.copiado = 'todo'; setTimeout(() => this.copiado = null, 2000); });
}
}">
{{-- Encabezado servicio --}}
<div class="px-4 py-2 bg-[#00a884]/20 flex items-center justify-between">
<p class="text-[#00a884] text-xs font-semibold uppercase tracking-wider" x-ref="cred_servicio">{{ $payload['servicio'] ?? 'Credenciales' }}</p>
<span class="text-[#00a884] text-[10px]"> Compra exitosa</span>
</div>
{{-- Mensaje de gracias --}}
@if (!empty($msg['contenido']))
<div class="px-4 pt-3 pb-1">
<p class="text-[#e9edef] text-sm leading-snug">{{ $msg['contenido'] }}</p>
</div>
@endif
{{-- Campos con copiar individual --}}
<div class="px-4 py-3 space-y-2">
@if (!empty($payload['email']))
<div class="bg-[#0b141a] rounded-lg px-3 py-2 cursor-pointer active:opacity-70"
@click="copiarCampo($refs.cred_email.innerText, 'email')">
<div class="flex justify-between items-center mb-0.5">
<p class="text-[#8696a0] text-[11px]">Usuario / Email</p>
<svg class="w-3.5 h-3.5 transition" :class="copiado==='email' ? 'text-green-400' : 'text-[#8696a0]'" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor">
<template x-if="copiado !== 'email'"><path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" /></template>
<template x-if="copiado === 'email'"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /></template>
</svg>
</div>
<p class="text-white text-sm font-mono break-all" x-ref="cred_email">{{ $payload['email'] }}</p>
</div>
@endif
@if (!empty($payload['password']))
<div class="bg-[#0b141a] rounded-lg px-3 py-2 cursor-pointer active:opacity-70"
@click="copiarCampo($refs.cred_pass.innerText, 'pass')">
<div class="flex justify-between items-center mb-0.5">
<p class="text-[#8696a0] text-[11px]">Contraseña</p>
<svg class="w-3.5 h-3.5 transition" :class="copiado==='pass' ? 'text-green-400' : 'text-[#8696a0]'" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor">
<template x-if="copiado !== 'pass'"><path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" /></template>
<template x-if="copiado === 'pass'"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /></template>
</svg>
</div>
<p class="text-white text-sm font-mono" x-ref="cred_pass">{{ $payload['password'] }}</p>
</div>
@endif
@if (!empty($payload['perfil']))
<div class="bg-[#0b141a] rounded-lg px-3 py-2">
<p class="text-[#8696a0] text-[11px] mb-0.5">Perfil</p>
<p class="text-white text-sm" x-ref="cred_perfil">{{ $payload['perfil'] }}</p>
</div>
@endif
@if (!empty($payload['vence']))
<p class="text-[#8696a0] text-xs text-center">📅 Vence: <span x-ref="cred_vence">{{ $payload['vence'] }}</span></p>
@endif
</div>
{{-- Botón Copiar todo --}}
<div class="px-4 pb-3">
<button @click="copiarTodo()"
class="w-full py-2 rounded-xl text-sm font-semibold transition flex items-center justify-center gap-2"
:class="copiado === 'todo' ? 'bg-green-500/20 text-green-400' : 'bg-[#00a884]/10 text-[#00a884] active:bg-[#00a884]/20'">
<template x-if="copiado !== 'todo'">
<span class="flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" /></svg>
Copiar todo
</span>
</template>
<template x-if="copiado === 'todo'">
<span class="flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /></svg>
¡Copiado!
</span>
</template>
</button>
</div>
<div class="px-4 pb-2 text-right">
<span class="text-[#8696a0] text-[11px]">{{ $msg['created_at'] }}</span>
</div>
</div>
{{-- BANCO: datos de transferencia bancaria --}}
@elseif ($tipoUi === 'banco')
<div class="max-w-[85%] bg-[#202c33] rounded-xl rounded-tl-sm shadow overflow-hidden">
<div class="px-4 py-2.5 bg-blue-900/40 flex items-center gap-2">
<svg class="w-4 h-4 text-blue-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21v-8.25M15.75 21v-8.25M8.25 21v-8.25M3 9l9-6 9 6m-1.5 12V10.332A48.36 48.36 0 0 0 12 9.75c-2.551 0-5.056.2-7.5.582V21M3 21h18M12 6.75h.008v.008H12V6.75Z" />
</svg>
<p class="text-blue-300 text-xs font-semibold uppercase tracking-wider">Pago por Bre-B</p>
</div>
<div class="px-4 py-3 space-y-3">
@if (!empty($payload['banco']))
<div class="flex justify-between items-center">
<span class="text-[#8696a0] text-xs">Banco / Billetera</span>
<span class="text-white text-sm font-semibold">{{ $payload['banco'] }}</span>
</div>
@endif
<div class="bg-[#0b141a] rounded-xl px-4 py-3 text-center"
x-data="{ copiado: false }">
<p class="text-[#8696a0] text-[11px] mb-2">Llave Bre-B</p>
<div class="flex items-center justify-center gap-3">
<p class="text-[#00a884] text-2xl font-bold font-mono tracking-widest select-all">{{ $payload['clave'] ?? '' }}</p>
<button
@click="navigator.clipboard.writeText('{{ $payload['clave'] ?? '' }}').then(() => { copiado = true; setTimeout(() => copiado = false, 2000) })"
class="flex-shrink-0 p-2 rounded-xl transition"
:class="copiado ? 'bg-green-500/20 text-green-400' : 'bg-[#00a884]/20 text-[#00a884] active:bg-[#00a884]/40'">
<template x-if="!copiado">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" />
</svg>
</template>
<template x-if="copiado">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</template>
</button>
</div>
<p x-show="copiado" x-transition class="text-green-400 text-xs mt-1.5">¡Copiado!</p>
</div>
@if (!empty($payload['titular']))
<div class="flex justify-between items-center">
<span class="text-[#8696a0] text-xs">Titular</span>
<span class="text-white text-sm">{{ $payload['titular'] }}</span>
</div>
@endif
<div class="flex justify-between items-center border-t border-white/10 pt-2">
<span class="text-[#8696a0] text-xs">Monto exacto</span>
<span class="text-white text-base font-bold">${{ number_format($payload['monto'] ?? 0) }}</span>
</div>
</div>
<div class="px-4 pb-3">
<div class="bg-amber-900/20 border border-amber-700/30 rounded-lg px-3 py-2 flex items-start gap-2">
<svg class="w-4 h-4 text-amber-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z" />
</svg>
<p class="text-amber-300 text-xs">Envía el comprobante con el ícono 📷 de abajo y el sistema lo validará automáticamente</p>
</div>
</div>
<div class="px-4 pb-2 text-right">
<span class="text-[#8696a0] text-[11px]">{{ $msg['created_at'] }}</span>
</div>
</div>
{{-- fallback --}}
@else
<div class="max-w-[80%] bg-[#202c33] text-white rounded-xl rounded-tl-sm px-4 py-2 shadow">
<p class="whitespace-pre-line break-words leading-relaxed" style="font-size:15px;">{{ $msg['contenido'] }}</p>
<span class="text-[#8696a0] text-[11px] float-right mt-1 ml-2">{{ $msg['created_at'] }}</span>
</div>
@endif
</div> </div>
@endif @endif
@empty @empty
<div class="text-center text-[#8696a0] text-sm py-10">Iniciando chat...</div> <div class="text-center text-[#8696a0] text-sm py-10">Iniciando chat...</div>
@endforelse @endforelse
{{-- Ancla para scroll al fondo --}}
{{-- Burbuja optimista: el mensaje del usuario aparece antes de
que el servidor responda, igual que en WhatsApp. Se borra
sola cuando Livewire devuelve la lista real. --}}
<template x-if="pendiente">
<div class="flex justify-end msg-in">
<div class="max-w-[80%] bg-[#005c4b] text-white rounded-xl rounded-tr-sm px-3 py-2 shadow opacity-80 bubble-out">
<p class="whitespace-pre-line break-words leading-relaxed" style="font-size:15px;" x-text="pendiente"></p>
<span class="inline-flex items-center gap-1 float-right mt-1 ml-2 translate-y-1">
<span class="text-white/60 text-[11px]">ahora</span>
<svg class="w-3.5 h-3.5 text-white/50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="12" cy="12" r="9" stroke-dasharray="2.5 2.5"/>
</svg>
</span>
</div>
</div>
</template>
{{-- "Escribiendo…". Mientras el job valida el pago se queda fijo,
porque el trabajo ocurre fuera del request y wire:loading no
lo cubriría. --}}
@if ($flujo === 'pago.procesando')
<div class="flex justify-start msg-in">
<div class="bg-[#202c33] rounded-xl rounded-tl-sm px-4 py-3 shadow bubble-in">
<div class="flex items-center gap-1">
<span class="typing-dot"></span>
<span class="typing-dot"></span>
<span class="typing-dot"></span>
</div>
</div>
</div>
@else
<div wire:loading.flex wire:target="enviar, clickBoton, fotoComprobante" class="justify-start msg-in">
<div class="bg-[#202c33] rounded-xl rounded-tl-sm px-4 py-3 shadow bubble-in">
<div class="flex items-center gap-1">
<span class="typing-dot"></span>
<span class="typing-dot"></span>
<span class="typing-dot"></span>
</div>
</div>
</div>
@endif
<div id="pub-messages-end"></div> <div id="pub-messages-end"></div>
</div> </div>
{{-- Input + safe area bottom (home indicator iPhone X+) --}} {{-- Input + upload --}}
@if ($estadoConv !== 'cerrada') @if ($estadoConv !== 'cerrada')
<div class="bg-[#202c33] flex-shrink-0 safe-bottom safe-left safe-right"> <div class="bg-[#202c33] flex-shrink-0 safe-bottom safe-left safe-right" style="flex-shrink:0;">
{{-- Preview local de la imagen seleccionada --}}
<template x-if="previewUrl">
<div class="px-3 pt-2 pb-1 flex items-center gap-3">
<div class="relative">
<img :src="previewUrl" class="h-16 w-16 object-cover rounded-xl border-2 border-[#00a884]">
<div class="absolute inset-0 bg-black/50 rounded-xl flex items-center justify-center">
<svg class="w-5 h-5 animate-spin text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
</svg>
</div>
</div>
<span class="text-[#8696a0] text-xs">Subiendo comprobante...</span>
</div>
</template>
{{-- Indicador: subiendo archivo (fase Alpine) --}}
<div x-show="uploading && !previewUrl" x-cloak class="px-4 pt-2 pb-1">
<div class="flex items-center gap-2 text-[#8696a0] text-xs">
<svg class="w-4 h-4 animate-spin text-[#00a884]" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
</svg>
Subiendo imagen...
</div>
</div>
{{-- Indicador: analizando con IA (fase Livewire server) --}}
<div wire:loading.block wire:target="fotoComprobante" class="px-4 pt-2 pb-1">
<div class="flex items-center gap-2 text-[#8696a0] text-xs">
<svg class="w-4 h-4 animate-spin text-[#00a884]" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
</svg>
Enviando comprobante...
</div>
</div>
<div class="flex items-end gap-2 px-3 py-2.5"> <div class="flex items-end gap-2 px-3 py-2.5">
{{-- Botón adjuntar foto --}}
<label class="flex-shrink-0 cursor-pointer text-[#8696a0] active:text-[#00a884] transition" style="min-width:44px; min-height:44px; display:flex; align-items:center; justify-content:center;">
<input type="file" wire:model="fotoComprobante" accept="image/*" class="hidden"
x-on:change="
if ($event.target.files[0]) {
let r = new FileReader();
r.onload = e => previewUrl = e.target.result;
r.readAsDataURL($event.target.files[0]);
// Red de seguridad: si la respuesta nunca llega,
// no dejamos la miniatura girando para siempre.
setTimeout(() => { previewUrl = null; uploading = false; }, 45000);
}
"
>
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
</svg>
</label>
{{-- wire:ignore: su contenido y su altura los maneja Alpine.
Sin esto, el poll de 3s le devolvería la altura original
mientras el usuario está escribiendo. --}}
<textarea <textarea
wire:model.defer="input" wire:ignore
wire:keydown.enter.prevent="enviar" x-ref="entrada"
x-on:input="autoGrow()"
x-on:keydown.enter.prevent="if (! $event.shiftKey) enviarTexto()"
rows="1" rows="1"
placeholder="Escribe un mensaje..." placeholder="Escribe o usa los botones..."
{{-- font-size 16px evita zoom; border-radius cubre -webkit-appearance --}}
class="flex-1 bg-[#2a3942] text-white rounded-xl px-4 py-3 resize-none outline-none placeholder-[#8696a0]" class="flex-1 bg-[#2a3942] text-white rounded-xl px-4 py-3 resize-none outline-none placeholder-[#8696a0]"
style="font-size:16px; overflow-y:auto; max-height:7rem; line-height:1.4; color-scheme:dark;" style="font-size:16px; overflow-y:auto; max-height:7rem; line-height:1.4; color-scheme:dark;"
></textarea> ></textarea>
{{-- 44x44 mínimo Apple HIG --}} <button type="button" x-on:click="enviarTexto()"
<button wire:click="enviar" class="bg-[#00a884] active:bg-[#06cf9c] active:scale-95 rounded-full flex items-center justify-center transition flex-shrink-0"
class="bg-[#00a884] active:bg-[#06cf9c] rounded-full flex items-center justify-center transition flex-shrink-0"
style="width:44px; height:44px; min-width:44px;"> style="width:44px; height:44px; min-width:44px;">
<svg class="w-5 h-5 text-white" viewBox="0 0 24 24" fill="currentColor"> <svg class="w-5 h-5 text-white" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/> <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
@@ -150,7 +790,26 @@
</div> </div>
@endif @endif
{{-- Visor de imagen a pantalla completa --}}
<template x-if="lightbox">
<div class="fixed inset-0 z-50 bg-black/95 flex flex-col"
x-on:click="lightbox = null"
x-on:keydown.escape.window="lightbox = null">
<div class="flex justify-end safe-top px-4 py-3">
<button type="button" class="p-2 rounded-full bg-white/10 text-white active:bg-white/20">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 flex items-center justify-center px-3 pb-6 safe-bottom">
<img :src="lightbox" alt="Comprobante" class="max-w-full max-h-full object-contain rounded-lg">
</div>
</div>
</template>
</div> </div>
@endif @endif
</div> </div>
+218
View File
@@ -0,0 +1,218 @@
<div class="space-y-6">
<div>
<h2 class="text-xl font-bold text-gray-800">Logs de uso de IA</h2>
<p class="text-gray-500 text-sm">Historial de llamadas a Gemini y Whisper con tokens, tiempos y costos.</p>
</div>
{{-- Cards resumen --}}
@php
$costoGemini = $resumen->costo_gemini ?? 0;
$costoWhisper = $resumen->costo_whisper ?? 0;
$costoOcr = $resumen->costo_ocr ?? 0;
$costoTotal = $costoGemini + $costoWhisper + $costoOcr;
@endphp
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div class="bg-white rounded-2xl border border-gray-100 shadow p-4">
<p class="text-xs text-gray-400 mb-1">Total llamadas</p>
<p class="text-2xl font-bold text-gray-800">{{ number_format($resumen->total_llamadas ?? 0) }}</p>
<p class="text-xs mt-1">
<span class="text-emerald-600">{{ number_format($resumen->total_ok ?? 0) }} ok</span>
&nbsp;·&nbsp;
<span class="text-red-500">{{ number_format($resumen->total_errores ?? 0) }} errores</span>
</p>
</div>
<div class="bg-white rounded-2xl border border-gray-100 shadow p-4">
<p class="text-xs text-gray-400 mb-1">Tokens Gemini</p>
<p class="text-2xl font-bold text-blue-700">{{ number_format(($resumen->total_input_tokens ?? 0) + ($resumen->total_output_tokens ?? 0)) }}</p>
<p class="text-xs text-gray-400 mt-1">
E: {{ number_format($resumen->total_input_tokens ?? 0) }} · S: {{ number_format($resumen->total_output_tokens ?? 0) }}
</p>
</div>
<div class="bg-white rounded-2xl border border-gray-100 shadow p-4">
<p class="text-xs text-gray-400 mb-1">Audio Whisper</p>
<p class="text-2xl font-bold text-purple-700">{{ number_format($resumen->total_segundos_whisper ?? 0) }}s</p>
<p class="text-xs text-gray-400 mt-1">segundos transcritos</p>
</div>
<div class="bg-white rounded-2xl border border-gray-100 shadow p-4">
<p class="text-xs text-gray-400 mb-1">Costo Gemini</p>
<p class="text-2xl font-bold text-blue-700">${{ number_format($costoGemini, 0, ',', '.') }}</p>
<p class="text-xs text-gray-400 mt-1">$20.000 / millón tokens</p>
</div>
<div class="bg-white rounded-2xl border border-gray-100 shadow p-4">
<p class="text-xs text-gray-400 mb-1">Costo OCR</p>
<p class="text-2xl font-bold text-orange-600">${{ number_format($costoOcr, 0, ',', '.') }}</p>
<p class="text-xs text-gray-400 mt-1">{{ number_format($resumen->total_fotos_ocr ?? 0) }} fotos · $5/foto</p>
</div>
<div class="bg-white rounded-2xl border border-gray-100 shadow p-4">
<p class="text-xs text-gray-400 mb-1">Costo Total</p>
<p class="text-2xl font-bold text-emerald-700">${{ number_format($costoTotal, 0, ',', '.') }}</p>
<p class="text-xs text-gray-400 mt-1">
Whisper: ${{ number_format($costoWhisper, 0, ',', '.') }}
· Prom: {{ number_format($resumen->avg_tiempo_ms ?? 0) }}ms
</p>
</div>
</div>
{{-- Filtros --}}
<div class="bg-white rounded-2xl border border-gray-100 shadow p-4">
<div class="flex flex-wrap gap-3 items-end">
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Mes</label>
<select wire:model.live="filtroMes" class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 outline-none">
<option value="">Todos los meses</option>
@foreach($meses as $mes)
<option value="{{ $mes['value'] }}">{{ $mes['label'] }}</option>
@endforeach
</select>
</div>
<div class="flex items-end gap-1 pb-2 text-gray-400 text-xs">ó rango:</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Desde</label>
<input wire:model.live="filtroFechaDesde" type="date" class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 outline-none">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Hasta</label>
<input wire:model.live="filtroFechaHasta" type="date" class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 outline-none">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Servicio</label>
<select wire:model.live="filtroServicio" class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 outline-none">
<option value="">Todos</option>
<option value="gemini_intent">Gemini Intent</option>
<option value="gemini_vision">Gemini Vision</option>
<option value="gemini_text">Gemini Texto (post-OCR)</option>
<option value="ocr">OCR</option>
<option value="whisper">Whisper</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Canal</label>
<select wire:model.live="filtroCanal" class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 outline-none">
<option value="">Todos</option>
<option value="web">Web</option>
<option value="telegram">Telegram</option>
</select>
</div>
<button wire:click="$set('filtroServicio','');$set('filtroCanal','');$set('filtroFechaDesde','');$set('filtroFechaHasta','');$set('filtroMes','')"
class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-600">
Limpiar
</button>
</div>
</div>
{{-- Tabla --}}
<div class="bg-white rounded-2xl border border-gray-100 shadow overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-100">
<tr>
<th class="text-left px-4 py-3 text-xs font-semibold text-gray-500">Fecha</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-gray-500">Servicio</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-gray-500">Canal</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-gray-500">Usuario</th>
<th class="text-right px-4 py-3 text-xs font-semibold text-gray-500">Tokens E/S</th>
<th class="text-right px-4 py-3 text-xs font-semibold text-gray-500">Audio</th>
<th class="text-right px-4 py-3 text-xs font-semibold text-gray-500">Tiempo</th>
<th class="text-right px-4 py-3 text-xs font-semibold text-gray-500">Costo COP</th>
<th class="text-center px-4 py-3 text-xs font-semibold text-gray-500">Estado</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-gray-500">Detalle</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
@forelse($logs as $log)
@php
$detalle = is_array($log->detalle) ? $log->detalle : [];
$badgeServicio = match($log->servicio) {
'gemini_intent' => 'bg-blue-100 text-blue-700',
'gemini_vision' => 'bg-indigo-100 text-indigo-700',
'gemini_text' => 'bg-sky-100 text-sky-700',
'ocr' => 'bg-orange-100 text-orange-700',
'whisper' => 'bg-purple-100 text-purple-700',
default => 'bg-gray-100 text-gray-600',
};
$labelServicio = match($log->servicio) {
'gemini_intent' => 'Gemini Intent',
'gemini_vision' => 'Gemini Vision',
'gemini_text' => 'Gemini Texto',
'ocr' => 'OCR',
'whisper' => 'Whisper',
default => $log->servicio,
};
$costoFila = in_array($log->servicio, ['gemini_intent', 'gemini_vision', 'gemini_text'])
? (($log->input_tokens ?? 0) + ($log->output_tokens ?? 0)) / 1000000 * 20000
: ($log->costo ?? 0);
@endphp
<tr class="hover:bg-gray-50/50">
<td class="px-4 py-3 text-gray-500 whitespace-nowrap text-xs">
{{ $log->created_at->format('d/m/Y') }}<br>
<span class="text-gray-400">{{ $log->created_at->format('H:i:s') }}</span>
</td>
<td class="px-4 py-3">
<span class="px-2 py-0.5 rounded-full text-xs font-semibold {{ $badgeServicio }}">{{ $labelServicio }}</span>
</td>
<td class="px-4 py-3 text-gray-600 capitalize text-xs">{{ $log->canal }}</td>
<td class="px-4 py-3 text-gray-600 text-xs">
{{ $log->usuario_id ? ('ID '.$log->usuario_id) : '—' }}
</td>
<td class="px-4 py-3 text-right text-xs text-gray-600">
@if($log->input_tokens || $log->output_tokens)
{{ number_format($log->input_tokens ?? 0) }} / {{ number_format($log->output_tokens ?? 0) }}
@else
@endif
</td>
<td class="px-4 py-3 text-right text-xs text-gray-600">
@if($log->audio_segundos)
{{ $log->audio_segundos }}s
@else
@endif
</td>
<td class="px-4 py-3 text-right text-xs text-gray-600">{{ number_format($log->tiempo_ms) }}ms</td>
<td class="px-4 py-3 text-right text-xs font-semibold {{ $costoFila > 0 ? 'text-emerald-700' : 'text-gray-400' }}">
@if($costoFila > 0)
${{ number_format($costoFila, 0, ',', '.') }}
@else
@endif
</td>
<td class="px-4 py-3 text-center">
@if($log->resultado === 'ok')
<span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-700">OK</span>
@else
<span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-700">Error</span>
@endif
</td>
<td class="px-4 py-3 text-xs text-gray-500 max-w-xs">
@if(isset($detalle['texto']) && $detalle['texto'])
<span class="italic">"{{ Str::limit($detalle['texto'], 60) }}"</span>
@elseif(isset($detalle['accion']) && $detalle['accion'])
{{ $detalle['accion'] }}
@elseif(isset($detalle['banco']))
{{ $detalle['banco'] }} · ${{ number_format($detalle['valor'] ?? 0) }}
@elseif(isset($detalle['error']))
<span class="text-red-500">{{ Str::limit($detalle['error'], 60) }}</span>
@else
@endif
</td>
</tr>
@empty
<tr>
<td colspan="10" class="px-4 py-12 text-center text-gray-400 text-sm">
No hay registros con los filtros seleccionados.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if($logs->hasPages())
<div class="px-4 py-3 border-t border-gray-100">
{{ $logs->links() }}
</div>
@endif
</div>
</div>
@@ -0,0 +1,99 @@
<div class="p-6">
{{-- Filtros --}}
<div class="flex flex-wrap gap-3 mb-5">
<input wire:model.debounce.300ms="busqueda" type="text"
placeholder="Buscar por email o nombre..."
class="flex-1 min-w-[220px] bg-white border border-gray-200 rounded-lg px-4 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500">
<select wire:model="filtroEstado"
class="bg-white border border-gray-200 rounded-lg px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500">
<option value="">Todos los estados</option>
<option value="pendiente">🟡 Pendiente</option>
<option value="confirmada"> Confirmada</option>
<option value="expirada"> Expirada</option>
</select>
<select wire:model="filtroCanal"
class="bg-white border border-gray-200 rounded-lg px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500">
<option value="">Todos los canales</option>
<option value="web">🌐 Web</option>
<option value="telegram">✈️ Telegram</option>
</select>
</div>
{{-- Tabla --}}
<div class="bg-white rounded-xl shadow overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-100">
<tr>
<th class="text-left px-4 py-3 text-gray-500 font-medium">#</th>
<th class="text-left px-4 py-3 text-gray-500 font-medium">Fecha</th>
<th class="text-left px-4 py-3 text-gray-500 font-medium">Usuario</th>
<th class="text-left px-4 py-3 text-gray-500 font-medium">Canal</th>
<th class="text-left px-4 py-3 text-gray-500 font-medium">Titular</th>
<th class="text-right px-4 py-3 text-gray-500 font-medium">Monto</th>
<th class="text-center px-4 py-3 text-gray-500 font-medium">Estado</th>
<th class="text-center px-4 py-3 text-gray-500 font-medium">Chat</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
@forelse($solicitudes as $s)
<tr class="hover:bg-gray-50 transition">
<td class="px-4 py-3 text-gray-400 text-xs">{{ $s->id }}</td>
<td class="px-4 py-3 text-gray-600 whitespace-nowrap">
{{ $s->created_at ? $s->created_at->format('d/m/y H:i') : '—' }}
</td>
<td class="px-4 py-3">
@if($s->user)
<p class="font-medium text-gray-800">{{ $s->user->name }}</p>
<p class="text-gray-400 text-xs">{{ $s->user->email }}</p>
@else
<span class="text-gray-400 text-xs">Usuario eliminado</span>
@endif
</td>
<td class="px-4 py-3">
@if($s->canal === 'telegram')
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-700">✈️ Telegram</span>
@else
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-700">🌐 Web</span>
@endif
</td>
<td class="px-4 py-3 text-gray-600 text-xs">{{ $s->nombre_remitente ?? '—' }}</td>
<td class="px-4 py-3 text-right font-semibold text-gray-800">
${{ number_format($s->monto) }}
</td>
<td class="px-4 py-3 text-center">
@if($s->estado === 'confirmada')
<span class="inline-block px-2.5 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-700"> Confirmada</span>
@elseif($s->estado === 'pendiente')
<span class="inline-block px-2.5 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-700">🟡 Pendiente</span>
@else
<span class="inline-block px-2.5 py-1 rounded-full text-xs font-semibold bg-red-100 text-red-700"> Expirada</span>
@endif
</td>
<td class="px-4 py-3 text-center">
@if($s->user_id)
<button wire:click="verChat({{ $s->user_id }})"
class="text-xs px-3 py-1.5 bg-[#00a884] hover:bg-[#06cf9c] text-white rounded-lg transition font-medium">
Ver chat
</button>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="8" class="px-4 py-10 text-center text-gray-400">No hay registros</td>
</tr>
@endforelse
</tbody>
</table>
@if($solicitudes->hasPages())
<div class="px-4 py-3 border-t border-gray-100">
{{ $solicitudes->links() }}
</div>
@endif
</div>
</div>
+191 -1
View File
@@ -57,6 +57,196 @@
@error('mensaje_transferencia') <span class="text-red-500 text-xs">{{ $message }}</span> @enderror @error('mensaje_transferencia') <span class="text-red-500 text-xs">{{ $message }}</span> @enderror
</div> </div>
<hr class="border-gray-100">
{{-- Gemini IA --}}
<div>
<h3 class="text-sm font-bold text-gray-700 mb-3">Inteligencia Artificial (Gemini)</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">API Key de Gemini</label>
<p class="text-xs text-gray-400 mb-2">Obtén tu clave en aistudio.google.com</p>
<input wire:model.defer="gemini_api_key" type="password" placeholder="AIza..."
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 outline-none font-mono">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Modelo</label>
<p class="text-xs text-gray-400 mb-2">Usa "Probar conexión" para ver los modelos disponibles en tu cuenta si no sabes cuál poner.</p>
<input wire:model.defer="gemini_model" type="text" placeholder="gemini-2.0-flash"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 outline-none font-mono">
</div>
<div class="flex items-center gap-3">
<input wire:model.defer="gemini_habilitado" type="checkbox" value="1"
id="gemini_hab" class="w-4 h-4 accent-emerald-600">
<label for="gemini_hab" class="text-sm text-gray-700">
Habilitar IA para interpretar texto libre del usuario
</label>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Contexto extra para la IA (opcional)</label>
<textarea wire:model.defer="gemini_prompt_extra" rows="2"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 outline-none resize-none"
placeholder="Ej: Solo vendemos streaming. No ofrecemos juegos."></textarea>
</div>
<div>
<button wire:click="probarGemini"
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded-lg transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09Z" />
</svg>
<span wire:loading.remove wire:target="probarGemini">Probar conexión Gemini</span>
<span wire:loading wire:target="probarGemini">Probando...</span>
</button>
@if($geminiResult)
<p class="mt-2 text-sm {{ str_contains($geminiResult, '✅') ? 'text-emerald-600' : 'text-red-600' }}">
{{ $geminiResult }}
</p>
@endif
</div>
</div>
</div>
<hr class="border-gray-100">
{{-- Whisper STT --}}
<div>
<h3 class="text-sm font-bold text-gray-700 mb-3">Transcripción de voz (Whisper)</h3>
<p class="text-xs text-gray-400 mb-4">Cuando un usuario envíe un audio en Telegram, se transcribirá con Whisper y el texto pasará al sistema de intención como si lo hubiera escrito.</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">URL del servidor Whisper</label>
<input wire:model.defer="whisper_url" type="text" placeholder="https://whisper.u-s.app/asr"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 outline-none font-mono">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Token (contraseña Basic Auth)</label>
<input wire:model.defer="whisper_token" type="password" placeholder="Token del servidor Whisper"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 outline-none font-mono">
</div>
<div class="flex items-center gap-3">
<input wire:model.defer="whisper_habilitado" type="checkbox" value="1"
id="whisper_hab" class="w-4 h-4 accent-emerald-600">
<label for="whisper_hab" class="text-sm text-gray-700">
Habilitar transcripción de audios en Telegram
</label>
</div>
<div>
<button wire:click="resetearWhisper"
class="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm font-semibold rounded-lg transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
<span wire:loading.remove wire:target="resetearWhisper">Probar conexión Whisper</span>
<span wire:loading wire:target="resetearWhisper">Probando...</span>
</button>
@if($whisperResult)
<p class="mt-2 text-sm {{ str_contains($whisperResult, '✅') ? 'text-emerald-600' : 'text-red-600' }}">
{{ $whisperResult }}
</p>
@endif
</div>
</div>
</div>
<hr class="border-gray-100">
{{-- OCR de comprobantes --}}
<div>
<h3 class="text-sm font-bold text-gray-700 mb-1">OCR de comprobantes (antes de la IA)</h3>
<p class="text-xs text-gray-400 mb-4">Si está habilitado, la imagen del comprobante se manda primero a este servicio para extraer el texto, y luego ese texto (no la imagen) se le pasa a Gemini para estructurarlo. Si el servicio falla o está deshabilitado, se usa el método anterior (Gemini lee la imagen directamente).</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">URL del servicio OCR</label>
<input wire:model.defer="ocr_url" type="text" placeholder="https://ocr.tu-dominio.com"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 outline-none font-mono">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Token (Authorization: Bearer)</label>
<input wire:model.defer="ocr_token" type="password" placeholder="Token del servicio OCR"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 outline-none font-mono">
</div>
<div class="flex items-center gap-3">
<input wire:model.defer="ocr_habilitado" type="checkbox" value="1"
id="ocr_hab" class="w-4 h-4 accent-emerald-600">
<label for="ocr_hab" class="text-sm text-gray-700">
Habilitar OCR antes de enviar a la IA
</label>
</div>
<div>
<button wire:click="probarOcr"
class="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm font-semibold rounded-lg transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
<span wire:loading.remove wire:target="probarOcr">Probar conexión OCR</span>
<span wire:loading wire:target="probarOcr">Probando...</span>
</button>
@if($ocrResult)
<p class="mt-2 text-sm {{ str_contains($ocrResult, '✅') ? 'text-emerald-600' : (str_contains($ocrResult, '⚠️') ? 'text-amber-600' : 'text-red-600') }}">
{{ $ocrResult }}
</p>
@endif
</div>
</div>
</div>
<hr class="border-gray-100">
{{-- Validacion de pagos --}}
<div>
<h3 class="text-sm font-bold text-gray-700 mb-1">Validacion de pagos por foto + correo IMAP</h3>
<p class="text-xs text-gray-400 mb-4">La IA lee el comprobante y cruza los datos con los correos del IMAP configurado en la seccion WhatsApp.</p>
<div class="space-y-4">
<div class="flex items-center gap-3">
<input wire:model.defer="validacion_foto_habilitada" type="checkbox" value="1"
id="val_foto" class="w-4 h-4 accent-emerald-600">
<label for="val_foto" class="text-sm text-gray-700">Habilitar validacion de comprobante por foto</label>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Accion al confirmar pago</label>
<select wire:model.defer="validacion_accion_auto"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 outline-none">
<option value="solo_notificar">Solo notificar al asesor (manual)</option>
<option value="recargar_saldo">Acreditar saldo automaticamente</option>
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Tolerancia de monto ($)</label>
<input wire:model.defer="validacion_monto_tolerancia" type="number" min="0"
class="w-32 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 outline-none">
<p class="text-xs text-gray-400 mt-1">Diferencia maxima permitida entre comprobante y correo. Recomendado: 0.</p>
</div>
</div>
</div>
<hr class="border-gray-100">
{{-- Bre-B --}}
<div>
<h3 class="text-sm font-bold text-gray-700 mb-1">Pago por Bre-B</h3>
<p class="text-xs text-gray-400 mb-4">Datos que se muestran al cliente cuando elige pagar por Bre-B. Deja la llave vacía para deshabilitar esta opción.</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Banco / Billetera</label>
<input wire:model.defer="recarga_banco_nombre" type="text" placeholder="Ej: Bancolombia, Nequi, Daviplata"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 outline-none">
@error('recarga_banco_nombre') <span class="text-red-500 text-xs">{{ $message }}</span> @enderror
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Llave Bre-B</label>
<input wire:model.defer="recarga_banco_llave" type="text" placeholder="Ej: 3001234567"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 outline-none font-mono">
@error('recarga_banco_llave') <span class="text-red-500 text-xs">{{ $message }}</span> @enderror
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Titular de la cuenta</label>
<input wire:model.defer="recarga_banco_titular" type="text" placeholder="Nombre del titular"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 outline-none">
@error('recarga_banco_titular') <span class="text-red-500 text-xs">{{ $message }}</span> @enderror
</div>
</div>
</div>
{{-- Guardar --}} {{-- Guardar --}}
<div class="flex justify-end pt-2"> <div class="flex justify-end pt-2">
<button wire:click="guardar" <button wire:click="guardar"
@@ -64,7 +254,7 @@
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"> <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg> </svg>
<span wire:loading.remove wire:target="guardar">Guardar configuración</span> <span wire:loading.remove wire:target="guardar">Guardar configuracion</span>
<span wire:loading wire:target="guardar">Guardando...</span> <span wire:loading wire:target="guardar">Guardando...</span>
</button> </button>
</div> </div>
@@ -71,7 +71,9 @@
<div class="grid h-full place-items-center"> <div class="grid h-full place-items-center">
<div class="bg-white rounded-lg shadow-md sm:1/2 md:1/3 lg:1/3 xl:w-1/4"> <div class="bg-white rounded-lg shadow-md sm:1/2 md:1/3 lg:1/3 xl:w-1/4">
{{-- PHOTO --}} {{-- PHOTO --}}
<div class="w-full text-left text-white px-4 bg-cover py-2 rounded-lg h-64 bg-[url({{asset($this->imagen)}})]"> {{-- URL de runtime: no puede ser clase arbitraria de Tailwind --}}
<div class="w-full bg-cover bg-center rounded-t-lg h-[16.8rem]"
style="background-image: url('{{ asset($this->imagen) }}');">
</div> </div>
<div class="text-left"> <div class="text-left">
@@ -106,7 +106,7 @@
<div wire:click="verPromocion({{ $promocion->id }})" class=""> <div wire:click="verPromocion({{ $promocion->id }})" class="">
<img src="{{ asset($promocion->img_publicidad) }}" alt="" <img src="{{ asset($promocion->img_publicidad) }}" alt=""
class="w-full m-auto rounded-lg object-cover h-24 sm:h-36 object-center shadow"> class="w-full m-auto rounded-lg object-cover object-center h-[6.3rem] sm:h-[9.45rem] shadow">
</div> </div>
@@ -145,7 +145,7 @@
<div wire:click="verPromocion({{ $promocion->id }})" class=""> <div wire:click="verPromocion({{ $promocion->id }})" class="">
<img src="{{ asset($promocion->img_publicidad) }}" alt="" <img src="{{ asset($promocion->img_publicidad) }}" alt=""
class="w-full m-auto rounded-lg object-cover h-24 sm:h-44 object-center shadow"> class="w-full m-auto rounded-lg object-cover object-center h-[6.3rem] sm:h-[11.55rem] shadow">
</div> </div>
@@ -197,13 +197,13 @@
<div> <div>
@if ($photo) @if ($photo)
<img src="{{ $photo->temporaryUrl() }}" alt="" <img src="{{ $photo->temporaryUrl() }}" alt=""
class="object-cover rounded-xl border-2 border-neutral-200 shadow-md w-28 h-20"> class="object-contain bg-white rounded-xl border-2 border-neutral-200 shadow-md w-28 h-20">
@error('photo') @error('photo')
<span class="text-red-400 text-sm">Obligatorio*</span> <span class="text-red-400 text-sm">Obligatorio*</span>
@enderror @enderror
@else @else
<img src="" alt="" <img src="" alt=""
class="rounded-xl border-2 border-neutral-200 shadow-md w-28 h-20"> class="object-contain bg-white rounded-xl border-2 border-neutral-200 shadow-md w-28 h-20">
@error('photo') @error('photo')
<span class="text-red-400 text-sm">Obligatorio*</span> <span class="text-red-400 text-sm">Obligatorio*</span>
@enderror @enderror
@@ -417,7 +417,7 @@
<div> <div>
@if ($categoria_photo) @if ($categoria_photo)
<img src="{{$categoria_photo->temporaryUrl()}}" alt="" class="rounded-xl border-2 border-neutral-200 shadow-md w-28 h-20"> <img src="{{$categoria_photo->temporaryUrl()}}" alt="" class="object-contain bg-white rounded-xl border-2 border-neutral-200 shadow-md w-28 h-20">
@endif @endif
</div> </div>
@@ -461,8 +461,11 @@
<div class="grid h-full place-items-center"> <div class="grid h-full place-items-center">
<div class="bg-white rounded-lg shadow-md sm:1/2 md:1/3 lg:1/3 xl:w-1/4"> <div class="bg-white rounded-lg shadow-md sm:1/2 md:1/3 lg:1/3 xl:w-1/4">
{{-- PHOTO --}} {{-- PHOTO --}}
<div {{-- La URL sale en runtime, así que no puede ir como clase
class="w-full text-left text-white px-4 bg-cover py-2 rounded-lg h-64 bg-[url({{ asset($this->imagen) }})] "> arbitraria de Tailwind: el build no la ve y no genera
nada. Va como estilo en línea. --}}
<div class="w-full bg-cover bg-center rounded-t-lg h-[16.8rem]"
style="background-image: url('{{ asset($this->imagen) }}');">
</div> </div>
@@ -520,10 +523,10 @@
<div> <div>
@if ($photo) @if ($photo)
<img src="{{ $photo->temporaryUrl() }}" alt="" <img src="{{ $photo->temporaryUrl() }}" alt=""
class="object-cover rounded-xl border-2 border-neutral-200 shadow-md w-28 h-20"> class="object-contain bg-white rounded-xl border-2 border-neutral-200 shadow-md w-28 h-20">
@else @else
<img src="{{ asset($img_ver) }}" alt="" <img src="{{ asset($img_ver) }}" alt=""
class="rounded-xl border-2 border-neutral-200 shadow-md w-28 h-20"> class="object-contain bg-white rounded-xl border-2 border-neutral-200 shadow-md w-28 h-20">
@endif @endif
</div> </div>
</div> </div>
@@ -1118,11 +1121,14 @@
@foreach ($categorias as $categoria1) @foreach ($categorias as $categoria1)
<div class="cursor-pointer transition hover:scale-[1.05] w-[250px] h-[250px] sm:w-[350px] sm:h-[350px] mx-4 my-2 mx-auto" {{-- Iba con mx-4 y mx-auto a la vez: cuál ganaba dependía
del orden de las reglas, que cambió al pasar del CDN
al build. Se deja solo mx-auto, que es el que centra. --}}
<div class="cursor-pointer transition hover:scale-[1.05] w-[262px] h-[262px] sm:w-[368px] sm:h-[368px] my-2 mx-auto"
x-on:click="categorian='{{ $categoria1->nombre }}',verPro=false"> x-on:click="categorian='{{ $categoria1->nombre }}',verPro=false">
<a href="#promo"> <a href="#promo">
<img src="{{ asset($categoria1->imagen) }}" <img src="{{ asset($categoria1->imagen) }}"
class="w-full m-auto rounded-lg object-cover h-full object-center shadow" class="w-full m-auto rounded-lg object-cover object-center h-full shadow"
alt=""> alt="">
</a> </a>
@@ -106,8 +106,8 @@
@if ($con = $tarifa['usuario_tarifas']) @if ($con = $tarifa['usuario_tarifas'])
@if ($con[0]['visible'] == 'true') @if ($con[0]['visible'] == 'true')
<button wire:click="cargartarjetaPlan({{ $tarifa['id'] }})" <button wire:click="cargartarjetaPlan({{ $tarifa['id'] }})"
class="shadow-lg text-white py-4 px-3 rounded-lg bg-gradient-to-br from-black class="shadow-lg text-white py-4 px-3 rounded-lg"
(via-[{{ $servicio['color_fondo'] }}],90%) to-[{{ $servicio['color_fondo'] }}]"> style="background: linear-gradient(to bottom right, #000, {{ $servicio['color_fondo'] }})">
<p class="font-bold mb-2"> <p class="font-bold mb-2">
@if ($servicio['por_tiempo'] == 1) @if ($servicio['por_tiempo'] == 1)
@@ -143,7 +143,8 @@
@endif @endif
@else @else
<button wire:click="cargartarjetaPlan({{ $tarifa['id'] }})" <button wire:click="cargartarjetaPlan({{ $tarifa['id'] }})"
class="shadow-lg text-white py-4 px-3 rounded-lg bg-gradient-to-br from-black (via-[{{ $servicio['color_fondo'] }}],90%) to-[{{ $servicio['color_fondo'] }}]"> class="shadow-lg text-white py-4 px-3 rounded-lg"
style="background: linear-gradient(to bottom right, #000, {{ $servicio['color_fondo'] }})">
<p class="font-bold mb-2"> <p class="font-bold mb-2">
@if ($servicio['por_tiempo'] == 1) @if ($servicio['por_tiempo'] == 1)
+2 -1
View File
@@ -97,8 +97,9 @@ Route::middleware(["auth", "solo_usuario_administrador"])->group(function () {
// Módulo Chat (web + Telegram) // Módulo Chat (web + Telegram)
Route::prefix('chat')->name('chat.')->group(function () { Route::prefix('chat')->name('chat.')->group(function () {
Route::get('/conversaciones', [ChatController::class, 'conversaciones'])->name('conversaciones'); Route::get('/conversaciones', [ChatController::class, 'conversaciones'])->name('conversaciones');
Route::get('/menus', [ChatController::class, 'menus'])->name('menus'); Route::get('/comprobantes', [ChatController::class, 'comprobantes'])->name('comprobantes');
Route::get('/configuracion', [ChatController::class, 'configuracion'])->name('configuracion'); Route::get('/configuracion', [ChatController::class, 'configuracion'])->name('configuracion');
Route::get('/ia-logs', [ChatController::class, 'iaLogs'])->name('ia-logs');
}); });
}); });
+2 -5
View File
@@ -10,14 +10,11 @@ module.exports = {
theme: { theme: {
extend: { extend: {
fontFamily: { fontFamily: {
sans: ['Proxima Nova', ...defaultTheme.fontFamily.sans], sans: ['Proxima Nova', ...defaultTheme.fontFamily.sans],
}, },
}, },
}, },
plugins: [require('@tailwindcss/forms'), plugins: [require('@tailwindcss/forms')],
require('tailwindcss-plugins/pagination')({
color: colors['teal-dark'],
}),],
}; };