chatbot nuevo
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
class ChatController extends Controller
|
||||
{
|
||||
public function publicPage()
|
||||
{
|
||||
return view('chat-publico');
|
||||
}
|
||||
|
||||
public function conversaciones()
|
||||
{
|
||||
return view('chat.conversaciones');
|
||||
}
|
||||
|
||||
public function menus()
|
||||
{
|
||||
return view('chat.menus');
|
||||
}
|
||||
|
||||
public function configuracion()
|
||||
{
|
||||
return view('chat.configuracion');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\ChatBotEngine;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TelegramWebhookController extends Controller
|
||||
{
|
||||
public function handle(Request $request)
|
||||
{
|
||||
$update = $request->all();
|
||||
|
||||
// Solo procesar mensajes de texto
|
||||
if (! isset($update['message']['text'])) {
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
$chatId = (string) $update['message']['chat']['id'];
|
||||
$text = $update['message']['text'];
|
||||
$nombre = trim(
|
||||
($update['message']['from']['first_name'] ?? '') . ' ' .
|
||||
($update['message']['from']['last_name'] ?? '')
|
||||
);
|
||||
|
||||
$engine = new ChatBotEngine();
|
||||
$replies = $engine->handle('telegram', $chatId, $text, $nombre);
|
||||
|
||||
foreach ($replies as $reply) {
|
||||
$engine->enviarTelegram($chatId, $reply);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Chat;
|
||||
|
||||
use App\Models\ChatContact;
|
||||
use App\Models\ChatConversation;
|
||||
use App\Models\ChatMessage;
|
||||
use App\Services\ChatBotEngine;
|
||||
use Livewire\Component;
|
||||
|
||||
class PublicChat extends Component
|
||||
{
|
||||
public string $paso = 'telefono'; // 'telefono' | 'chat'
|
||||
public string $telefono = '';
|
||||
public string $nombre = '';
|
||||
public string $input = '';
|
||||
public array $mensajes = [];
|
||||
public ?int $convId = null;
|
||||
public string $estadoConv = 'bot';
|
||||
|
||||
public function iniciar()
|
||||
{
|
||||
$this->validate([
|
||||
'telefono' => 'required|min:7|max:20',
|
||||
'nombre' => 'nullable|max:80',
|
||||
]);
|
||||
|
||||
$contact = ChatContact::firstOrCreate(
|
||||
['canal' => 'web', 'canal_id' => $this->telefono],
|
||||
['nombre' => $this->nombre ?: $this->telefono, 'telefono' => $this->telefono]
|
||||
);
|
||||
|
||||
if ($this->nombre && $contact->nombre !== $this->nombre) {
|
||||
$contact->update(['nombre' => $this->nombre]);
|
||||
}
|
||||
|
||||
$conv = $contact->activeConversation();
|
||||
|
||||
if (! $conv) {
|
||||
// Primera vez — el engine inicia el flujo con "hola"
|
||||
$engine = new ChatBotEngine();
|
||||
$engine->handle('web', $this->telefono, 'hola', $contact->nombre);
|
||||
$contact->refresh();
|
||||
$conv = $contact->activeConversation();
|
||||
}
|
||||
|
||||
if ($conv) {
|
||||
$this->convId = $conv->id;
|
||||
$this->estadoConv = $conv->estado;
|
||||
$this->cargarMensajes();
|
||||
}
|
||||
|
||||
$this->paso = 'chat';
|
||||
}
|
||||
|
||||
public function enviar()
|
||||
{
|
||||
if (! $this->convId || trim($this->input) === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$texto = trim($this->input);
|
||||
$this->input = '';
|
||||
|
||||
$engine = new ChatBotEngine();
|
||||
$engine->handle('web', $this->telefono, $texto, $this->nombre ?: $this->telefono);
|
||||
|
||||
$conv = ChatConversation::find($this->convId);
|
||||
if ($conv) {
|
||||
$this->estadoConv = $conv->fresh()->estado;
|
||||
}
|
||||
|
||||
$this->cargarMensajes();
|
||||
// scroll ya se dispara desde cargarMensajes()
|
||||
}
|
||||
|
||||
public function cargarMensajes()
|
||||
{
|
||||
if (! $this->convId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->mensajes = ChatMessage::where('conversation_id', $this->convId)
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->map(fn($m) => [
|
||||
'tipo' => $m->tipo,
|
||||
'contenido' => $m->contenido,
|
||||
'created_at' => $m->created_at->format('H:i'),
|
||||
])
|
||||
->toArray();
|
||||
|
||||
$this->dispatchBrowserEvent('scroll-chat');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.chat.public-chat');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Chat;
|
||||
|
||||
use App\Models\ChatConfig;
|
||||
use Jantinnerezo\LivewireAlert\LivewireAlert;
|
||||
use Livewire\Component;
|
||||
|
||||
class ShowConfiguracionChat extends Component
|
||||
{
|
||||
use LivewireAlert;
|
||||
|
||||
public string $telegram_token = '';
|
||||
public string $mensaje_bienvenida = '';
|
||||
public string $mensaje_transferencia = '';
|
||||
public string $webhookResult = '';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->telegram_token = ChatConfig::get('telegram_token', '');
|
||||
$this->mensaje_bienvenida = ChatConfig::get('mensaje_bienvenida', 'Hola 👋 Bienvenido. Escribe tu consulta.');
|
||||
$this->mensaje_transferencia = ChatConfig::get('mensaje_transferencia', 'Un agente se comunicará contigo en breve. Por favor espera.');
|
||||
}
|
||||
|
||||
public function guardar(): void
|
||||
{
|
||||
$this->validate([
|
||||
'mensaje_bienvenida' => 'required|string|max:500',
|
||||
'mensaje_transferencia' => 'required|string|max:500',
|
||||
]);
|
||||
|
||||
ChatConfig::set('telegram_token', $this->telegram_token);
|
||||
ChatConfig::set('mensaje_bienvenida', $this->mensaje_bienvenida);
|
||||
ChatConfig::set('mensaje_transferencia', $this->mensaje_transferencia);
|
||||
|
||||
$this->alert('success', 'Configuración guardada correctamente.');
|
||||
}
|
||||
|
||||
public function registrarWebhook(): void
|
||||
{
|
||||
$token = trim($this->telegram_token);
|
||||
|
||||
if (! $token) {
|
||||
$this->webhookResult = '⚠️ El token de Telegram está vacío.';
|
||||
return;
|
||||
}
|
||||
|
||||
// Guardar el token antes de registrar
|
||||
ChatConfig::set('telegram_token', $token);
|
||||
|
||||
$webhookUrl = url('/chat/webhook/telegram');
|
||||
$apiUrl = "https://api.telegram.org/bot{$token}/setWebhook";
|
||||
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
|
||||
'content' => http_build_query(['url' => $webhookUrl]),
|
||||
'timeout' => 10,
|
||||
],
|
||||
]);
|
||||
|
||||
$result = @file_get_contents($apiUrl, false, $context);
|
||||
|
||||
if ($result === false) {
|
||||
$this->webhookResult = '❌ Error de conexión con la API de Telegram.';
|
||||
return;
|
||||
}
|
||||
|
||||
$data = json_decode($result, true);
|
||||
$this->webhookResult = ($data['ok'] ?? false)
|
||||
? '✅ Webhook registrado correctamente: ' . ($data['description'] ?? 'OK')
|
||||
: '❌ Error: ' . ($data['description'] ?? 'respuesta desconocida');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.chat.show-configuracion-chat');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Chat;
|
||||
|
||||
use App\Models\ChatConversation;
|
||||
use App\Models\ChatMessage;
|
||||
use App\Services\ChatBotEngine;
|
||||
use Livewire\Component;
|
||||
|
||||
class ShowConversaciones extends Component
|
||||
{
|
||||
public string $busqueda = '';
|
||||
public string $filtroEstado = '';
|
||||
public ?int $convSelecId = null;
|
||||
public string $replyText = '';
|
||||
public array $mensajes = [];
|
||||
|
||||
public function selectConv(int $convId): void
|
||||
{
|
||||
$this->convSelecId = $convId;
|
||||
$this->replyText = '';
|
||||
$this->cargarMensajes();
|
||||
|
||||
// Marcar mensajes entrantes como leídos
|
||||
ChatMessage::where('conversation_id', $convId)
|
||||
->where('tipo', 'usuario')
|
||||
->where('leido', false)
|
||||
->update(['leido' => true]);
|
||||
|
||||
ChatConversation::where('id', $convId)
|
||||
->update(['mensajes_no_leidos' => 0]);
|
||||
}
|
||||
|
||||
public function cargarMensajes(): void
|
||||
{
|
||||
if (! $this->convSelecId) {
|
||||
$this->mensajes = [];
|
||||
return;
|
||||
}
|
||||
|
||||
$this->mensajes = ChatMessage::where('conversation_id', $this->convSelecId)
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->map(fn($m) => [
|
||||
'id' => $m->id,
|
||||
'tipo' => $m->tipo,
|
||||
'contenido' => $m->contenido,
|
||||
'created_at' => $m->created_at->format('d/m H:i'),
|
||||
])
|
||||
->toArray();
|
||||
|
||||
$this->dispatchBrowserEvent('scroll-admin');
|
||||
}
|
||||
|
||||
public function sendReply(): void
|
||||
{
|
||||
$this->validate(['replyText' => 'required|string|max:4096']);
|
||||
|
||||
if (! $this->convSelecId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$conv = ChatConversation::with('contact')->find($this->convSelecId);
|
||||
if (! $conv) {
|
||||
return;
|
||||
}
|
||||
|
||||
ChatMessage::create([
|
||||
'conversation_id' => $this->convSelecId,
|
||||
'tipo' => 'agente',
|
||||
'contenido' => $this->replyText,
|
||||
'leido' => true,
|
||||
]);
|
||||
|
||||
$conv->update(['ultimo_mensaje_at' => now()]);
|
||||
|
||||
// Enviar via Telegram si el canal es telegram
|
||||
if ($conv->canal === 'telegram') {
|
||||
$engine = new ChatBotEngine();
|
||||
$engine->enviarTelegram($conv->contact->canal_id, $this->replyText);
|
||||
}
|
||||
|
||||
$this->replyText = '';
|
||||
$this->cargarMensajes();
|
||||
}
|
||||
|
||||
public function cerrarConv(): void
|
||||
{
|
||||
if (! $this->convSelecId) {
|
||||
return;
|
||||
}
|
||||
|
||||
ChatConversation::where('id', $this->convSelecId)
|
||||
->update(['estado' => 'cerrada']);
|
||||
|
||||
$this->cargarMensajes();
|
||||
}
|
||||
|
||||
public function devolverAlBot(): void
|
||||
{
|
||||
if (! $this->convSelecId) {
|
||||
return;
|
||||
}
|
||||
|
||||
ChatConversation::where('id', $this->convSelecId)
|
||||
->update(['estado' => 'bot', 'menu_actual_id' => null]);
|
||||
}
|
||||
|
||||
public function tomarControl(): void
|
||||
{
|
||||
if (! $this->convSelecId) {
|
||||
return;
|
||||
}
|
||||
|
||||
ChatConversation::where('id', $this->convSelecId)
|
||||
->update(['estado' => 'agente']);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$conversaciones = ChatConversation::with('contact')
|
||||
->when($this->filtroEstado, fn($q) => $q->where('estado', $this->filtroEstado))
|
||||
->when($this->busqueda, function ($q) {
|
||||
$q->whereHas('contact', function ($s) {
|
||||
$s->where('nombre', 'like', "%{$this->busqueda}%")
|
||||
->orWhere('telefono', 'like', "%{$this->busqueda}%")
|
||||
->orWhere('canal_id', 'like', "%{$this->busqueda}%");
|
||||
});
|
||||
})
|
||||
->orderByDesc('ultimo_mensaje_at')
|
||||
->get();
|
||||
|
||||
$convSelec = $this->convSelecId
|
||||
? ChatConversation::with('contact')->find($this->convSelecId)
|
||||
: null;
|
||||
|
||||
return view('livewire.chat.show-conversaciones', compact('conversaciones', 'convSelec'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Chat;
|
||||
|
||||
use App\Models\ChatMenu;
|
||||
use App\Models\ChatMenuOption;
|
||||
use Livewire\Component;
|
||||
|
||||
class ShowMenusChat extends Component
|
||||
{
|
||||
// ── Formulario Menú ──────────────────────────────────
|
||||
public bool $showMenuForm = false;
|
||||
public ?int $editMenuId = null;
|
||||
public string $menuNombre = '';
|
||||
public string $menuMensaje = '';
|
||||
public bool $menuEsRaiz = false;
|
||||
public bool $menuActivo = true;
|
||||
|
||||
// ── Formulario Opción ─────────────────────────────────
|
||||
public bool $showOpcionForm = false;
|
||||
public ?int $editOpcionId = null;
|
||||
public ?int $opcionMenuId = null;
|
||||
public string $opcionClave = '';
|
||||
public string $opcionEtiqueta = '';
|
||||
public string $opcionAccion = 'respuesta'; // ir_menu | respuesta | agente
|
||||
public ?int $opcionMenuDestId = null;
|
||||
public string $opcionRespuesta = '';
|
||||
public int $opcionOrden = 0;
|
||||
|
||||
// ── CRUD Menú ─────────────────────────────────────────
|
||||
|
||||
public function saveMenu(): void
|
||||
{
|
||||
$this->validate([
|
||||
'menuNombre' => 'required|string|max:80',
|
||||
'menuMensaje' => 'required|string|max:500',
|
||||
]);
|
||||
|
||||
if ($this->menuEsRaiz) {
|
||||
ChatMenu::where('es_raiz', true)->update(['es_raiz' => false]);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'nombre' => $this->menuNombre,
|
||||
'mensaje' => $this->menuMensaje,
|
||||
'es_raiz' => $this->menuEsRaiz,
|
||||
'activo' => $this->menuActivo,
|
||||
];
|
||||
|
||||
if ($this->editMenuId) {
|
||||
ChatMenu::where('id', $this->editMenuId)->update($data);
|
||||
} else {
|
||||
ChatMenu::create($data);
|
||||
}
|
||||
|
||||
$this->closeMenuForm();
|
||||
}
|
||||
|
||||
public function editMenu(int $id): void
|
||||
{
|
||||
$menu = ChatMenu::findOrFail($id);
|
||||
$this->editMenuId = $id;
|
||||
$this->menuNombre = $menu->nombre;
|
||||
$this->menuMensaje = $menu->mensaje;
|
||||
$this->menuEsRaiz = (bool) $menu->es_raiz;
|
||||
$this->menuActivo = (bool) $menu->activo;
|
||||
$this->showMenuForm = true;
|
||||
}
|
||||
|
||||
public function deleteMenu(int $id): void
|
||||
{
|
||||
ChatMenu::destroy($id);
|
||||
}
|
||||
|
||||
public function closeMenuForm(): void
|
||||
{
|
||||
$this->showMenuForm = false;
|
||||
$this->editMenuId = null;
|
||||
$this->menuNombre = '';
|
||||
$this->menuMensaje = '';
|
||||
$this->menuEsRaiz = false;
|
||||
$this->menuActivo = true;
|
||||
}
|
||||
|
||||
// ── CRUD Opción ───────────────────────────────────────
|
||||
|
||||
public function saveOpcion(): void
|
||||
{
|
||||
$this->validate([
|
||||
'opcionClave' => 'required|string|max:20',
|
||||
'opcionEtiqueta' => 'required|string|max:150',
|
||||
'opcionAccion' => 'required|in:ir_menu,respuesta,agente',
|
||||
'opcionMenuDestId' => 'nullable|exists:chat_menus,id',
|
||||
'opcionRespuesta' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
$data = [
|
||||
'menu_id' => $this->opcionMenuId,
|
||||
'clave' => strtolower(trim($this->opcionClave)),
|
||||
'etiqueta' => $this->opcionEtiqueta,
|
||||
'accion' => $this->opcionAccion,
|
||||
'menu_destino_id' => $this->opcionAccion === 'ir_menu' ? $this->opcionMenuDestId : null,
|
||||
'respuesta_texto' => $this->opcionAccion === 'respuesta' ? $this->opcionRespuesta : null,
|
||||
'orden' => $this->opcionOrden,
|
||||
];
|
||||
|
||||
if ($this->editOpcionId) {
|
||||
ChatMenuOption::where('id', $this->editOpcionId)->update($data);
|
||||
} else {
|
||||
ChatMenuOption::create($data);
|
||||
}
|
||||
|
||||
$this->closeOpcionForm();
|
||||
}
|
||||
|
||||
public function openOpcionForm(int $menuId): void
|
||||
{
|
||||
$this->opcionMenuId = $menuId;
|
||||
$this->editOpcionId = null;
|
||||
$this->opcionClave = '';
|
||||
$this->opcionEtiqueta = '';
|
||||
$this->opcionAccion = 'respuesta';
|
||||
$this->opcionMenuDestId = null;
|
||||
$this->opcionRespuesta = '';
|
||||
$this->opcionOrden = 0;
|
||||
$this->showOpcionForm = true;
|
||||
}
|
||||
|
||||
public function editOpcion(int $id): void
|
||||
{
|
||||
$op = ChatMenuOption::findOrFail($id);
|
||||
$this->editOpcionId = $id;
|
||||
$this->opcionMenuId = $op->menu_id;
|
||||
$this->opcionClave = $op->clave;
|
||||
$this->opcionEtiqueta = $op->etiqueta;
|
||||
$this->opcionAccion = $op->accion;
|
||||
$this->opcionMenuDestId = $op->menu_destino_id;
|
||||
$this->opcionRespuesta = $op->respuesta_texto ?? '';
|
||||
$this->opcionOrden = $op->orden;
|
||||
$this->showOpcionForm = true;
|
||||
}
|
||||
|
||||
public function deleteOpcion(int $id): void
|
||||
{
|
||||
ChatMenuOption::destroy($id);
|
||||
}
|
||||
|
||||
public function closeOpcionForm(): void
|
||||
{
|
||||
$this->showOpcionForm = false;
|
||||
$this->editOpcionId = null;
|
||||
$this->opcionMenuId = null;
|
||||
$this->opcionClave = '';
|
||||
$this->opcionEtiqueta = '';
|
||||
$this->opcionAccion = 'respuesta';
|
||||
$this->opcionMenuDestId = null;
|
||||
$this->opcionRespuesta = '';
|
||||
$this->opcionOrden = 0;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$menus = ChatMenu::with('options.menuDestino')->orderByDesc('es_raiz')->orderBy('nombre')->get();
|
||||
$todosMenus = ChatMenu::orderBy('nombre')->get();
|
||||
|
||||
return view('livewire.chat.show-menus-chat', compact('menus', 'todosMenus'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ChatConfig extends Model
|
||||
{
|
||||
protected $table = 'chat_config';
|
||||
protected $fillable = ['config_key', 'config_value', 'description'];
|
||||
|
||||
public static function get(string $key, string $default = ''): string
|
||||
{
|
||||
return static::where('config_key', $key)->value('config_value') ?? $default;
|
||||
}
|
||||
|
||||
public static function set(string $key, string $value): void
|
||||
{
|
||||
static::updateOrCreate(
|
||||
['config_key' => $key],
|
||||
['config_value' => $value]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ChatContact extends Model
|
||||
{
|
||||
protected $table = 'chat_contacts';
|
||||
protected $fillable = ['canal', 'canal_id', 'nombre', 'telefono', 'metadata'];
|
||||
protected $casts = ['metadata' => 'array'];
|
||||
|
||||
public function conversations(): HasMany
|
||||
{
|
||||
return $this->hasMany(ChatConversation::class, 'contact_id');
|
||||
}
|
||||
|
||||
public function activeConversation(): ?ChatConversation
|
||||
{
|
||||
return $this->conversations()
|
||||
->whereIn('estado', ['bot', 'agente'])
|
||||
->latest()
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ChatConversation extends Model
|
||||
{
|
||||
protected $table = 'chat_conversations';
|
||||
protected $fillable = ['contact_id', 'canal', 'estado', 'menu_actual_id', 'ultimo_mensaje_at', 'mensajes_no_leidos'];
|
||||
protected $casts = ['ultimo_mensaje_at' => 'datetime'];
|
||||
|
||||
public function contact(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ChatContact::class, 'contact_id');
|
||||
}
|
||||
|
||||
public function messages(): HasMany
|
||||
{
|
||||
return $this->hasMany(ChatMessage::class, 'conversation_id');
|
||||
}
|
||||
|
||||
public function menuActual(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ChatMenu::class, 'menu_actual_id');
|
||||
}
|
||||
|
||||
public function canalBadge(): string
|
||||
{
|
||||
return match($this->canal) {
|
||||
'telegram' => '✈️ Telegram',
|
||||
default => '💬 Web',
|
||||
};
|
||||
}
|
||||
|
||||
public function canalColor(): string
|
||||
{
|
||||
return match($this->canal) {
|
||||
'telegram' => 'bg-blue-100 text-blue-700',
|
||||
default => 'bg-green-100 text-green-700',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ChatMenu extends Model
|
||||
{
|
||||
protected $table = 'chat_menus';
|
||||
protected $fillable = ['nombre', 'mensaje', 'es_raiz', 'activo'];
|
||||
protected $casts = ['es_raiz' => 'boolean', 'activo' => 'boolean'];
|
||||
|
||||
public function options(): HasMany
|
||||
{
|
||||
return $this->hasMany(ChatMenuOption::class, 'menu_id')->orderBy('orden');
|
||||
}
|
||||
|
||||
public static function raiz(): ?self
|
||||
{
|
||||
return static::where('es_raiz', true)->where('activo', true)->first();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ChatMenuOption extends Model
|
||||
{
|
||||
protected $table = 'chat_menu_options';
|
||||
protected $fillable = ['menu_id', 'clave', 'etiqueta', 'accion', 'menu_destino_id', 'respuesta_texto', 'orden'];
|
||||
|
||||
public function menu(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ChatMenu::class, 'menu_id');
|
||||
}
|
||||
|
||||
public function menuDestino(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ChatMenu::class, 'menu_destino_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ChatMessage extends Model
|
||||
{
|
||||
protected $table = 'chat_messages';
|
||||
protected $fillable = ['conversation_id', 'tipo', 'contenido', 'leido'];
|
||||
|
||||
public function conversation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ChatConversation::class, 'conversation_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ChatConfig;
|
||||
use App\Models\ChatContact;
|
||||
use App\Models\ChatConversation;
|
||||
use App\Models\ChatMessage;
|
||||
use App\Models\ChatMenu;
|
||||
|
||||
/**
|
||||
* ChatBotEngine
|
||||
*
|
||||
* Procesa un mensaje entrante de cualquier canal y devuelve
|
||||
* la respuesta que el bot debe enviar.
|
||||
*
|
||||
* Uso:
|
||||
* $engine = new ChatBotEngine();
|
||||
* $reply = $engine->handle('web', $telefono, $textoUsuario);
|
||||
* // $reply es un array de strings a enviar
|
||||
*/
|
||||
class ChatBotEngine
|
||||
{
|
||||
// ─────────────────────────────────────────────
|
||||
// Punto de entrada principal
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param string $canal 'web' | 'telegram'
|
||||
* @param string $canalId teléfono o chat_id de Telegram
|
||||
* @param string $texto texto enviado por el usuario
|
||||
* @param string $nombre nombre del contacto (opcional)
|
||||
* @return string[] array de mensajes a enviar de vuelta
|
||||
*/
|
||||
public function handle(string $canal, string $canalId, string $texto, string $nombre = ''): array
|
||||
{
|
||||
$texto = trim($texto);
|
||||
|
||||
// 1. Obtener o crear contacto
|
||||
$contact = ChatContact::firstOrCreate(
|
||||
['canal' => $canal, 'canal_id' => $canalId],
|
||||
['nombre' => $nombre ?: $canalId, 'telefono' => $canal === 'web' ? $canalId : null]
|
||||
);
|
||||
|
||||
if ($nombre && $contact->nombre !== $nombre) {
|
||||
$contact->update(['nombre' => $nombre]);
|
||||
}
|
||||
|
||||
// 2. Obtener conversación activa o crear una nueva
|
||||
$conv = $contact->activeConversation();
|
||||
|
||||
if (! $conv) {
|
||||
$conv = ChatConversation::create([
|
||||
'contact_id' => $contact->id,
|
||||
'canal' => $canal,
|
||||
'estado' => 'bot',
|
||||
'ultimo_mensaje_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
// 3. Guardar el mensaje del usuario
|
||||
ChatMessage::create([
|
||||
'conversation_id' => $conv->id,
|
||||
'tipo' => 'usuario',
|
||||
'contenido' => $texto,
|
||||
'leido' => false,
|
||||
]);
|
||||
|
||||
$conv->update([
|
||||
'ultimo_mensaje_at' => now(),
|
||||
'mensajes_no_leidos' => $conv->mensajes_no_leidos + 1,
|
||||
]);
|
||||
|
||||
// 4. Si la conversación está en modo agente, no responder automáticamente
|
||||
if ($conv->estado === 'agente') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 5. Procesar con el árbol de menús
|
||||
$replies = $this->processMenu($conv, $texto);
|
||||
|
||||
// 6. Guardar respuestas del bot
|
||||
foreach ($replies as $reply) {
|
||||
ChatMessage::create([
|
||||
'conversation_id' => $conv->id,
|
||||
'tipo' => 'bot',
|
||||
'contenido' => $reply,
|
||||
'leido' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
return $replies;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Lógica de menús
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
private function processMenu(ChatConversation $conv, string $texto): array
|
||||
{
|
||||
// Cargar menú actual (si existe) o el menú raíz
|
||||
$menu = $conv->menu_actual_id
|
||||
? ChatMenu::find($conv->menu_actual_id)
|
||||
: ChatMenu::raiz();
|
||||
|
||||
if (! $menu) {
|
||||
return [$this->mensajeSinMenu()];
|
||||
}
|
||||
|
||||
// Buscar opción que coincida con lo que escribió el usuario
|
||||
$opcion = $menu->options()
|
||||
->where('clave', strtolower($texto))
|
||||
->first();
|
||||
|
||||
if (! $opcion) {
|
||||
// Opción no reconocida — reenviar el menú actual
|
||||
return [$menu->mensaje . "\n\n" . $this->formatOpciones($menu)];
|
||||
}
|
||||
|
||||
return match($opcion->accion) {
|
||||
'ir_menu' => $this->irAMenu($conv, $opcion->menu_destino_id),
|
||||
'respuesta' => $this->respuestaSimple($conv, $opcion),
|
||||
'agente' => $this->transferirAgente($conv),
|
||||
default => ['Lo siento, no pude procesar tu solicitud.'],
|
||||
};
|
||||
}
|
||||
|
||||
private function irAMenu(ChatConversation $conv, ?int $menuId): array
|
||||
{
|
||||
if (! $menuId) {
|
||||
return ['Error: menú de destino no configurado.'];
|
||||
}
|
||||
|
||||
$menu = ChatMenu::find($menuId);
|
||||
if (! $menu || ! $menu->activo) {
|
||||
return ['Lo siento, esa opción no está disponible en este momento.'];
|
||||
}
|
||||
|
||||
$conv->update(['menu_actual_id' => $menu->id]);
|
||||
|
||||
return [$menu->mensaje . "\n\n" . $this->formatOpciones($menu)];
|
||||
}
|
||||
|
||||
private function respuestaSimple(ChatConversation $conv, $opcion): array
|
||||
{
|
||||
// Después de la respuesta, volver al menú raíz
|
||||
$raiz = ChatMenu::raiz();
|
||||
$conv->update(['menu_actual_id' => $raiz?->id]);
|
||||
|
||||
$replies = [$opcion->respuesta_texto];
|
||||
|
||||
if ($raiz) {
|
||||
$replies[] = $raiz->mensaje . "\n\n" . $this->formatOpciones($raiz);
|
||||
}
|
||||
|
||||
return $replies;
|
||||
}
|
||||
|
||||
private function transferirAgente(ChatConversation $conv): array
|
||||
{
|
||||
$conv->update(['estado' => 'agente', 'menu_actual_id' => null]);
|
||||
|
||||
return [
|
||||
ChatConfig::get('mensaje_transferencia',
|
||||
'Un agente se comunicará contigo en breve. Por favor espera.'
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Helpers de formato
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
private function formatOpciones(\App\Models\ChatMenu $menu): string
|
||||
{
|
||||
$lines = [];
|
||||
foreach ($menu->options as $op) {
|
||||
$lines[] = "{$op->clave}. {$op->etiqueta}";
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
private function mensajeSinMenu(): string
|
||||
{
|
||||
return ChatConfig::get(
|
||||
'mensaje_bienvenida',
|
||||
'Hola 👋 Bienvenido. Escribe *hola* para comenzar.'
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Envío hacia Telegram
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
public function enviarTelegram(string $chatId, string $texto): bool
|
||||
{
|
||||
$token = ChatConfig::get('telegram_token');
|
||||
if (! $token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$url = "https://api.telegram.org/bot{$token}/sendMessage";
|
||||
$payload = json_encode(['chat_id' => $chatId, 'text' => $texto, 'parse_mode' => 'Markdown']);
|
||||
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/json\r\n",
|
||||
'content' => $payload,
|
||||
'timeout' => 10,
|
||||
],
|
||||
]);
|
||||
|
||||
$result = @file_get_contents($url, false, $ctx);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?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
|
||||
{
|
||||
// ── Configuración global del chatbot ─────────────────────────────
|
||||
Schema::create('chat_config', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('config_key')->unique();
|
||||
$table->text('config_value')->nullable();
|
||||
$table->string('description')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
// ── Contactos (un registro por teléfono/chat_id + canal) ─────────
|
||||
Schema::create('chat_contacts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('canal', 20); // 'web' | 'telegram'
|
||||
$table->string('canal_id'); // teléfono (web) o chat_id numérico (telegram)
|
||||
$table->string('nombre')->nullable();
|
||||
$table->string('telefono')->nullable();
|
||||
$table->json('metadata')->nullable(); // datos extra del canal
|
||||
$table->timestamps();
|
||||
$table->unique(['canal', 'canal_id']);
|
||||
});
|
||||
|
||||
// ── Conversaciones ────────────────────────────────────────────────
|
||||
Schema::create('chat_conversations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('contact_id')->constrained('chat_contacts')->cascadeOnDelete();
|
||||
$table->string('canal', 20); // 'web' | 'telegram'
|
||||
$table->string('estado', 20)->default('bot'); // 'bot' | 'agente' | 'cerrada'
|
||||
$table->integer('menu_actual_id')->nullable(); // id del menú donde está el usuario
|
||||
$table->timestamp('ultimo_mensaje_at')->nullable();
|
||||
$table->unsignedInteger('mensajes_no_leidos')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
// ── Mensajes ──────────────────────────────────────────────────────
|
||||
Schema::create('chat_messages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('conversation_id')->constrained('chat_conversations')->cascadeOnDelete();
|
||||
$table->string('tipo', 20); // 'usuario' | 'bot' | 'agente'
|
||||
$table->text('contenido');
|
||||
$table->boolean('leido')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
// ── Menús del bot ─────────────────────────────────────────────────
|
||||
Schema::create('chat_menus', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('nombre');
|
||||
$table->text('mensaje'); // texto que envía el bot al llegar a este menú
|
||||
$table->boolean('es_raiz')->default(false);
|
||||
$table->boolean('activo')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
// ── Opciones de menú ──────────────────────────────────────────────
|
||||
Schema::create('chat_menu_options', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('menu_id')->constrained('chat_menus')->cascadeOnDelete();
|
||||
$table->string('clave', 20); // lo que el usuario escribe: '1', '2', 'a', etc.
|
||||
$table->string('etiqueta'); // texto descriptivo de la opción
|
||||
$table->string('accion', 30); // 'ir_menu' | 'respuesta' | 'agente'
|
||||
$table->unsignedBigInteger('menu_destino_id')->nullable(); // si accion = ir_menu
|
||||
$table->text('respuesta_texto')->nullable(); // si accion = respuesta
|
||||
$table->integer('orden')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('chat_menu_options');
|
||||
Schema::dropIfExists('chat_menus');
|
||||
Schema::dropIfExists('chat_messages');
|
||||
Schema::dropIfExists('chat_conversations');
|
||||
Schema::dropIfExists('chat_contacts');
|
||||
Schema::dropIfExists('chat_config');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<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">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="bg-[#111b21] min-h-screen flex items-center justify-center" style="font-family:'Inter',sans-serif;">
|
||||
|
||||
<livewire:chat.public-chat />
|
||||
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
<x-app-layout>
|
||||
<div class="p-4">
|
||||
<livewire:chat.show-configuracion-chat />
|
||||
</div>
|
||||
@include('layouts.footer')
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-app-layout>
|
||||
<livewire:chat.show-conversaciones />
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,6 @@
|
||||
<x-app-layout>
|
||||
<div class="p-4">
|
||||
<livewire:chat.show-menus-chat />
|
||||
</div>
|
||||
@include('layouts.footer')
|
||||
</x-app-layout>
|
||||
@@ -291,6 +291,38 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Módulo Chat (Web + Telegram) --}}
|
||||
@if (in_array(auth()->user()->rol->nombre, ['super', 'administrador']))
|
||||
<div x-data="{ openChat: false }" class="space-y-1">
|
||||
<button @click="openChat = !openChat"
|
||||
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" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.625 9.75a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z" />
|
||||
</svg>
|
||||
<span>Chat / Bot</span>
|
||||
</div>
|
||||
<svg :class="openChat ? '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="openChat" x-transition class="space-y-1">
|
||||
<a href="{{ route('chat.conversaciones') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('chat.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('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' }}">
|
||||
<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('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>
|
||||
<span>Configuración</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<livewire:show-saving-ip />
|
||||
|
||||
<!-- Logout -->
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<div class="w-full max-w-md mx-auto flex flex-col" style="height:100dvh">
|
||||
|
||||
{{-- ══ PASO 1: Formulario de inicio ══ --}}
|
||||
@if ($paso === 'telefono')
|
||||
<div class="flex flex-col items-center justify-center h-full px-6">
|
||||
<div class="w-full bg-[#202c33] rounded-2xl shadow-2xl p-8">
|
||||
|
||||
<div class="flex flex-col items-center mb-8">
|
||||
<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="M8.625 9.75a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<form wire:submit.prevent="iniciar" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-[#8696a0] text-xs font-medium mb-1 uppercase tracking-wider">Teléfono *</label>
|
||||
<input wire:model.defer="telefono" type="tel" placeholder="Ej: 3001234567"
|
||||
class="w-full bg-[#2a3942] text-white rounded-xl px-4 py-3 text-sm outline-none border border-transparent focus:border-[#00a884] transition placeholder-[#8696a0]">
|
||||
@error('telefono')
|
||||
<span class="text-red-400 text-xs mt-1 block">{{ $message }}</span>
|
||||
@enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[#8696a0] text-xs font-medium mb-1 uppercase tracking-wider">Nombre (opcional)</label>
|
||||
<input wire:model.defer="nombre" type="text" placeholder="Tu nombre"
|
||||
class="w-full bg-[#2a3942] text-white rounded-xl px-4 py-3 text-sm outline-none border border-transparent focus:border-[#00a884] transition placeholder-[#8696a0]">
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="w-full bg-[#00a884] hover:bg-[#06cf9c] text-white font-semibold py-3 rounded-xl transition text-sm shadow-lg">
|
||||
<span wire:loading.remove wire:target="iniciar">Comenzar chat →</span>
|
||||
<span wire:loading wire:target="iniciar">Conectando...</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ══ PASO 2: Interfaz de chat ══ --}}
|
||||
@else
|
||||
<div class="flex flex-col h-full bg-[#0b141a]"
|
||||
x-data
|
||||
x-on:scroll-chat.window="$nextTick(() => { let el = document.getElementById('pub-messages'); if(el) el.scrollTop = el.scrollHeight; })">
|
||||
|
||||
{{-- Header --}}
|
||||
<div class="bg-[#202c33] px-4 py-3 flex items-center gap-3 shadow-md flex-shrink-0">
|
||||
<div class="w-10 h-10 bg-[#00a884] rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.625 9.75a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-white font-semibold text-sm truncate">Soporte en línea</p>
|
||||
<p class="text-[#8696a0] text-xs">
|
||||
@if ($estadoConv === 'agente') Atendido por un agente
|
||||
@elseif ($estadoConv === 'cerrada') Conversación cerrada
|
||||
@else Bot activo
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Mensajes --}}
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4 space-y-2" id="pub-messages"
|
||||
wire:poll.3000ms="cargarMensajes">
|
||||
@forelse($mensajes as $msg)
|
||||
@if ($msg['tipo'] === 'usuario')
|
||||
<div class="flex justify-end">
|
||||
<div class="max-w-[78%] bg-[#005c4b] text-white rounded-xl rounded-tr-sm px-4 py-2 shadow text-sm">
|
||||
<p class="whitespace-pre-line break-words">{{ $msg['contenido'] }}</p>
|
||||
<span class="text-[#8696a0] text-[10px] float-right mt-1 ml-2">{{ $msg['created_at'] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex justify-start">
|
||||
<div class="max-w-[78%] bg-[#202c33] text-white rounded-xl rounded-tl-sm px-4 py-2 shadow text-sm">
|
||||
@if ($msg['tipo'] === 'agente')
|
||||
<span class="text-amber-400 text-[10px] font-semibold block mb-0.5">Agente</span>
|
||||
@endif
|
||||
<p class="whitespace-pre-line break-words">{{ $msg['contenido'] }}</p>
|
||||
<span class="text-[#8696a0] text-[10px] float-right mt-1 ml-2">{{ $msg['created_at'] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@empty
|
||||
<div class="text-center text-[#8696a0] text-sm py-10">Iniciando chat...</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{-- Input --}}
|
||||
@if ($estadoConv !== 'cerrada')
|
||||
<div class="bg-[#202c33] px-3 py-3 flex items-end gap-2 flex-shrink-0">
|
||||
<textarea
|
||||
wire:model.defer="input"
|
||||
wire:keydown.enter.prevent="enviar"
|
||||
rows="1"
|
||||
placeholder="Escribe un mensaje..."
|
||||
class="flex-1 bg-[#2a3942] text-white text-sm rounded-xl px-4 py-3 resize-none outline-none placeholder-[#8696a0] max-h-28"
|
||||
style="overflow-y:auto;"
|
||||
></textarea>
|
||||
<button wire:click="enviar"
|
||||
class="w-11 h-11 bg-[#00a884] hover:bg-[#06cf9c] rounded-full flex items-center justify-center transition flex-shrink-0">
|
||||
<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"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
<div class="bg-[#202c33] px-4 py-4 text-center text-[#8696a0] text-sm flex-shrink-0">
|
||||
Esta conversación ha sido cerrada.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,82 @@
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-800">Configuración del Chat</h2>
|
||||
<p class="text-gray-500 text-sm">Parámetros generales del chatbot y del canal Telegram.</p>
|
||||
</div>
|
||||
|
||||
{{-- Formulario --}}
|
||||
<div class="bg-white rounded-2xl shadow border border-gray-100 p-6 space-y-5">
|
||||
|
||||
{{-- Token Telegram --}}
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">Token del bot de Telegram</label>
|
||||
<p class="text-xs text-gray-400 mb-2">Obtenlo hablando con @BotFather en Telegram. Deja vacío si no usas Telegram.</p>
|
||||
<input wire:model.defer="telegram_token" type="text"
|
||||
placeholder="1234567890:ABCDefGhijKlmNoPqrStUVwxYZ"
|
||||
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>
|
||||
|
||||
{{-- Webhook URL (solo lectura) --}}
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">URL del Webhook Telegram</label>
|
||||
<p class="text-xs text-gray-400 mb-2">Esta es la URL que Telegram usará para enviarte los mensajes entrantes.</p>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" readonly value="{{ url('/chat/webhook/telegram') }}"
|
||||
class="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm bg-gray-50 text-gray-600 font-mono">
|
||||
<button wire:click="registrarWebhook"
|
||||
class="flex-shrink-0 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded-lg transition">
|
||||
<span wire:loading.remove wire:target="registrarWebhook">Registrar Webhook</span>
|
||||
<span wire:loading wire:target="registrarWebhook">Registrando...</span>
|
||||
</button>
|
||||
</div>
|
||||
@if($webhookResult)
|
||||
<p class="mt-2 text-sm {{ str_contains($webhookResult, '✅') ? 'text-emerald-600' : 'text-red-600' }}">
|
||||
{{ $webhookResult }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-100">
|
||||
|
||||
{{-- Mensaje de bienvenida --}}
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">Mensaje de bienvenida <span class="text-red-500">*</span></label>
|
||||
<p class="text-xs text-gray-400 mb-2">Primer mensaje cuando el bot no tiene menú configurado o cuando el usuario escribe "hola".</p>
|
||||
<textarea wire:model.defer="mensaje_bienvenida" rows="3"
|
||||
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"></textarea>
|
||||
@error('mensaje_bienvenida') <span class="text-red-500 text-xs">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
{{-- Mensaje de transferencia --}}
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">Mensaje al transferir a agente <span class="text-red-500">*</span></label>
|
||||
<p class="text-xs text-gray-400 mb-2">Se envía al usuario cuando elige la opción de hablar con un agente.</p>
|
||||
<textarea wire:model.defer="mensaje_transferencia" rows="3"
|
||||
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"></textarea>
|
||||
@error('mensaje_transferencia') <span class="text-red-500 text-xs">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
{{-- Guardar --}}
|
||||
<div class="flex justify-end pt-2">
|
||||
<button wire:click="guardar"
|
||||
class="flex items-center gap-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold px-6 py-2.5 rounded-lg transition shadow">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
<span wire:loading.remove wire:target="guardar">Guardar configuración</span>
|
||||
<span wire:loading wire:target="guardar">Guardando...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Info de canales --}}
|
||||
<div class="bg-blue-50 border border-blue-100 rounded-2xl p-5">
|
||||
<h3 class="font-semibold text-blue-800 text-sm mb-2">ℹ️ Canales disponibles</h3>
|
||||
<ul class="text-blue-700 text-sm space-y-1">
|
||||
<li><strong>Web:</strong> Los visitantes acceden en <code class="bg-blue-100 px-1 rounded">{{ url('/chat') }}</code></li>
|
||||
<li><strong>Telegram:</strong> Configurar el token y registrar el webhook para recibir mensajes de Telegram.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,201 @@
|
||||
<div class="flex overflow-hidden bg-[#111b21]" style="height: calc(100vh - 4.5rem)">
|
||||
|
||||
{{-- ══ Panel izquierdo: lista de conversaciones ══ --}}
|
||||
<div class="w-80 flex flex-col border-r border-[#2a3942] flex-shrink-0 bg-[#111b21]">
|
||||
|
||||
{{-- Header --}}
|
||||
<div class="bg-[#202c33] px-4 py-3 flex items-center gap-2 flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-[#00a884]" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.625 9.75a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z" />
|
||||
</svg>
|
||||
<span class="text-white font-semibold text-sm">Conversaciones</span>
|
||||
</div>
|
||||
|
||||
{{-- Búsqueda + filtros --}}
|
||||
<div class="bg-[#111b21] px-3 py-2 space-y-2 flex-shrink-0">
|
||||
<input wire:model.debounce.300ms="busqueda" type="text" placeholder="Buscar contacto..."
|
||||
class="w-full bg-[#2a3942] text-white text-sm rounded-full px-4 py-2 outline-none placeholder-[#8696a0]">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button wire:click="$set('filtroEstado','')"
|
||||
class="text-xs px-2 py-1 rounded-full transition {{ $filtroEstado === '' ? 'bg-[#00a884] text-white' : 'bg-[#2a3942] text-[#8696a0] hover:text-white' }}">
|
||||
Todas
|
||||
</button>
|
||||
<button wire:click="$set('filtroEstado','bot')"
|
||||
class="text-xs px-2 py-1 rounded-full transition {{ $filtroEstado === 'bot' ? 'bg-[#00a884] text-white' : 'bg-[#2a3942] text-[#8696a0] hover:text-white' }}">
|
||||
Bot
|
||||
</button>
|
||||
<button wire:click="$set('filtroEstado','agente')"
|
||||
class="text-xs px-2 py-1 rounded-full transition {{ $filtroEstado === 'agente' ? 'bg-amber-600 text-white' : 'bg-[#2a3942] text-[#8696a0] hover:text-white' }}">
|
||||
Agente
|
||||
</button>
|
||||
<button wire:click="$set('filtroEstado','cerrada')"
|
||||
class="text-xs px-2 py-1 rounded-full transition {{ $filtroEstado === 'cerrada' ? 'bg-gray-600 text-white' : 'bg-[#2a3942] text-[#8696a0] hover:text-white' }}">
|
||||
Cerradas
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Lista conversaciones --}}
|
||||
<div class="flex-1 overflow-y-auto" wire:poll.5000ms>
|
||||
@forelse($conversaciones as $conv)
|
||||
<button wire:click="selectConv({{ $conv->id }})"
|
||||
class="w-full flex items-start gap-3 px-4 py-3 border-b border-[#2a3942] text-left transition
|
||||
{{ $convSelecId === $conv->id ? 'bg-[#2a3942]' : 'hover:bg-[#182229]' }}">
|
||||
|
||||
{{-- Avatar canal --}}
|
||||
<div class="w-11 h-11 rounded-full flex items-center justify-center flex-shrink-0
|
||||
{{ $conv->canal === 'telegram' ? 'bg-blue-600' : 'bg-[#00a884]' }}">
|
||||
<span class="text-white text-sm font-semibold">
|
||||
{{ strtoupper(substr($conv->contact?->nombre ?? '?', 0, 1)) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{-- Info --}}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between gap-1">
|
||||
<span class="text-white text-sm font-medium truncate">{{ $conv->contact?->nombre ?? 'Sin nombre' }}</span>
|
||||
@if($conv->mensajes_no_leidos > 0)
|
||||
<span class="bg-[#00a884] text-white text-[10px] rounded-full px-1.5 py-0.5 flex-shrink-0 font-semibold">
|
||||
{{ $conv->mensajes_no_leidos }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 mt-0.5">
|
||||
{{-- badge canal --}}
|
||||
<span class="text-[9px] px-1.5 py-0.5 rounded-full flex-shrink-0 font-semibold
|
||||
{{ $conv->canal === 'telegram' ? 'bg-blue-900 text-blue-300' : 'bg-emerald-900 text-emerald-300' }}">
|
||||
{{ $conv->canal === 'telegram' ? 'TG' : 'WEB' }}
|
||||
</span>
|
||||
{{-- badge estado --}}
|
||||
<span class="text-[9px] px-1.5 py-0.5 rounded-full flex-shrink-0
|
||||
{{ $conv->estado === 'bot' ? 'bg-gray-700 text-gray-300' : ($conv->estado === 'agente' ? 'bg-amber-700 text-amber-200' : 'bg-gray-800 text-gray-500') }}">
|
||||
{{ $conv->estado }}
|
||||
</span>
|
||||
<span class="text-[#8696a0] text-[10px] truncate flex-1">
|
||||
{{ $conv->contact?->telefono ?? $conv->contact?->canal_id }}
|
||||
</span>
|
||||
</div>
|
||||
@if($conv->ultimo_mensaje_at)
|
||||
<p class="text-[#8696a0] text-[10px] mt-0.5">{{ $conv->ultimo_mensaje_at->diffForHumans() }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</button>
|
||||
@empty
|
||||
<div class="p-6 text-center text-[#8696a0] text-sm">Sin conversaciones</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ══ Panel derecho: mensajes ══ --}}
|
||||
@if($convSelec)
|
||||
<div class="flex-1 flex flex-col min-w-0 bg-[#0b141a]"
|
||||
x-data
|
||||
x-on:scroll-admin.window="$nextTick(() => { let el = document.getElementById('admin-msgs'); if(el) el.scrollTop = el.scrollHeight; })">
|
||||
|
||||
{{-- Header conv seleccionada --}}
|
||||
<div class="bg-[#202c33] px-4 py-3 flex items-center gap-3 flex-shrink-0 border-b border-[#2a3942]">
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0
|
||||
{{ $convSelec->canal === 'telegram' ? 'bg-blue-600' : 'bg-[#00a884]' }}">
|
||||
<span class="text-white text-sm font-semibold">
|
||||
{{ strtoupper(substr($convSelec->contact?->nombre ?? '?', 0, 1)) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-white text-sm font-semibold truncate">{{ $convSelec->contact?->nombre ?? 'Sin nombre' }}</p>
|
||||
<p class="text-[#8696a0] text-xs">
|
||||
{{ $convSelec->canal === 'telegram' ? 'Telegram' : 'Web' }} ·
|
||||
{{ $convSelec->contact?->telefono ?? $convSelec->contact?->canal_id }}
|
||||
</p>
|
||||
</div>
|
||||
{{-- Acciones --}}
|
||||
<div class="flex items-center gap-2">
|
||||
@if($convSelec->estado === 'bot')
|
||||
<button wire:click="tomarControl"
|
||||
class="text-xs px-3 py-1.5 bg-amber-700 hover:bg-amber-600 text-white rounded-lg transition">
|
||||
Tomar control
|
||||
</button>
|
||||
@elseif($convSelec->estado === 'agente')
|
||||
<button wire:click="devolverAlBot"
|
||||
class="text-xs px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition">
|
||||
↩ Al bot
|
||||
</button>
|
||||
@endif
|
||||
@if($convSelec->estado !== 'cerrada')
|
||||
<button wire:click="cerrarConv" onclick="return confirm('¿Cerrar esta conversación?')"
|
||||
class="text-xs px-3 py-1.5 bg-red-800 hover:bg-red-700 text-white rounded-lg transition">
|
||||
Cerrar
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Mensajes --}}
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4 space-y-2" id="admin-msgs">
|
||||
@forelse($mensajes as $msg)
|
||||
@if ($msg['tipo'] === 'usuario')
|
||||
<div class="flex justify-start">
|
||||
<div class="max-w-[70%] bg-[#202c33] text-white rounded-xl rounded-tl-sm px-4 py-2 shadow text-sm">
|
||||
<p class="whitespace-pre-line break-words">{{ $msg['contenido'] }}</p>
|
||||
<span class="text-[#8696a0] text-[10px] float-right mt-1 ml-2">{{ $msg['created_at'] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($msg['tipo'] === 'bot')
|
||||
<div class="flex justify-end">
|
||||
<div class="max-w-[70%] bg-[#1a3a2a] text-white rounded-xl rounded-tr-sm px-4 py-2 shadow text-sm border border-[#00a884]/20">
|
||||
<span class="text-[#00a884] text-[10px] font-semibold block mb-0.5">Bot</span>
|
||||
<p class="whitespace-pre-line break-words">{{ $msg['contenido'] }}</p>
|
||||
<span class="text-[#8696a0] text-[10px] float-right mt-1 ml-2">{{ $msg['created_at'] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex justify-end">
|
||||
<div class="max-w-[70%] bg-[#005c4b] text-white rounded-xl rounded-tr-sm px-4 py-2 shadow text-sm">
|
||||
<span class="text-amber-300 text-[10px] font-semibold block mb-0.5">Agente</span>
|
||||
<p class="whitespace-pre-line break-words">{{ $msg['contenido'] }}</p>
|
||||
<span class="text-[#8696a0] text-[10px] float-right mt-1 ml-2">{{ $msg['created_at'] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@empty
|
||||
<div class="text-center text-[#8696a0] text-sm py-10">Sin mensajes</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{-- Input respuesta (solo en estado agente) --}}
|
||||
@if($convSelec->estado === 'agente')
|
||||
<div class="bg-[#202c33] px-3 py-3 flex items-end gap-2 flex-shrink-0">
|
||||
<textarea wire:model.defer="replyText" rows="2"
|
||||
placeholder="Escribe tu respuesta como agente..."
|
||||
class="flex-1 bg-[#2a3942] text-white text-sm rounded-xl px-4 py-3 resize-none outline-none placeholder-[#8696a0] max-h-28"></textarea>
|
||||
<button wire:click="sendReply"
|
||||
class="w-11 h-11 bg-[#00a884] hover:bg-[#06cf9c] rounded-full flex items-center justify-center transition flex-shrink-0">
|
||||
<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"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@elseif($convSelec->estado === 'bot')
|
||||
<div class="bg-[#202c33] px-4 py-3 text-center text-[#8696a0] text-xs flex-shrink-0 border-t border-[#2a3942]">
|
||||
El bot está respondiendo. Usa <strong class="text-white">Tomar control</strong> para responder manualmente.
|
||||
</div>
|
||||
@else
|
||||
<div class="bg-[#202c33] px-4 py-3 text-center text-[#8696a0] text-xs flex-shrink-0 border-t border-[#2a3942]">
|
||||
Conversación cerrada.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
@else
|
||||
<div class="flex-1 flex items-center justify-center bg-[#0b141a]">
|
||||
<div class="text-center">
|
||||
<div class="w-20 h-20 bg-[#202c33] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-10 h-10 text-[#8696a0]" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.625 9.75a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-[#8696a0] text-sm">Selecciona una conversación para ver los mensajes</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,231 @@
|
||||
<div class="max-w-5xl mx-auto space-y-6">
|
||||
|
||||
{{-- Cabecera --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-800">Menús del Bot</h2>
|
||||
<p class="text-gray-500 text-sm">Configura el árbol de menús que usa el chatbot.</p>
|
||||
</div>
|
||||
<button wire:click="$set('showMenuForm', true)"
|
||||
class="flex items-center gap-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold px-4 py-2 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="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Nuevo Menú
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Modal Menú --}}
|
||||
@if($showMenuForm)
|
||||
<div class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-lg">
|
||||
<div class="flex items-center justify-between p-6 border-b">
|
||||
<h3 class="font-bold text-gray-800">{{ $editMenuId ? 'Editar Menú' : 'Nuevo Menú' }}</h3>
|
||||
<button wire:click="closeMenuForm" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Nombre del menú <span class="text-red-500">*</span></label>
|
||||
<input wire:model.defer="menuNombre" type="text" placeholder="Ej: Menú principal"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 focus:border-transparent outline-none">
|
||||
@error('menuNombre') <span class="text-red-500 text-xs">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Mensaje que muestra <span class="text-red-500">*</span></label>
|
||||
<textarea wire:model.defer="menuMensaje" rows="4" placeholder="Hola 👋 ¿En qué podemos ayudarte? Escribe el número de tu opción:"
|
||||
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"></textarea>
|
||||
@error('menuMensaje') <span class="text-red-500 text-xs">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
<div class="flex items-center gap-6">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input wire:model="menuEsRaiz" type="checkbox" class="rounded text-emerald-600">
|
||||
<span class="text-sm text-gray-700">Es el menú raíz (punto de inicio)</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input wire:model="menuActivo" type="checkbox" class="rounded text-emerald-600">
|
||||
<span class="text-sm text-gray-700">Activo</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 px-6 pb-6">
|
||||
<button wire:click="closeMenuForm"
|
||||
class="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cancelar</button>
|
||||
<button wire:click="saveMenu"
|
||||
class="px-4 py-2 text-sm bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg font-semibold transition">Guardar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Modal Opción --}}
|
||||
@if($showOpcionForm)
|
||||
<div class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-lg">
|
||||
<div class="flex items-center justify-between p-6 border-b">
|
||||
<h3 class="font-bold text-gray-800">{{ $editOpcionId ? 'Editar Opción' : 'Nueva Opción' }}</h3>
|
||||
<button wire:click="closeOpcionForm" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Clave (lo que escribe) <span class="text-red-500">*</span></label>
|
||||
<input wire:model.defer="opcionClave" type="text" placeholder="Ej: 1, hola, info"
|
||||
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('opcionClave') <span class="text-red-500 text-xs">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Orden</label>
|
||||
<input wire:model.defer="opcionOrden" type="number" min="0"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 outline-none">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Etiqueta (texto visible) <span class="text-red-500">*</span></label>
|
||||
<input wire:model.defer="opcionEtiqueta" type="text" placeholder="Ej: Consulta de saldo"
|
||||
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('opcionEtiqueta') <span class="text-red-500 text-xs">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Acción <span class="text-red-500">*</span></label>
|
||||
<select wire:model="opcionAccion"
|
||||
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="respuesta">Respuesta de texto</option>
|
||||
<option value="ir_menu">Ir a otro menú</option>
|
||||
<option value="agente">Transferir a agente</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if($opcionAccion === 'ir_menu')
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Menú destino <span class="text-red-500">*</span></label>
|
||||
<select wire:model.defer="opcionMenuDestId"
|
||||
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="">-- Selecciona --</option>
|
||||
@foreach($todosMenus as $m)
|
||||
<option value="{{ $m->id }}">{{ $m->nombre }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('opcionMenuDestId') <span class="text-red-500 text-xs">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($opcionAccion === 'respuesta')
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Texto de respuesta <span class="text-red-500">*</span></label>
|
||||
<textarea wire:model.defer="opcionRespuesta" rows="4"
|
||||
placeholder="Escribe la respuesta que enviará el bot..."
|
||||
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"></textarea>
|
||||
@error('opcionRespuesta') <span class="text-red-500 text-xs">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($opcionAccion === 'agente')
|
||||
<p class="text-sm text-amber-600 bg-amber-50 rounded-lg px-3 py-2">
|
||||
⚠️ Al elegir esta opción, la conversación se transferirá a un agente humano.
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 px-6 pb-6">
|
||||
<button wire:click="closeOpcionForm"
|
||||
class="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cancelar</button>
|
||||
<button wire:click="saveOpcion"
|
||||
class="px-4 py-2 text-sm bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg font-semibold transition">Guardar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Lista de menús --}}
|
||||
@forelse($menus as $menu)
|
||||
<div class="bg-white rounded-2xl shadow border border-gray-100 overflow-hidden">
|
||||
{{-- Header del menú --}}
|
||||
<div class="flex items-start justify-between px-5 py-4 border-b border-gray-100 bg-gray-50">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex flex-col gap-1 mt-0.5">
|
||||
@if($menu->es_raiz)
|
||||
<span class="text-[10px] bg-emerald-100 text-emerald-700 font-bold px-2 py-0.5 rounded-full uppercase">RAÍZ</span>
|
||||
@endif
|
||||
@if(!$menu->activo)
|
||||
<span class="text-[10px] bg-red-100 text-red-600 font-bold px-2 py-0.5 rounded-full uppercase">INACTIVO</span>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-gray-800">{{ $menu->nombre }}</h3>
|
||||
<p class="text-gray-500 text-sm mt-1 whitespace-pre-line">{{ $menu->mensaje }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0 ml-4">
|
||||
<button wire:click="openOpcionForm({{ $menu->id }})"
|
||||
class="text-xs px-3 py-1.5 bg-emerald-50 hover:bg-emerald-100 text-emerald-700 rounded-lg border border-emerald-200 transition font-medium">
|
||||
+ Opción
|
||||
</button>
|
||||
<button wire:click="editMenu({{ $menu->id }})"
|
||||
class="text-xs px-3 py-1.5 bg-blue-50 hover:bg-blue-100 text-blue-700 rounded-lg border border-blue-200 transition font-medium">
|
||||
Editar
|
||||
</button>
|
||||
<button wire:click="deleteMenu({{ $menu->id }})" onclick="return confirm('¿Eliminar este menú y todas sus opciones?')"
|
||||
class="text-xs px-3 py-1.5 bg-red-50 hover:bg-red-100 text-red-600 rounded-lg border border-red-200 transition font-medium">
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Opciones del menú --}}
|
||||
@if($menu->options->count() > 0)
|
||||
<div class="divide-y divide-gray-100">
|
||||
@foreach($menu->options as $op)
|
||||
<div class="flex items-center justify-between px-5 py-3 hover:bg-gray-50 transition">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-8 h-8 bg-gray-100 rounded-lg flex items-center justify-center text-sm font-bold text-gray-700">
|
||||
{{ $op->clave }}
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700">{{ $op->etiqueta }}</p>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
@if($op->accion === 'ir_menu')
|
||||
<span class="text-[10px] bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full font-semibold">→ Menú</span>
|
||||
@if($op->menuDestino)
|
||||
<span class="text-[10px] text-gray-500">{{ $op->menuDestino->nombre }}</span>
|
||||
@endif
|
||||
@elseif($op->accion === 'respuesta')
|
||||
<span class="text-[10px] bg-green-100 text-green-700 px-2 py-0.5 rounded-full font-semibold">Respuesta</span>
|
||||
<span class="text-[10px] text-gray-500 truncate max-w-xs">{{ $op->respuesta_texto }}</span>
|
||||
@else
|
||||
<span class="text-[10px] bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full font-semibold">→ Agente</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button wire:click="editOpcion({{ $op->id }})"
|
||||
class="text-xs text-blue-600 hover:text-blue-800 font-medium transition">Editar</button>
|
||||
<button wire:click="deleteOpcion({{ $op->id }})" onclick="return confirm('¿Eliminar esta opción?')"
|
||||
class="text-xs text-red-500 hover:text-red-700 font-medium transition">Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="px-5 py-4 text-sm text-gray-400">
|
||||
Sin opciones. <button wire:click="openOpcionForm({{ $menu->id }})" class="text-emerald-600 hover:underline">Agregar primera opción →</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<div class="bg-white rounded-2xl shadow border border-gray-100 p-12 text-center">
|
||||
<svg class="w-12 h-12 text-gray-300 mx-auto mb-3" 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>
|
||||
<p class="text-gray-500 mb-4">No hay menús configurados todavía.</p>
|
||||
<button wire:click="$set('showMenuForm', true)"
|
||||
class="bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold px-4 py-2 rounded-lg transition">
|
||||
Crear primer menú
|
||||
</button>
|
||||
</div>
|
||||
@endforelse
|
||||
|
||||
</div>
|
||||
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\ChatController;
|
||||
use App\Http\Controllers\MenuController;
|
||||
use App\Http\Controllers\TelegramWebhookController;
|
||||
use App\Http\Controllers\WhatsappController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@@ -83,10 +85,23 @@ Route::middleware(["auth", "solo_usuario_administrador"])->group(function () {
|
||||
Route::get('/logs', [WhatsappController::class, 'logs'])->name('logs');
|
||||
Route::get('/correo', [WhatsappController::class, 'correo'])->name('correo');
|
||||
});
|
||||
|
||||
// Módulo Chat (web + Telegram)
|
||||
Route::prefix('chat')->name('chat.')->group(function () {
|
||||
Route::get('/conversaciones', [ChatController::class, 'conversaciones'])->name('conversaciones');
|
||||
Route::get('/menus', [ChatController::class, 'menus'])->name('menus');
|
||||
Route::get('/configuracion', [ChatController::class, 'configuracion'])->name('configuracion');
|
||||
});
|
||||
});
|
||||
|
||||
Route::post('/registrar-accion-compra', [MenuController::class, 'click_compra'])->name('click_compra');
|
||||
|
||||
// Rutas públicas del módulo Chat (sin autenticación)
|
||||
Route::get('/chat', [ChatController::class, 'publicPage'])->name('chat.publico');
|
||||
Route::post('/chat/webhook/telegram', [TelegramWebhookController::class, 'handle'])
|
||||
->name('chat.telegram.webhook')
|
||||
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
|
||||
|
||||
// Route::get('/ip-test', [MenuController::class, 'ip_test'])->name('ip');
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user