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>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
a56a69dccb
commit
536377363c
File diff suppressed because it is too large
Load Diff
@@ -10,30 +10,61 @@ class ShowConfiguracionChat extends Component
|
||||
{
|
||||
use LivewireAlert;
|
||||
|
||||
// ── Chat general ──────────────────────────────────────────
|
||||
public string $telegram_token = '';
|
||||
public string $mensaje_bienvenida = '';
|
||||
public string $mensaje_transferencia = '';
|
||||
public string $webhookResult = '';
|
||||
|
||||
// ── Gemini IA ─────────────────────────────────────────────
|
||||
public string $gemini_api_key = '';
|
||||
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'; // recargar_saldo | solo_notificar
|
||||
public string $validacion_monto_tolerancia = '0';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->telegram_token = ChatConfig::get('telegram_token', '');
|
||||
$this->mensaje_bienvenida = ChatConfig::get('mensaje_bienvenida', 'Hola 👋 Bienvenido. Escribe tu consulta.');
|
||||
$this->mensaje_transferencia = ChatConfig::get('mensaje_transferencia', 'Un agente se comunicará contigo en breve. Por favor espera.');
|
||||
$keys = [
|
||||
'telegram_token', 'mensaje_bienvenida', 'mensaje_transferencia',
|
||||
'gemini_api_key', 'gemini_habilitado', 'gemini_prompt_extra',
|
||||
'validacion_foto_habilitada', 'validacion_accion_auto', 'validacion_monto_tolerancia',
|
||||
];
|
||||
|
||||
$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',
|
||||
];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$this->{$key} = ChatConfig::get($key, $defaults[$key] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
public function guardar(): void
|
||||
{
|
||||
$this->validate([
|
||||
'mensaje_bienvenida' => 'required|string|max:500',
|
||||
'mensaje_transferencia' => 'required|string|max:500',
|
||||
'mensaje_bienvenida' => 'required|string|max:500',
|
||||
'mensaje_transferencia' => 'required|string|max:500',
|
||||
'validacion_monto_tolerancia' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
ChatConfig::set('telegram_token', $this->telegram_token);
|
||||
ChatConfig::set('mensaje_bienvenida', $this->mensaje_bienvenida);
|
||||
ChatConfig::set('mensaje_transferencia', $this->mensaje_transferencia);
|
||||
$keys = [
|
||||
'telegram_token', 'mensaje_bienvenida', 'mensaje_transferencia',
|
||||
'gemini_api_key', 'gemini_habilitado', 'gemini_prompt_extra',
|
||||
'validacion_foto_habilitada', 'validacion_accion_auto', 'validacion_monto_tolerancia',
|
||||
];
|
||||
|
||||
$this->alert('success', 'Configuración guardada correctamente.');
|
||||
foreach ($keys as $key) {
|
||||
ChatConfig::set($key, $this->{$key});
|
||||
}
|
||||
|
||||
$this->alert('success', 'Configuracion guardada correctamente.');
|
||||
}
|
||||
|
||||
public function registrarWebhook(): void
|
||||
@@ -41,11 +72,10 @@ class ShowConfiguracionChat extends Component
|
||||
$token = trim($this->telegram_token);
|
||||
|
||||
if (! $token) {
|
||||
$this->webhookResult = '⚠️ El token de Telegram está vacío.';
|
||||
$this->webhookResult = 'El token de Telegram esta vacio.';
|
||||
return;
|
||||
}
|
||||
|
||||
// Guardar el token antes de registrar
|
||||
ChatConfig::set('telegram_token', $token);
|
||||
|
||||
$webhookUrl = url('/chat/webhook/telegram');
|
||||
@@ -63,14 +93,14 @@ class ShowConfiguracionChat extends Component
|
||||
$result = @file_get_contents($apiUrl, false, $context);
|
||||
|
||||
if ($result === false) {
|
||||
$this->webhookResult = '❌ Error de conexión con la API de Telegram.';
|
||||
$this->webhookResult = 'Error de conexion con la API de Telegram.';
|
||||
return;
|
||||
}
|
||||
|
||||
$data = json_decode($result, true);
|
||||
$this->webhookResult = ($data['ok'] ?? false)
|
||||
? '✅ Webhook registrado correctamente: ' . ($data['description'] ?? 'OK')
|
||||
: '❌ Error: ' . ($data['description'] ?? 'respuesta desconocida');
|
||||
? 'Webhook registrado: ' . ($data['description'] ?? 'OK')
|
||||
: 'Error: ' . ($data['description'] ?? 'respuesta desconocida');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -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;">© {$appName} — soporte a traves del chat</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,24 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ChatContact extends Model
|
||||
{
|
||||
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'];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function conversations(): HasMany
|
||||
{
|
||||
return $this->hasMany(ChatConversation::class, 'contact_id');
|
||||
|
||||
@@ -8,7 +8,15 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
class ChatMessage extends Model
|
||||
{
|
||||
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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ChatConfig;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class GeminiIntentService
|
||||
{
|
||||
private string $apiKey;
|
||||
private string $apiUrl = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->apiKey = ChatConfig::get('gemini_api_key', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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): ?array
|
||||
{
|
||||
if (! $this->apiKey || ChatConfig::get('gemini_habilitado', '0') !== '1') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$prompt = $this->buildPrompt($texto);
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders(['Content-Type' => 'application/json'])
|
||||
->timeout(8)
|
||||
->post("{$this->apiUrl}?key={$this->apiKey}", [
|
||||
'contents' => [['parts' => [['text' => $prompt]]]],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0.1,
|
||||
'maxOutputTokens' => 100,
|
||||
],
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::warning('[Gemini Intent] API error: ' . $response->body());
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = $response->json('candidates.0.content.parts.0.text', '');
|
||||
return $this->parseRespuesta($text);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('[Gemini Intent] Excepcion: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function buildPrompt(string $texto): string
|
||||
{
|
||||
$promptExtra = ChatConfig::get('gemini_prompt_extra', '');
|
||||
|
||||
return <<<PROMPT
|
||||
Eres un clasificador de intenciones para una tienda de streaming (peliculas, series).
|
||||
{$promptExtra}
|
||||
|
||||
Acciones validas (devuelve EXACTAMENTE una de estas en el campo "action"):
|
||||
- menu.principal (usuario quiere ver el menu, inicio, ayuda general)
|
||||
- servicios.listar (quiere ver servicios, planes, catalogo, que hay disponible)
|
||||
- promo.listar (quiere ver promociones, ofertas, descuentos)
|
||||
- recarga.iniciar (quiere recargar saldo, agregar dinero, cargar cuenta)
|
||||
- credenciales.listar (quiere ver sus credenciales, usuario, contrasena, cuenta, acceso)
|
||||
- historial.ver (quiere ver historial, compras anteriores, que ha comprado)
|
||||
- perfil.ver (quiere ver su perfil, sus datos, su informacion)
|
||||
- asesor.solicitar (quiere hablar con una persona, agente, asesor, humano, soporte)
|
||||
|
||||
Texto del usuario: "{$texto}"
|
||||
|
||||
Responde UNICAMENTE con un JSON valido, sin markdown, sin explicacion:
|
||||
{"action": "la_accion", "data": {}}
|
||||
|
||||
Si no puedes determinar la intencion con certeza, 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'] : [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ChatConfig;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class GeminiVisionService
|
||||
{
|
||||
private string $apiKey;
|
||||
private string $apiUrl = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->apiKey = ChatConfig::get('gemini_api_key', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae datos de pago de una imagen de comprobante.
|
||||
*
|
||||
* @param string $base64 Imagen en base64
|
||||
* @param string $mimeType MIME type (image/jpeg, image/png, etc.)
|
||||
* @return array|null ['banco', 'valor', 'referencia', 'fecha', 'hora', 'remitente'] o null
|
||||
*/
|
||||
public function extraerPago(string $base64, string $mimeType): ?array
|
||||
{
|
||||
if (! $this->apiKey) {
|
||||
Log::warning('[Gemini Vision] No hay API key configurada.');
|
||||
return null;
|
||||
}
|
||||
|
||||
$prompt = $this->buildPrompt();
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders(['Content-Type' => 'application/json'])
|
||||
->timeout(15)
|
||||
->post("{$this->apiUrl}?key={$this->apiKey}", [
|
||||
'contents' => [[
|
||||
'parts' => [
|
||||
['text' => $prompt],
|
||||
['inline_data' => ['mime_type' => $mimeType, 'data' => $base64]],
|
||||
],
|
||||
]],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0.1,
|
||||
'maxOutputTokens' => 200,
|
||||
],
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::warning('[Gemini Vision] API error: ' . $response->body());
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = $response->json('candidates.0.content.parts.0.text', '');
|
||||
return $this->parseRespuesta($text);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('[Gemini Vision] Excepcion: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function buildPrompt(): string
|
||||
{
|
||||
return <<<PROMPT
|
||||
Analiza esta imagen de comprobante de pago bancario o transferencia.
|
||||
|
||||
Extrae los siguientes campos:
|
||||
- banco: nombre del banco (Bancolombia, Nequi, Davivienda, 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)
|
||||
|
||||
Responde UNICAMENTE con un JSON valido, sin markdown, sin explicacion:
|
||||
{"banco": "...", "valor": 50000, "referencia": "...", "fecha": "...", "hora": "...", "remitente": "..."}
|
||||
|
||||
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 parseRespuesta(string $text): ?array
|
||||
{
|
||||
$text = trim($text);
|
||||
$text = preg_replace('/```json\s*|\s*```/', '', $text);
|
||||
$text = trim($text);
|
||||
|
||||
$json = json_decode($text, true);
|
||||
|
||||
if (! is_array($json)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isset($json['error'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalizar valor a entero
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ChatConfig;
|
||||
use App\Models\WhatsappSystemConfig;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PagoValidadorService
|
||||
{
|
||||
/**
|
||||
* Cruza los datos extraídos por IA con los correos IMAP recientes.
|
||||
*
|
||||
* @param array $datosPago ['banco', 'valor', 'referencia', 'fecha', 'hora', 'remitente']
|
||||
* @return array ['estado' => confirmado|no_encontrado|monto_incorrecto, 'correo' => array|null]
|
||||
*/
|
||||
public function validar(array $datosPago): array
|
||||
{
|
||||
if (WhatsappSystemConfig::get('correo_imap_enabled', '0') !== '1') {
|
||||
return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'correo_deshabilitado'];
|
||||
}
|
||||
|
||||
try {
|
||||
$correos = $this->fetchCorreos();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('[PagoValidador] Error leyendo correos: ' . $e->getMessage());
|
||||
return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'error_imap'];
|
||||
}
|
||||
|
||||
if (empty($correos)) {
|
||||
return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'sin_correos'];
|
||||
}
|
||||
|
||||
$valorIA = (int) ($datosPago['valor'] ?? 0);
|
||||
|
||||
foreach ($correos as $correo) {
|
||||
$datosCorreo = BancolombiaParser::parse($correo['body'] ?? '');
|
||||
|
||||
if (! $datosCorreo) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$valorCorreo = (int) ($datosCorreo['valor'] ?? 0);
|
||||
|
||||
// Verificar referencia si ambas están disponibles
|
||||
if (! empty($datosPago['referencia']) && ! empty($datosCorreo['referencia'])) {
|
||||
if ($datosPago['referencia'] === $datosCorreo['referencia']) {
|
||||
return ['estado' => 'confirmado', 'correo' => $datosCorreo];
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar valor
|
||||
if ($valorIA > 0 && $valorCorreo > 0) {
|
||||
$tolerancia = (int) ChatConfig::get('validacion_monto_tolerancia', '0');
|
||||
|
||||
if (abs($valorIA - $valorCorreo) <= $tolerancia) {
|
||||
return ['estado' => 'confirmado', 'correo' => $datosCorreo];
|
||||
} else {
|
||||
return ['estado' => 'monto_incorrecto', 'correo' => $datosCorreo,
|
||||
'esperado' => $valorCorreo, 'recibido' => $valorIA];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'sin_coincidencia'];
|
||||
}
|
||||
|
||||
private function fetchCorreos(): array
|
||||
{
|
||||
$minutos = (int) WhatsappSystemConfig::get('correo_imap_minutos', '5');
|
||||
|
||||
$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: 10, minutesBack: $minutos);
|
||||
}
|
||||
}
|
||||
@@ -36,4 +36,5 @@ return [
|
||||
'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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,3 @@
|
||||
{{--
|
||||
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;">
|
||||
@@ -23,33 +19,20 @@
|
||||
|
||||
<form wire:submit.prevent="iniciar" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-[#8696a0] text-xs font-medium mb-1.5 uppercase tracking-wider">
|
||||
Teléfono *
|
||||
</label>
|
||||
{{-- font-size 16px (via CSS global) evita el zoom automático en Safari --}}
|
||||
<input wire:model.defer="telefono"
|
||||
type="tel"
|
||||
inputmode="tel"
|
||||
autocomplete="tel"
|
||||
<label class="block text-[#8696a0] text-xs font-medium mb-1.5 uppercase tracking-wider">Teléfono *</label>
|
||||
<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]"
|
||||
style="color-scheme: dark;">
|
||||
@error('telefono')
|
||||
<span class="text-red-400 text-xs mt-1 block">{{ $message }}</span>
|
||||
@enderror
|
||||
@error('telefono') <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">
|
||||
Nombre (opcional)
|
||||
</label>
|
||||
<input wire:model.defer="nombre"
|
||||
type="text"
|
||||
autocomplete="name"
|
||||
<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"
|
||||
class="w-full bg-[#00a884] active:bg-[#06cf9c] text-white font-semibold rounded-xl transition shadow-lg"
|
||||
style="min-height: 48px;">
|
||||
@@ -61,16 +44,73 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ══ PASO 2: Interfaz de chat ══ --}}
|
||||
{{-- ══ PASO 2: 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
|
||||
|
||||
<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="one-time-code"
|
||||
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 →</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', 'telefono')" type="button"
|
||||
class="text-[#8696a0] text-xs hover:text-white">
|
||||
Volver
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ══ PASO 3: Interfaz de chat ══ --}}
|
||||
@else
|
||||
<div class="flex flex-col h-full bg-[#0b141a]"
|
||||
x-data
|
||||
x-data="{ uploading: false }"
|
||||
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) --}}
|
||||
{{-- Header --}}
|
||||
<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="w-10 h-10 bg-[#00a884] rounded-full flex items-center justify-center flex-shrink-0">
|
||||
@@ -79,62 +119,265 @@
|
||||
</svg>
|
||||
</div>
|
||||
<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">
|
||||
@if ($estadoConv === 'agente') Atendido por un agente
|
||||
@elseif ($estadoConv === 'cerrada') Conversación cerrada
|
||||
@else Bot activo
|
||||
@endif
|
||||
@else Bot activo @endif
|
||||
</p>
|
||||
</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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Mensajes — ios-scroll da momentum nativo en iPhone --}}
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4 space-y-2 ios-scroll"
|
||||
{{-- Mensajes --}}
|
||||
<div class="flex-1 overflow-y-auto px-3 py-4 space-y-3 ios-scroll"
|
||||
id="pub-messages"
|
||||
wire:poll.3000ms="cargarMensajes"
|
||||
style="overscroll-behavior-y: contain;">
|
||||
|
||||
@forelse($mensajes as $msg)
|
||||
|
||||
{{-- ── Mensaje del USUARIO ── --}}
|
||||
@if ($msg['tipo'] === 'usuario')
|
||||
<div class="flex justify-end">
|
||||
<div class="max-w-[80%] bg-[#005c4b] text-white rounded-xl rounded-tr-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>
|
||||
@if (($msg['tipo_ui'] ?? 'text') === 'imagen')
|
||||
<div class="max-w-[75%] rounded-xl rounded-tr-sm overflow-hidden shadow">
|
||||
<img src="{{ $msg['payload']['url'] ?? '' }}" class="w-full object-cover" style="max-height:220px;">
|
||||
<div class="bg-[#005c4b] px-3 py-1 text-right">
|
||||
<span class="text-[#8696a0] text-[11px]">{{ $msg['created_at'] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="max-w-[80%] bg-[#005c4b] text-white rounded-xl rounded-tr-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>
|
||||
|
||||
{{-- ── Mensajes del BOT / AGENTE ── --}}
|
||||
@else
|
||||
<div class="flex justify-start">
|
||||
<div class="max-w-[80%] bg-[#202c33] text-white rounded-xl rounded-tl-sm px-4 py-2 shadow">
|
||||
@if ($msg['tipo'] === 'agente')
|
||||
<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>
|
||||
@php $tipoUi = $msg['tipo_ui'] ?? 'text'; $payload = $msg['payload'] ?? []; @endphp
|
||||
|
||||
{{-- TEXTO normal --}}
|
||||
@if ($tipoUi === 'text')
|
||||
<div class="max-w-[80%] bg-[#202c33] text-white rounded-xl rounded-tl-sm px-4 py-2 shadow">
|
||||
@if ($msg['tipo'] === 'agente')
|
||||
<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="{{ $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>
|
||||
|
||||
{{-- 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 🔍 No encontrado en correos
|
||||
@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">
|
||||
<div class="px-4 py-2 bg-[#00a884]/20">
|
||||
<p class="text-[#00a884] text-xs font-semibold uppercase tracking-wider">{{ $payload['servicio'] ?? 'Credenciales' }}</p>
|
||||
</div>
|
||||
<div class="px-4 py-3 space-y-2">
|
||||
@if (!empty($payload['email']))
|
||||
<div class="bg-[#0b141a] rounded-lg px-3 py-2">
|
||||
<p class="text-[#8696a0] text-[11px] mb-0.5">Usuario / Email</p>
|
||||
<p class="text-white text-sm font-mono break-all">{{ $payload['email'] }}</p>
|
||||
</div>
|
||||
@endif
|
||||
@if (!empty($payload['password']))
|
||||
<div class="bg-[#0b141a] rounded-lg px-3 py-2">
|
||||
<p class="text-[#8696a0] text-[11px] mb-0.5">Contraseña</p>
|
||||
<p class="text-white text-sm font-mono">{{ $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">{{ $payload['perfil'] }}</p>
|
||||
</div>
|
||||
@endif
|
||||
@if (!empty($payload['vence']))
|
||||
<p class="text-[#8696a0] text-xs text-center mt-1">Vence: {{ $payload['vence'] }}</p>
|
||||
@endif
|
||||
</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>
|
||||
@endif
|
||||
|
||||
@empty
|
||||
<div class="text-center text-[#8696a0] text-sm py-10">Iniciando chat...</div>
|
||||
@endforelse
|
||||
{{-- Ancla para scroll al fondo --}}
|
||||
|
||||
<div id="pub-messages-end"></div>
|
||||
</div>
|
||||
|
||||
{{-- Input + safe area bottom (home indicator iPhone X+) --}}
|
||||
{{-- Input + upload --}}
|
||||
@if ($estadoConv !== 'cerrada')
|
||||
<div class="bg-[#202c33] flex-shrink-0 safe-bottom safe-left safe-right">
|
||||
{{-- Indicador de procesando imagen --}}
|
||||
@if ($procesandoImagen)
|
||||
<div 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>
|
||||
Analizando comprobante con IA...
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
<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" capture="environment">
|
||||
<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>
|
||||
<textarea
|
||||
wire:model.defer="input"
|
||||
wire:keydown.enter.prevent="enviar"
|
||||
rows="1"
|
||||
placeholder="Escribe un mensaje..."
|
||||
{{-- font-size 16px evita zoom; border-radius cubre -webkit-appearance --}}
|
||||
placeholder="Escribe o usa los botones..."
|
||||
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;"
|
||||
></textarea>
|
||||
{{-- 44x44 mínimo Apple HIG --}}
|
||||
<button wire:click="enviar"
|
||||
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;">
|
||||
|
||||
@@ -57,6 +57,63 @@
|
||||
@error('mensaje_transferencia') <span class="text-red-500 text-xs">{{ $message }}</span> @enderror
|
||||
</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 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>
|
||||
</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>
|
||||
|
||||
{{-- Guardar --}}
|
||||
<div class="flex justify-end pt-2">
|
||||
<button wire:click="guardar"
|
||||
@@ -64,7 +121,7 @@
|
||||
<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" />
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user