chatbot nuevo

This commit is contained in:
Lizandro Guarnizo
2026-04-30 21:32:32 -05:00
parent 88a42ec629
commit f61e8005fc
24 changed files with 1724 additions and 0 deletions
+26
View File
@@ -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]);
}
}
+100
View File
@@ -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'));
}
}
+168
View File
@@ -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'));
}
}
+24
View File
@@ -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]
);
}
}
+26
View File
@@ -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();
}
}
+45
View File
@@ -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',
};
}
}
+23
View File
@@ -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();
}
}
+22
View File
@@ -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');
}
}
+17
View File
@@ -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');
}
}
+217
View File
@@ -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');
}
};
+19
View File
@@ -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>
+6
View File
@@ -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 &rarr;</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>
+15
View File
@@ -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');