This commit is contained in:
Lizandro Guarnizo
2026-04-24 21:40:10 -05:00
parent 877a0d393a
commit 148fa6b9f9
36 changed files with 3037 additions and 0 deletions
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class WhatsappController extends Controller
{
public function conversaciones()
{
return view('whatsapp.conversaciones');
}
public function menus()
{
return view('whatsapp.menus');
}
public function plantillas()
{
return view('whatsapp.plantillas');
}
public function programados()
{
return view('whatsapp.programados');
}
public function configuracion()
{
return view('whatsapp.configuracion');
}
public function logs()
{
return view('whatsapp.logs');
}
}
@@ -0,0 +1,71 @@
<?php
namespace App\Http\Livewire\Whatsapp;
use App\Models\WhatsappSystemConfig;
use Livewire\Component;
use Jantinnerezo\LivewireAlert\LivewireAlert;
class ShowConfiguracionBot extends Component
{
use LivewireAlert;
public string $whatsapp_token = '';
public string $phone_number_id = '';
public string $whatsapp_api_url = '';
public string $webhook_verify_token = '';
public string $bot_enabled = '1';
public string $welcome_message = '';
public string $default_no_match = '';
public string $advisor_message = '';
public string $business_hours_enabled = '0';
public string $business_hours_start = '';
public string $business_hours_end = '';
protected $rules = [
'whatsapp_token' => 'required|string',
'phone_number_id' => 'required|string',
'whatsapp_api_url' => 'required|url',
'webhook_verify_token' => 'required|string',
'welcome_message' => 'required|string|max:1000',
'default_no_match' => 'required|string|max:500',
'advisor_message' => 'required|string|max:500',
];
public function mount(): void
{
$keys = [
'whatsapp_token', 'phone_number_id', 'whatsapp_api_url',
'webhook_verify_token', 'bot_enabled', 'welcome_message',
'default_no_match', 'advisor_message', 'business_hours_enabled',
'business_hours_start', 'business_hours_end',
];
foreach ($keys as $key) {
$this->{$key} = WhatsappSystemConfig::get($key);
}
}
public function save(): void
{
$this->validate();
$keys = [
'whatsapp_token', 'phone_number_id', 'whatsapp_api_url',
'webhook_verify_token', 'bot_enabled', 'welcome_message',
'default_no_match', 'advisor_message', 'business_hours_enabled',
'business_hours_start', 'business_hours_end',
];
foreach ($keys as $key) {
WhatsappSystemConfig::set($key, $this->{$key});
}
$this->alert('success', 'Configuración guardada correctamente.');
}
public function render()
{
return view('livewire.whatsapp.show-configuracion-bot');
}
}
@@ -0,0 +1,112 @@
<?php
namespace App\Http\Livewire\Whatsapp;
use App\Models\WhatsappConversation;
use App\Models\WhatsappUser;
use Livewire\Component;
use Livewire\WithPagination;
use Illuminate\Support\Facades\Http;
use App\Models\WhatsappSystemConfig;
class ShowConversaciones extends Component
{
use WithPagination;
public ?int $selectedUserId = null;
public string $search = '';
public string $replyText = '';
public ?WhatsappUser $selectedUser = null;
protected $listeners = ['refreshChatList' => '$refresh'];
public function selectUser(int $userId): void
{
$this->selectedUserId = $userId;
$this->selectedUser = WhatsappUser::find($userId);
$this->replyText = '';
// Marcar mensajes como leídos
WhatsappConversation::where('user_id', $userId)
->where('direction', 'incoming')
->where('is_read', 0)
->update(['is_read' => 1]);
}
public function sendReply(): void
{
$this->validate(['replyText' => 'required|string|max:4096']);
if (! $this->selectedUser) {
return;
}
$token = WhatsappSystemConfig::get('whatsapp_token');
$phoneNumberId = WhatsappSystemConfig::get('phone_number_id');
$apiUrl = WhatsappSystemConfig::get('whatsapp_api_url', 'https://graph.facebook.com/v22.0/');
$response = Http::withToken($token)
->post("{$apiUrl}{$phoneNumberId}/messages", [
'messaging_product' => 'whatsapp',
'to' => $this->selectedUser->phone_number,
'type' => 'text',
'text' => ['body' => $this->replyText],
]);
if ($response->successful()) {
WhatsappConversation::create([
'user_id' => $this->selectedUserId,
'message_id' => $response->json('messages.0.id'),
'direction' => 'outgoing',
'message_type' => 'text',
'content' => $this->replyText,
'status' => 'sent',
]);
$this->replyText = '';
$this->dispatch('scrollToBottom');
} else {
session()->flash('error', 'Error al enviar el mensaje: ' . $response->body());
}
}
public function toggleBlockUser(): void
{
if (! $this->selectedUser) {
return;
}
$newStatus = $this->selectedUser->status === 'blocked' ? 'active' : 'blocked';
$this->selectedUser->update(['status' => $newStatus]);
$this->selectedUser->refresh();
}
public function render()
{
$users = WhatsappUser::when($this->search, function ($q) {
$q->where(function ($q2) {
$q2->where('name', 'like', '%' . $this->search . '%')
->orWhere('phone_number', 'like', '%' . $this->search . '%');
});
})
->withCount(['conversations as unread_count' => function ($q) {
$q->where('direction', 'incoming')->where('is_read', 0);
}])
->with('lastMessage')
->orderByDesc(
WhatsappConversation::select('created_at')
->whereColumn('user_id', 'whatsapp_users.id')
->orderByDesc('created_at')
->limit(1)
)
->paginate(20);
$messages = $this->selectedUserId
? WhatsappConversation::where('user_id', $this->selectedUserId)
->orderBy('created_at')
->get()
: collect();
return view('livewire.whatsapp.show-conversaciones', compact('users', 'messages'));
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Livewire\Whatsapp;
use App\Models\WhatsappWebhookLog;
use Livewire\Component;
use Livewire\WithPagination;
class ShowLogsWebhook extends Component
{
use WithPagination;
public string $search = '';
public ?int $viewId = null;
public ?string $viewPayload = null;
public function viewLog(int $id): void
{
$log = WhatsappWebhookLog::findOrFail($id);
$this->viewId = $id;
$decoded = json_decode($log->payload, true);
$this->viewPayload = json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
}
public function clearView(): void
{
$this->viewId = null;
$this->viewPayload = null;
}
public function deleteLog(int $id): void
{
WhatsappWebhookLog::findOrFail($id)->delete();
}
public function clearAll(): void
{
WhatsappWebhookLog::truncate();
}
public function render()
{
$logs = WhatsappWebhookLog::when($this->search, fn($q) =>
$q->where('payload', 'like', "%{$this->search}%"))
->orderByDesc('created_at')
->paginate(25);
return view('livewire.whatsapp.show-logs-webhook', compact('logs'));
}
}
+194
View File
@@ -0,0 +1,194 @@
<?php
namespace App\Http\Livewire\Whatsapp;
use App\Models\WhatsappMenu;
use App\Models\WhatsappMenuOption;
use Livewire\Component;
use Jantinnerezo\LivewireAlert\LivewireAlert;
class ShowMenusBot extends Component
{
use LivewireAlert;
// Lista
public string $search = '';
// Form menú
public bool $showMenuForm = false;
public ?int $editMenuId = null;
public string $menuName = '';
public string $menuTitle = '';
public string $menuMessage = '';
public ?int $menuParentId = null;
public bool $menuIsMain = false;
public bool $menuIsActive = true;
public int $menuSortOrder = 0;
// Form opción
public bool $showOptionForm = false;
public ?int $editOptionId = null;
public ?int $optionForMenuId = null;
public int $optionNumber = 1;
public string $optionTitle = '';
public string $optionAction = 'message';
public string $optionValue = '';
public int $optionSort = 0;
public bool $optionIsActive = true;
// Vista detalle
public ?int $viewMenuId = null;
protected function rules(): array
{
return [
'menuName' => 'required|string|max:255',
'menuTitle' => 'required|string|max:255',
'menuMessage' => 'required|string',
];
}
public function openMenuForm(?int $id = null): void
{
$this->resetMenuForm();
if ($id) {
$menu = WhatsappMenu::findOrFail($id);
$this->editMenuId = $id;
$this->menuName = $menu->name;
$this->menuTitle = $menu->title;
$this->menuMessage = $menu->message;
$this->menuParentId = $menu->parent_id;
$this->menuIsMain = (bool) $menu->is_main;
$this->menuIsActive = (bool) $menu->is_active;
$this->menuSortOrder = $menu->sort_order;
}
$this->showMenuForm = true;
}
public function saveMenu(): void
{
$this->validate();
$data = [
'name' => $this->menuName,
'title' => $this->menuTitle,
'message' => $this->menuMessage,
'parent_id' => $this->menuParentId ?: null,
'is_main' => $this->menuIsMain,
'is_active' => $this->menuIsActive,
'sort_order' => $this->menuSortOrder,
];
if ($this->editMenuId) {
WhatsappMenu::findOrFail($this->editMenuId)->update($data);
$this->alert('success', 'Menú actualizado correctamente.');
} else {
WhatsappMenu::create($data);
$this->alert('success', 'Menú creado correctamente.');
}
$this->resetMenuForm();
}
public function deleteMenu(int $id): void
{
WhatsappMenu::findOrFail($id)->delete();
$this->alert('success', 'Menú eliminado.');
if ($this->viewMenuId === $id) {
$this->viewMenuId = null;
}
}
public function openOptionForm(?int $optionId = null, ?int $menuId = null): void
{
$this->resetOptionForm();
$this->optionForMenuId = $menuId;
if ($optionId) {
$opt = WhatsappMenuOption::findOrFail($optionId);
$this->editOptionId = $optionId;
$this->optionForMenuId = $opt->menu_id;
$this->optionNumber = $opt->option_number;
$this->optionTitle = $opt->title;
$this->optionAction = $opt->action_type;
$this->optionValue = $opt->action_value ?? '';
$this->optionSort = $opt->sort_order;
$this->optionIsActive = (bool) $opt->is_active;
}
$this->showOptionForm = true;
}
public function saveOption(): void
{
$this->validate([
'optionTitle' => 'required|string|max:255',
'optionForMenuId' => 'required|integer',
'optionNumber' => 'required|integer|min:1',
]);
$data = [
'menu_id' => $this->optionForMenuId,
'option_number' => $this->optionNumber,
'title' => $this->optionTitle,
'action_type' => $this->optionAction,
'action_value' => $this->optionValue,
'sort_order' => $this->optionSort,
'is_active' => $this->optionIsActive,
];
if ($this->editOptionId) {
WhatsappMenuOption::findOrFail($this->editOptionId)->update($data);
$this->alert('success', 'Opción actualizada.');
} else {
WhatsappMenuOption::create($data);
$this->alert('success', 'Opción creada.');
}
$this->resetOptionForm();
}
public function deleteOption(int $id): void
{
WhatsappMenuOption::findOrFail($id)->delete();
$this->alert('success', 'Opción eliminada.');
}
public function resetMenuForm(): void
{
$this->showMenuForm = false;
$this->editMenuId = null;
$this->menuName = '';
$this->menuTitle = '';
$this->menuMessage = '';
$this->menuParentId = null;
$this->menuIsMain = false;
$this->menuIsActive = true;
$this->menuSortOrder = 0;
}
public function resetOptionForm(): void
{
$this->showOptionForm = false;
$this->editOptionId = null;
$this->optionForMenuId = null;
$this->optionNumber = 1;
$this->optionTitle = '';
$this->optionAction = 'message';
$this->optionValue = '';
$this->optionSort = 0;
$this->optionIsActive = true;
}
public function render()
{
$menus = WhatsappMenu::when($this->search, fn($q) => $q->where('name', 'like', "%{$this->search}%")
->orWhere('title', 'like', "%{$this->search}%"))
->withCount('options')
->orderBy('sort_order')
->get();
$viewMenu = $this->viewMenuId ? WhatsappMenu::with('options')->find($this->viewMenuId) : null;
$allMenus = WhatsappMenu::orderBy('title')->get();
return view('livewire.whatsapp.show-menus-bot', compact('menus', 'viewMenu', 'allMenus'));
}
}
@@ -0,0 +1,113 @@
<?php
namespace App\Http\Livewire\Whatsapp;
use App\Models\WhatsappMessageTemplate;
use Livewire\Component;
use Jantinnerezo\LivewireAlert\LivewireAlert;
class ShowPlantillas extends Component
{
use LivewireAlert;
public string $search = '';
public string $filterStatus = '';
// Formulario
public bool $showForm = false;
public ?int $editId = null;
public string $name = '';
public string $templateName = '';
public string $languageCode = 'es';
public string $status = 'pending';
public string $bodyText = '';
public string $headerType = 'none';
public string $headerText = '';
public string $footerText = '';
protected function rules(): array
{
return [
'name' => 'required|string|max:255',
'templateName' => 'required|string|max:255|regex:/^[a-z0-9_]+$/',
'languageCode' => 'required|string|max:10',
'bodyText' => 'required|string',
];
}
public function openForm(?int $id = null): void
{
$this->resetForm();
if ($id) {
$tpl = WhatsappMessageTemplate::findOrFail($id);
$this->editId = $id;
$this->name = $tpl->name;
$this->templateName = $tpl->template_name;
$this->languageCode = $tpl->language_code;
$this->status = $tpl->status;
$this->bodyText = $tpl->body_text ?? '';
$this->headerType = $tpl->header_type;
$this->headerText = $tpl->header_text ?? '';
$this->footerText = $tpl->footer_text ?? '';
}
$this->showForm = true;
}
public function save(): void
{
$this->validate();
$data = [
'name' => $this->name,
'template_name' => $this->templateName,
'language_code' => $this->languageCode,
'status' => $this->status,
'body_text' => $this->bodyText,
'header_type' => $this->headerType,
'header_text' => $this->headerText,
'footer_text' => $this->footerText,
];
if ($this->editId) {
WhatsappMessageTemplate::findOrFail($this->editId)->update($data);
$this->alert('success', 'Plantilla actualizada.');
} else {
WhatsappMessageTemplate::create($data);
$this->alert('success', 'Plantilla creada.');
}
$this->resetForm();
}
public function delete(int $id): void
{
WhatsappMessageTemplate::findOrFail($id)->delete();
$this->alert('success', 'Plantilla eliminada.');
}
public function resetForm(): void
{
$this->showForm = false;
$this->editId = null;
$this->name = '';
$this->templateName = '';
$this->languageCode = 'es';
$this->status = 'pending';
$this->bodyText = '';
$this->headerType = 'none';
$this->headerText = '';
$this->footerText = '';
}
public function render()
{
$templates = WhatsappMessageTemplate::when($this->search, fn($q) =>
$q->where('name', 'like', "%{$this->search}%")
->orWhere('template_name', 'like', "%{$this->search}%"))
->when($this->filterStatus, fn($q) => $q->where('status', $this->filterStatus))
->orderByDesc('updated_at')
->paginate(15);
return view('livewire.whatsapp.show-plantillas', compact('templates'));
}
}
@@ -0,0 +1,100 @@
<?php
namespace App\Http\Livewire\Whatsapp;
use App\Models\WhatsappScheduledMessage;
use App\Models\WhatsappMessageTemplate;
use App\Models\WhatsappUser;
use Livewire\Component;
use Jantinnerezo\LivewireAlert\LivewireAlert;
use Illuminate\Support\Facades\Auth;
class ShowProgramados extends Component
{
use LivewireAlert;
public string $search = '';
public string $filterStatus = '';
// Formulario
public bool $showForm = false;
public string $phoneNumber = '';
public string $messageType = 'text';
public string $messageContent = '';
public ?int $templateId = null;
public string $scheduledDate = '';
public string $scheduledTime = '';
protected function rules(): array
{
return [
'phoneNumber' => 'required|string|max:20',
'scheduledDate' => 'required|date',
'scheduledTime' => 'required',
'messageType' => 'required|in:text,template',
'messageContent'=> 'required_if:messageType,text|nullable|string',
'templateId' => 'required_if:messageType,template|nullable|integer',
];
}
public function save(): void
{
$this->validate();
$template = $this->templateId ? WhatsappMessageTemplate::find($this->templateId) : null;
WhatsappScheduledMessage::create([
'phone_number' => preg_replace('/\D/', '', $this->phoneNumber),
'message_type' => $this->messageType,
'message_content' => $this->messageContent,
'template_id' => $this->templateId,
'template_name' => $template?->template_name,
'template_language' => $template?->language_code,
'scheduled_date' => $this->scheduledDate,
'scheduled_time' => $this->scheduledTime,
'status' => 'pending',
'created_by' => Auth::id(),
]);
$this->alert('success', 'Mensaje programado correctamente.');
$this->resetForm();
}
public function cancel(int $id): void
{
WhatsappScheduledMessage::findOrFail($id)->update(['status' => 'cancelled']);
$this->alert('info', 'Mensaje cancelado.');
}
public function delete(int $id): void
{
WhatsappScheduledMessage::findOrFail($id)->delete();
$this->alert('success', 'Registro eliminado.');
}
public function resetForm(): void
{
$this->showForm = false;
$this->phoneNumber = '';
$this->messageType = 'text';
$this->messageContent = '';
$this->templateId = null;
$this->scheduledDate = '';
$this->scheduledTime = '';
}
public function render()
{
$messages = WhatsappScheduledMessage::when($this->search, fn($q) =>
$q->where('phone_number', 'like', "%{$this->search}%")
->orWhere('message_content', 'like', "%{$this->search}%"))
->when($this->filterStatus, fn($q) => $q->where('status', $this->filterStatus))
->orderByDesc('scheduled_date')
->orderByDesc('scheduled_time')
->paginate(20);
$templates = WhatsappMessageTemplate::where('status', 'approved')->get();
return view('livewire.whatsapp.show-programados', compact('messages', 'templates'));
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class WhatsappConversation extends Model
{
protected $table = 'whatsapp_conversations';
public $updated_at = null;
protected $fillable = [
'user_id', 'message_id', 'reply_to_message_id', 'reaction_to_message_id',
'reaction_emoji', 'direction', 'message_type', 'content', 'media_url',
'whatsapp_media_id', 'local_file', 'local_thumb', 'media_storage',
'status', 'is_read', 'filename', 'mime_type',
];
protected $casts = [
'is_read' => 'boolean',
'created_at' => 'datetime',
];
public function user()
{
return $this->belongsTo(WhatsappUser::class, 'user_id');
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class WhatsappMenu extends Model
{
protected $table = 'whatsapp_menus';
protected $fillable = [
'name', 'title', 'message', 'parent_id', 'is_main', 'is_active', 'sort_order',
];
protected $casts = [
'is_main' => 'boolean',
'is_active' => 'boolean',
];
public function options()
{
return $this->hasMany(WhatsappMenuOption::class, 'menu_id')->orderBy('sort_order');
}
public function parent()
{
return $this->belongsTo(WhatsappMenu::class, 'parent_id');
}
public function children()
{
return $this->hasMany(WhatsappMenu::class, 'parent_id');
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class WhatsappMenuOption extends Model
{
protected $table = 'whatsapp_menu_options';
public $timestamps = false;
protected $fillable = [
'menu_id', 'option_number', 'title', 'action_type', 'action_value',
'is_active', 'sort_order',
];
protected $casts = [
'is_active' => 'boolean',
];
public function menu()
{
return $this->belongsTo(WhatsappMenu::class, 'menu_id');
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class WhatsappMessageTemplate extends Model
{
protected $table = 'whatsapp_message_templates';
protected $fillable = [
'name', 'template_name', 'language_code', 'status', 'body_text',
'header_type', 'header_text', 'footer_text', 'components', 'example_parameters',
];
protected $casts = [
'components' => 'array',
'example_parameters' => 'array',
];
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class WhatsappScheduledMessage extends Model
{
protected $table = 'whatsapp_scheduled_messages';
public $updated_at = null;
protected $fillable = [
'user_id', 'phone_number', 'template_id', 'template_name', 'template_language',
'template_parameters', 'message_type', 'message_content', 'scheduled_date',
'scheduled_time', 'status', 'sent_at', 'error_message', 'created_by',
];
protected $casts = [
'template_parameters' => 'array',
'scheduled_date' => 'date',
'sent_at' => 'datetime',
];
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class WhatsappSystemConfig extends Model
{
protected $table = 'whatsapp_system_config';
protected $fillable = ['config_key', 'config_value', 'description'];
public static function get(string $key, string $default = ''): string
{
$record = static::where('config_key', $key)->first();
return $record ? ($record->config_value ?? $default) : $default;
}
public static function set(string $key, string $value): void
{
static::updateOrCreate(
['config_key' => $key],
['config_value' => $value]
);
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class WhatsappUser extends Model
{
protected $table = 'whatsapp_users';
protected $fillable = [
'phone_number', 'name', 'status', 'current_menu_id', 'current_step',
'session_data', 'welcome_sent_at', 'in_service', 'in_service_by',
'on_hold', 'bot_paused_until', 'advisor_requested', 'bot_enabled',
'terms_pending', 'terms_accepted_at', 'terms_version_id',
];
protected $casts = [
'session_data' => 'array',
'welcome_sent_at' => 'datetime',
'bot_paused_until' => 'datetime',
'terms_accepted_at' => 'datetime',
'in_service' => 'boolean',
'on_hold' => 'boolean',
'advisor_requested' => 'boolean',
'bot_enabled' => 'boolean',
'terms_pending' => 'boolean',
];
public function conversations()
{
return $this->hasMany(WhatsappConversation::class, 'user_id');
}
public function lastMessage()
{
return $this->hasOne(WhatsappConversation::class, 'user_id')->latestOfMany();
}
public function currentMenu()
{
return $this->belongsTo(WhatsappMenu::class, 'current_menu_id');
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class WhatsappWebhookLog extends Model
{
protected $table = 'whatsapp_webhook_logs';
public $timestamps = false;
protected $fillable = ['payload', 'response', 'status_code'];
protected $casts = [
'created_at' => 'datetime',
];
}
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('whatsapp_users', function (Blueprint $table) {
$table->id();
$table->string('phone_number', 20)->unique();
$table->string('name', 100)->nullable();
$table->enum('status', ['active', 'blocked', 'inactive'])->default('active');
$table->unsignedBigInteger('current_menu_id')->nullable();
$table->integer('current_step')->default(0);
$table->text('session_data')->nullable();
$table->datetime('welcome_sent_at')->nullable();
$table->tinyInteger('in_service')->default(0);
$table->unsignedBigInteger('in_service_by')->nullable();
$table->tinyInteger('on_hold')->default(0);
$table->datetime('bot_paused_until')->nullable();
$table->tinyInteger('advisor_requested')->default(0);
$table->tinyInteger('bot_enabled')->default(1);
$table->tinyInteger('terms_pending')->default(0);
$table->datetime('terms_accepted_at')->nullable();
$table->unsignedBigInteger('terms_version_id')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('whatsapp_users');
}
};
@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('whatsapp_conversations', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id');
$table->string('message_id', 255)->nullable()->unique();
$table->string('reply_to_message_id', 255)->nullable();
$table->string('reaction_to_message_id', 255)->nullable();
$table->string('reaction_emoji', 64)->nullable();
$table->enum('direction', ['incoming', 'outgoing']);
$table->string('message_type', 32)->default('text');
$table->text('content')->nullable();
$table->text('media_url')->nullable();
$table->string('whatsapp_media_id', 255)->nullable();
$table->string('local_file', 255)->nullable();
$table->string('local_thumb', 255)->nullable();
$table->string('media_storage', 50)->nullable();
$table->string('status', 32)->default('received');
$table->tinyInteger('is_read')->default(0);
$table->string('filename', 255)->nullable();
$table->string('mime_type', 100)->nullable();
$table->timestamp('created_at')->useCurrent();
$table->foreign('user_id')->references('id')->on('whatsapp_users')->onDelete('cascade');
$table->index(['user_id', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('whatsapp_conversations');
}
};
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('whatsapp_menus', function (Blueprint $table) {
$table->id();
$table->string('name', 255);
$table->string('title', 255);
$table->text('message');
$table->unsignedBigInteger('parent_id')->nullable();
$table->tinyInteger('is_main')->default(0);
$table->tinyInteger('is_active')->default(1);
$table->integer('sort_order')->default(0);
$table->timestamps();
});
Schema::create('whatsapp_menu_options', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('menu_id');
$table->integer('option_number');
$table->string('title', 255);
$table->enum('action_type', ['submenu', 'message', 'template', 'url', 'advisor', 'flow']);
$table->text('action_value')->nullable();
$table->tinyInteger('is_active')->default(1);
$table->integer('sort_order')->default(0);
$table->foreign('menu_id')->references('id')->on('whatsapp_menus')->onDelete('cascade');
});
}
public function down(): void
{
Schema::dropIfExists('whatsapp_menu_options');
Schema::dropIfExists('whatsapp_menus');
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('whatsapp_message_templates', function (Blueprint $table) {
$table->id();
$table->string('name', 255);
$table->string('template_name', 255);
$table->string('language_code', 10)->default('es');
$table->enum('status', ['pending', 'approved', 'rejected', 'paused'])->default('pending');
$table->text('body_text')->nullable();
$table->enum('header_type', ['none', 'text', 'image', 'video', 'document'])->default('none');
$table->string('header_text', 255)->nullable();
$table->string('footer_text', 255)->nullable();
$table->longText('components')->nullable();
$table->longText('example_parameters')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('whatsapp_message_templates');
}
};
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('whatsapp_system_config', function (Blueprint $table) {
$table->id();
$table->string('config_key', 255)->unique();
$table->text('config_value')->nullable();
$table->text('description')->nullable();
$table->timestamps();
});
// Seed default config keys
\DB::table('whatsapp_system_config')->insert([
['config_key' => 'whatsapp_token', 'config_value' => '', 'description' => 'Token de acceso Bearer de Meta', 'created_at' => now(), 'updated_at' => now()],
['config_key' => 'phone_number_id', 'config_value' => '', 'description' => 'ID del número de teléfono en Meta', 'created_at' => now(), 'updated_at' => now()],
['config_key' => 'whatsapp_api_url', 'config_value' => 'https://graph.facebook.com/v22.0/', 'description' => 'URL base de la Graph API', 'created_at' => now(), 'updated_at' => now()],
['config_key' => 'webhook_verify_token', 'config_value' => '', 'description' => 'Token secreto para verificar el webhook', 'created_at' => now(), 'updated_at' => now()],
['config_key' => 'bot_enabled', 'config_value' => '1', 'description' => 'Activar/desactivar el bot (1/0)', 'created_at' => now(), 'updated_at' => now()],
['config_key' => 'welcome_message', 'config_value' => '¡Hola! Bienvenido. ¿En qué te podemos ayudar?', 'description' => 'Mensaje de bienvenida', 'created_at' => now(), 'updated_at' => now()],
['config_key' => 'default_no_match', 'config_value' => 'No entendí tu mensaje. Escribe MENU para ver las opciones.', 'description' => 'Mensaje cuando no se entiende la entrada', 'created_at' => now(), 'updated_at' => now()],
['config_key' => 'advisor_message', 'config_value' => 'Un asesor se comunicará contigo pronto.', 'description' => 'Mensaje al solicitar asesor', 'created_at' => now(), 'updated_at' => now()],
['config_key' => 'business_hours_enabled', 'config_value' => '0', 'description' => 'Validar horarios de atención (1/0)', 'created_at' => now(), 'updated_at' => now()],
['config_key' => 'business_hours_start', 'config_value' => '08:00', 'description' => 'Hora inicio de atención', 'created_at' => now(), 'updated_at' => now()],
['config_key' => 'business_hours_end', 'config_value' => '18:00', 'description' => 'Hora fin de atención', 'created_at' => now(), 'updated_at' => now()],
]);
}
public function down(): void
{
Schema::dropIfExists('whatsapp_system_config');
}
};
@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('whatsapp_scheduled_messages', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('phone_number', 20);
$table->unsignedBigInteger('template_id')->nullable();
$table->string('template_name', 100)->nullable();
$table->string('template_language', 10)->nullable();
$table->longText('template_parameters')->nullable();
$table->enum('message_type', ['text', 'template'])->default('text');
$table->text('message_content')->nullable();
$table->date('scheduled_date');
$table->time('scheduled_time');
$table->enum('status', ['pending', 'sent', 'failed', 'cancelled'])->default('pending');
$table->datetime('sent_at')->nullable();
$table->text('error_message')->nullable();
$table->unsignedBigInteger('created_by')->nullable();
$table->datetime('created_at')->useCurrent();
});
Schema::create('whatsapp_webhook_logs', function (Blueprint $table) {
$table->id();
$table->longText('payload');
$table->text('response')->nullable();
$table->integer('status_code')->nullable();
$table->timestamp('created_at')->useCurrent();
});
}
public function down(): void
{
Schema::dropIfExists('whatsapp_webhook_logs');
Schema::dropIfExists('whatsapp_scheduled_messages');
}
};
+916
View File
@@ -0,0 +1,916 @@
# Documentación Técnica — Bot WhatsApp Cloud API
> **Propósito:** Esta documentación está pensada para ser entregada a otra IA o equipo de desarrollo para reimplementar el bot en cualquier lenguaje/plataforma, con toda la información necesaria sobre flujos, APIs, base de datos y contratos de datos.
---
## 1. Resumen del sistema
El sistema es un **bot de WhatsApp** basado en la **WhatsApp Cloud API (Meta/Facebook Graph API v22.0)**. Permite:
- Recibir y procesar mensajes entrantes mediante un **webhook HTTP**.
- Responder automáticamente con textos, menús, botones interactivos, listas y archivos multimedia.
- Transferir la conversación a un asesor humano.
- Enviar **plantillas de mensaje** aprobadas por Meta.
- Enviar **mensajes programados**.
- Gestionar **términos y condiciones** (envío y aceptación).
**Lenguaje original:** PHP 8.x
**Base de datos:** MySQL / MariaDB
**Cola / Workers:** Worker PHP con cron (no RabbitMQ ni Redis, aunque se usa Redis como opción para rate-limit).
---
## 2. Credenciales y configuración requerida
Todas las credenciales se almacenan en la **tabla `system_config`** de la base de datos (no en código duro). También se cargan desde variables de entorno / archivo `.env`.
### 2.1 Variables de entorno (`.env`)
```
DB_HOST=mysql # Host de la base de datos
DB_PORT=3306
DB_NAME=usite_whatsapp_bot
DB_USER=usite_whatsapp_user
DB_PASS=<password>
DB_CHARSET=utf8mb4
```
### 2.2 Configuración de WhatsApp (en tabla `system_config`)
| `config_key` | Descripción | Ejemplo |
|----------------------|-----------------------------------------------------------------|--------------------------------------------------|
| `whatsapp_token` | Token de acceso permanente de la App de Meta | `EAABz...` |
| `phone_number_id` | ID del número de teléfono de la cuenta de WhatsApp Business | `123456789012345` |
| `whatsapp_api_url` | URL base de la Graph API | `https://graph.facebook.com/v22.0/` |
| `webhook_verify_token` | Token secreto para verificar el webhook (GET de Meta) | `mi_token_secreto` |
| `bot_enabled` | Si el bot responde automáticamente (`1`/`0`) | `1` |
| `business_hours_*` | Horarios de atención (ver sección 8) | — |
| `terms_message` | Mensaje de términos y condiciones que se envía al usuario | texto libre |
---
## 3. API de WhatsApp consumida
**Base URL:** `https://graph.facebook.com/v22.0/{phone_number_id}/`
**Autenticación:** Header `Authorization: Bearer {whatsapp_token}`
**Formato:** JSON (Content-Type: `application/json`) salvo para subida de archivos (multipart/form-data).
### 3.1 Enviar mensajes — `POST /{phone_number_id}/messages`
Todos los tipos de mensaje tienen el campo común:
```json
{
"messaging_product": "whatsapp",
"to": "57300XXXXXXX"
}
```
---
#### 3.1.1 Mensaje de texto
```json
{
"messaging_product": "whatsapp",
"to": "57300XXXXXXX",
"type": "text",
"text": {
"body": "Hola, ¿en qué te podemos ayudar?"
}
}
```
---
#### 3.1.2 Mensaje de plantilla (template)
```json
{
"messaging_product": "whatsapp",
"to": "57300XXXXXXX",
"type": "template",
"template": {
"name": "nombre_plantilla",
"language": { "code": "es" },
"components": [
{
"type": "header",
"parameters": [
{ "type": "text", "text": "Valor del header" }
]
},
{
"type": "body",
"parameters": [
{ "type": "text", "parameter_name": "nombre", "text": "Juan" },
{ "type": "text", "parameter_name": "fecha", "text": "20/04/2026" }
]
}
]
}
}
```
> **Nota sobre parámetros nombrados vs posicionales:**
> - Si la plantilla usa variables `{{nombre}}` (nombradas): incluir `"parameter_name": "nombre"`.
> - Si la plantilla usa variables `{{1}}`, `{{2}}` (posicionales): omitir `parameter_name`, usar solo `"type": "text", "text": "valor"` en orden.
---
#### 3.1.3 Mensaje interactivo — Botones
```json
{
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": "57300XXXXXXX",
"type": "interactive",
"interactive": {
"type": "button",
"header": { "type": "text", "text": "Encabezado opcional" },
"body": { "text": "Selecciona una opción:" },
"footer": { "text": "Pie de página opcional" },
"action": {
"buttons": [
{ "type": "reply", "reply": { "id": "btn_1", "title": "Opción 1" } },
{ "type": "reply", "reply": { "id": "btn_2", "title": "Opción 2" } }
]
}
}
}
```
> Máximo **3 botones**. El `title` no puede superar 20 caracteres.
---
#### 3.1.4 Mensaje interactivo — Lista
```json
{
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": "57300XXXXXXX",
"type": "interactive",
"interactive": {
"type": "list",
"body": { "text": "Elige una opción de la lista:" },
"action": {
"button": "Ver opciones",
"sections": [
{
"title": "Sección 1",
"rows": [
{ "id": "row_1", "title": "Opción 1", "description": "Descripción opcional" },
{ "id": "row_2", "title": "Opción 2" }
]
}
]
}
}
}
```
---
#### 3.1.5 Mensaje de imagen
```json
{
"messaging_product": "whatsapp",
"to": "57300XXXXXXX",
"type": "image",
"image": {
"link": "https://ejemplo.com/imagen.jpg",
"caption": "Pie de foto opcional"
}
}
```
---
#### 3.1.6 Mensaje de video
```json
{
"messaging_product": "whatsapp",
"to": "57300XXXXXXX",
"type": "video",
"video": {
"link": "https://ejemplo.com/video.mp4",
"caption": "Descripción del video"
}
}
```
---
#### 3.1.7 Mensaje de audio
```json
{
"messaging_product": "whatsapp",
"to": "57300XXXXXXX",
"type": "audio",
"audio": {
"link": "https://ejemplo.com/audio.mp3"
}
}
```
> Para notas de voz, el archivo debe ser `.ogg` con códec **OPUS** y subirse primero al media endpoint.
---
#### 3.1.8 Mensaje de documento
```json
{
"messaging_product": "whatsapp",
"to": "57300XXXXXXX",
"type": "document",
"document": {
"link": "https://ejemplo.com/archivo.pdf",
"filename": "resultados.pdf",
"caption": "Tus resultados de laboratorio"
}
}
```
---
#### 3.1.9 Marcar mensaje como leído — `POST /{phone_number_id}/conversations`
```json
{
"messaging_product": "whatsapp",
"status": "read",
"message_id": "wamid.XXXXX"
}
```
---
#### 3.1.10 Enviar reacción a un mensaje
```json
{
"messaging_product": "whatsapp",
"to": "57300XXXXXXX",
"type": "reaction",
"reaction": {
"message_id": "wamid.XXXXX",
"emoji": "👍"
}
}
```
---
### 3.2 Subir archivo multimedia — `POST /{phone_number_id}/media`
**Content-Type:** `multipart/form-data`
Campos del formulario:
| Campo | Valor |
|---------------------|------------------------------------------|
| `messaging_product` | `whatsapp` |
| `file` | Archivo binario |
| `type` | MIME type (ej: `image/jpeg`, `video/mp4`) |
**Respuesta exitosa:**
```json
{ "id": "1234567890123456" }
```
Una vez obtenido el `id`, se puede usar en mensajes así:
```json
{
"messaging_product": "whatsapp",
"to": "57300XXXXXXX",
"type": "image",
"image": { "id": "1234567890123456" }
}
```
---
### 3.3 Obtener URL de un media — `GET /{media_id}`
**Respuesta:**
```json
{
"url": "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=...",
"mime_type": "image/jpeg",
"sha256": "...",
"file_size": 12345,
"id": "1234567890123456",
"messaging_product": "whatsapp"
}
```
Luego descargar esa URL con el mismo Bearer token.
---
### 3.4 Gestión de plantillas — `GET /{waba_id}/message_templates`
```
GET https://graph.facebook.com/v22.0/{WABA_ID}/message_templates
Authorization: Bearer {token}
```
**Parámetros opcionales:** `?name=nombre_plantilla&status=APPROVED`
---
## 4. Webhook — Recepción de mensajes
### 4.1 Verificación del webhook (GET)
Meta envía un GET para verificar el endpoint:
```
GET /api/webhook.php?hub.mode=subscribe&hub.verify_token=TOKEN&hub.challenge=CHALLENGE
```
El sistema debe responder con el valor de `hub.challenge` si `hub.verify_token` coincide con `WEBHOOK_VERIFY_TOKEN`.
---
### 4.2 Payload de mensaje entrante (POST)
```json
{
"object": "whatsapp_business_account",
"entry": [
{
"id": "WABA_ID",
"changes": [
{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "57300XXXXXXX",
"phone_number_id": "PHONE_NUMBER_ID"
},
"contacts": [
{
"profile": { "name": "Nombre del Contacto" },
"wa_id": "57300XXXXXXX"
}
],
"messages": [
{
"from": "57300XXXXXXX",
"id": "wamid.XXXXXXXXXXXXX",
"timestamp": "1713900000",
"type": "text",
"text": { "body": "Hola" }
}
]
}
}
]
}
]
}
```
**Tipos de mensaje posibles en el campo `type`:**
| `type` | Estructura del mensaje |
|--------------|-------------------------------------------------------------|
| `text` | `message.text.body` |
| `image` | `message.image.id`, `message.image.mime_type` |
| `video` | `message.video.id`, `message.video.mime_type` |
| `audio` | `message.audio.id`, `message.audio.voice` (bool) |
| `document` | `message.document.id`, `document.filename`, `document.mime_type` |
| `sticker` | `message.sticker.id` |
| `location` | `message.location.latitude`, `message.location.longitude` |
| `interactive`| `message.interactive.type` = `list_reply` o `button_reply` |
| `reaction` | `message.reaction.emoji`, `message.reaction.message_id` |
**Respuesta de acceso interactivo:**
```json
{
"interactive": {
"type": "list_reply",
"list_reply": {
"id": "row_1",
"title": "Opción 1",
"description": "descripción"
}
}
}
```
---
### 4.3 Normalización interna de mensajes interactivos
El webhook normaliza las respuestas de lista/botón a texto plano para que el BotService las procese como si fueran texto:
- Si `title` empieza con un número (`"1. Opción"`), se extrae solo el número.
- Si no, se usa el `title` completo.
---
## 5. Flujo de procesamiento de mensajes
```
WhatsApp Cloud API
POST /api/webhook.php
├─ Verificar duplicado (por message_id en conversations)
├─ Obtener/crear usuario en tabla `users`
├─ Guardar nombre del contacto si no tenía
WhatsAppWebhook::processConversations()
├─ Extraer tipo de mensaje y contenido
├─ Guardar mensaje en tabla `conversations` (direction: incoming)
BotService::processMessage($user, $messageText, $messageType)
├─ 1. Adquirir advisory lock por usuario (MySQL GET_LOCK) → evita race conditions
├─ 2. Si usuario bloqueado → descartar
├─ 3. Si usuario in_service (atendido por asesor) → descartar
├─ 4. maybeResetConversationAfterIdle() → reinicia si inactividad > 6h
├─ 5. Flujo de Términos:
│ ├─ Si terms_pending → processTermsResponse()
│ └─ Si needsTermsAcceptance() → sendTermsMessage()
├─ 6. Si 'isNewUser' → sendWelcomeMessage()
├─ 7. Si usuario escribe "enviado/a/os/as" → requestAdvisor()
├─ 8. processSpecialCommands() (MENU, ATRÁS, ASESOR, etc.)
├─ 9. Si on_hold activo y no expiró → notificar y retornar
├─10. Si advisor_requested activo → solo navegación explícita
├─11. Si bot_paused_until activo → solo navegación explícita
├─12. Si bot_enabled=false → solo comandos explícitos
├─13. Si current_menu_id → processMenuSelection()
└─14. Fallback → sendDefaultNoMatch()
```
---
## 6. Comandos especiales del bot
Los usuarios pueden escribir estas palabras clave en cualquier momento:
| Palabra clave | Acción |
|----------------------------------------|--------------------------------------------|
| `menu`, `menú`, `inicio` | Mostrar menú principal |
| `atras`, `atrás`, `volver`, `back` | Retroceder al menú anterior |
| `asesor`, `agente`, `humano` | Solicitar atención humana |
| `enviado`, `enviada`, `enviados`, `enviadas` | Indicar que se envió documentación — transfiere a asesor |
| Número (`1`, `2`, `3`…) | Seleccionar opción de menú activo |
---
## 7. Base de datos — Tablas principales del bot
### 7.1 `users` — Usuarios (contactos de WhatsApp)
| Campo | Tipo | Descripción |
|---------------------|-----------------------|----------------------------------------------------|
| `id` | INT AUTO_INCREMENT | Clave primaria |
| `phone_number` | VARCHAR(20) UNIQUE | Número en formato internacional sin `+` (ej: `573001234567`) |
| `name` | VARCHAR(100) | Nombre del contacto (viene del perfil de WhatsApp) |
| `status` | ENUM | `active`, `blocked`, `inactive` |
| `current_menu_id` | INT NULL | FK → `menus.id` — menú donde está el usuario |
| `current_step` | INT DEFAULT 0 | Paso dentro del flujo actual |
| `session_data` | TEXT (JSON) | Datos temporales de sesión |
| `welcome_sent_at` | DATETIME | Última vez que se envió el mensaje de bienvenida |
| `in_service` | TINYINT(1) | `1` = está siendo atendido por un asesor |
| `in_service_by` | INT NULL | FK → `admin_users.id` — asesor que lo atiende |
| `on_hold` | TINYINT(1) | `1` = el asesor lo puso en espera |
| `bot_paused_until` | DATETIME NULL | El bot está pausado hasta esta fecha/hora |
| `advisor_requested` | TINYINT(1) | `1` = el usuario solicitó asesor |
| `bot_enabled` | TINYINT(1) DEFAULT 1 | Si el bot responde a este usuario |
| `terms_pending` | TINYINT(1) | `1` = esperando respuesta de términos |
| `terms_accepted_at` | DATETIME | Cuándo aceptó los términos |
| `terms_version_id` | INT NULL | FK → `terms_versions.id` |
| `created_at` | TIMESTAMP | Fecha de creación |
| `updated_at` | TIMESTAMP | Última actualización |
---
### 7.2 `conversations` — Mensajes
| Campo | Tipo | Descripción |
|--------------------------|-----------------|-----------------------------------------------------------|
| `id` | INT | Clave primaria |
| `user_id` | INT | FK → `users.id` |
| `message_id` | VARCHAR(255) | ID único del mensaje en WhatsApp (`wamid.XXX`) |
| `reply_to_message_id` | VARCHAR(255) | ID del mensaje al que responde |
| `reaction_to_message_id` | VARCHAR(255) | ID del mensaje al que se reaccionó |
| `reaction_emoji` | VARCHAR(64) | Emoji de la reacción |
| `direction` | ENUM | `incoming` (usuario→bot) / `outgoing` (bot→usuario) |
| `message_type` | VARCHAR(32) | `text`, `image`, `video`, `audio`, `document`, `reaction`|
| `content` | TEXT | Contenido del mensaje (texto o JSON para multimedia/sistema) |
| `media_url` | TEXT | URL pública del archivo |
| `whatsapp_media_id` | VARCHAR(255) | ID del media en WhatsApp |
| `local_file` | VARCHAR(255) | Ruta local del archivo descargado |
| `local_thumb` | VARCHAR(255) | Ruta local del thumbnail |
| `media_storage` | VARCHAR(50) | Dónde se almacena (`local`, `whatsapp`, etc.) |
| `status` | VARCHAR(32) | `received`, `sent`, `delivered`, `read`, `failed` |
| `is_read` | TINYINT(1) | Si el admin marcó la conversación como leída |
| `filename` | VARCHAR(255) | Nombre del archivo adjunto |
| `mime_type` | VARCHAR(100) | MIME type del archivo |
| `created_at` | TIMESTAMP | Fecha del mensaje |
---
### 7.3 `menus` — Menús del bot
| Campo | Tipo | Descripción |
|-----------------|---------------|----------------------------------------------------------|
| `id` | INT | Clave primaria |
| `name` | VARCHAR(255) | Nombre interno del menú |
| `title` | VARCHAR(255) | Título que se muestra al usuario |
| `message` | TEXT | Texto del menú completo |
| `parent_id` | INT NULL | FK → `menus.id` — menú padre (para navegación ATRÁS) |
| `is_main` | TINYINT(1) | `1` = es el menú principal |
| `is_active` | TINYINT(1) | `1` = activo |
| `sort_order` | INT | Orden de presentación |
| `created_at` | TIMESTAMP | |
---
### 7.4 `menu_options` — Opciones de menú
| Campo | Tipo | Descripción |
|-----------------|---------------|----------------------------------------------------------------|
| `id` | INT | Clave primaria |
| `menu_id` | INT | FK → `menus.id` |
| `option_number` | INT | Número que el usuario debe escribir (1, 2, 3…) |
| `title` | VARCHAR(255) | Texto de la opción |
| `action_type` | ENUM | `submenu`, `message`, `template`, `url`, `advisor`, `flow` |
| `action_value` | TEXT | Depends en `action_type`: ID de submenú, texto, nombre de plantilla, URL |
| `is_active` | TINYINT(1) | `1` = activa |
| `sort_order` | INT | Orden |
---
### 7.5 `message_templates` — Plantillas
| Campo | Tipo | Descripción |
|----------------------|---------------|----------------------------------------------------------------|
| `id` | INT | Clave primaria |
| `name` | VARCHAR(255) | Nombre interno |
| `template_name` | VARCHAR(255) | Nombre en Meta (slug, sin espacios) |
| `language_code` | VARCHAR(10) | Idioma (`es`, `en`, etc.) |
| `status` | ENUM | `pending`, `approved`, `rejected`, `paused` |
| `body_text` | TEXT | Cuerpo del template con variables `{{1}}` o `{{nombre}}` |
| `header_type` | ENUM | `text`, `image`, `video`, `document` |
| `header_text` | VARCHAR(255) | Texto del header si aplica |
| `footer_text` | VARCHAR(255) | Pie del mensaje |
| `components` | LONGTEXT JSON | Componentes completos del template tal como los devuelve Meta |
| `example_parameters` | LONGTEXT JSON | Ejemplos de valores para las variables |
| `created_at` | TIMESTAMP | |
| `updated_at` | TIMESTAMP | |
---
### 7.6 `autoresponses` — Respuestas automáticas por keyword
| Campo | Tipo | Descripción |
|-----------------|-|-----------------------------------------------------------------|
| `id` | INT | Clave primaria |
| `trigger_type` | ENUM | `keyword`, `contains`, `exact`, `welcome`, `default` |
| `trigger_value` | TEXT | Palabras clave separadas por coma |
| `response_text` | TEXT | Texto de la respuesta |
| `response_type` | ENUM | `text`, `template`, `menu` |
| `template_name` | VARCHAR(255) | Nombre de la plantilla si `response_type = template` |
| `menu_id` | INT NULL | ID del menú si `response_type = menu` |
| `priority` | INT | Prioridad (mayor número = mayor prioridad) |
| `is_active` | TINYINT(1) | `1` = activa |
---
### 7.7 `scheduled_messages` — Mensajes programados
| Campo | Tipo | Descripción |
|-----------------------|---------------|-----------------------------------------------------------|
| `id` | INT | Clave primaria |
| `user_id` | INT | FK → `users.id` |
| `template_id` | INT NULL | FK → `message_templates.id` |
| `template_name` | VARCHAR(100) | Nombre de la plantilla |
| `template_language` | VARCHAR(10) | Idioma de la plantilla |
| `template_parameters` | LONGTEXT JSON | Parámetros para las variables de la plantilla |
| `message_type` | ENUM | `text`, `template` |
| `message_content` | TEXT | Texto si `message_type = text` |
| `scheduled_date` | DATE | Fecha programada |
| `scheduled_time` | TIME | Hora programada |
| `status` | ENUM | `pending`, `sent`, `failed`, `cancelled` |
| `sent_at` | DATETIME | Cuándo se envió efectivamente |
| `error_message` | TEXT | Error si falló el envío |
| `created_by` | INT NULL | Admin que creó el recordatorio |
| `created_at` | DATETIME | |
---
### 7.8 `system_config` — Configuración del sistema
| Campo | Tipo | Descripción |
|----------------|---------------|------------------------------------|
| `id` | INT | Clave primaria |
| `config_key` | VARCHAR(255) | Clave de configuración (UNIQUE) |
| `config_value` | TEXT | Valor de la configuración |
| `description` | TEXT NULL | Descripción opcional |
| `created_at` | DATETIME | |
| `updated_at` | DATETIME | |
**Claves relevantes para el bot:**
| `config_key` | Descripción |
|--------------------------|------------------------------------------------------|
| `whatsapp_token` | Token de acceso Bearer |
| `phone_number_id` | ID del número de teléfono |
| `whatsapp_api_url` | URL base del API |
| `webhook_verify_token` | Token secreto del webhook |
| `bot_enabled` | Activar/desactivar el bot (`1`/`0`) |
| `welcome_message` | Texto del mensaje de bienvenida |
| `default_no_match` | Mensaje cuando no se entiende la entrada del usuario|
| `advisor_message` | Mensaje al solicitar asesor |
| `terms_message` | Texto de los términos y condiciones |
| `business_hours_enabled` | Si se validan horarios de atención |
| `business_hours_start` | Hora inicio (ej: `06:15`) |
| `business_hours_end` | Hora fin (ej: `17:00`) |
---
### 7.9 `notifications` — Notificaciones internas para asesores
| Campo | Tipo | Descripción |
|-------------|--------------|------------------------------------------|
| `id` | INT | Clave primaria |
| `user_id` | INT | FK → `users.id` |
| `type` | VARCHAR(50) | Tipo de notificación (`new_message`, `advisor_requested`, etc.) |
| `message` | TEXT | Cuerpo de la notificación |
| `is_read` | TINYINT(1) | Si fue leída |
| `created_at`| TIMESTAMP | |
---
### 7.10 `terms_versions` — Versiones de términos y condiciones
| Campo | Tipo | Descripción |
|--------------------|---------------|-------------------------------------------------------|
| `id` | INT | Clave primaria |
| `version` | VARCHAR(20) | Versión (ej: `1.0`, `2.0`) |
| `titulo` | VARCHAR(255) | Título del documento |
| `documento_url` | TEXT | URL pública del PDF de términos |
| `mensaje_aceptacion` | TEXT | Mensaje que se envía al usuario solicitando aceptación |
| `activa` | TINYINT(1) | `1` = versión vigente |
| `forzar_reenvio` | TINYINT(1) | `1` = forzar que todos los usuarios re-acepten |
| `created_at` | TIMESTAMP | |
---
### 7.11 `terms_acceptance` — Aceptación de términos
| Campo | Tipo | Descripción |
|--------------------|---------------|---------------------------------------|
| `id` | INT | Clave primaria |
| `user_id` | INT | FK → `users.id` |
| `terms_version_id` | INT | FK → `terms_versions.id` |
| `phone_number` | VARCHAR(20) | Teléfono del usuario |
| `estado` | ENUM | `pendiente`, `aceptado`, `rechazado` |
| `fecha_envio` | DATETIME | Cuándo se envió la solicitud |
| `fecha_respuesta` | DATETIME NULL | Cuándo respondió el usuario |
---
### 7.12 `admin_users` — Administradores del sistema
| Campo | Tipo | Descripción |
|----------------|---------------|----------------------------------|
| `id` | INT | Clave primaria |
| `username` | VARCHAR(50) | Nombre de usuario (UNIQUE) |
| `password_hash`| VARCHAR(255) | Bcrypt hash de la contraseña |
| `full_name` | VARCHAR(100) | Nombre completo |
| `email` | VARCHAR(100) | Correo electrónico |
| `is_active` | TINYINT(1) | `1` = activo |
| `role_id` | INT NULL | FK → `roles.id` |
| `last_login` | TIMESTAMP | Último login |
---
### 7.13 `webhook_logs` — Logs del webhook
| Campo | Tipo | Descripción |
|---------------|---------------|---------------------------------------|
| `id` | INT | Clave primaria |
| `payload` | LONGTEXT | Body JSON recibido de WhatsApp |
| `response` | TEXT | Respuesta devuelta |
| `status_code` | INT | Código HTTP |
| `created_at` | TIMESTAMP | Fecha del evento |
---
## 8. Formato del número de teléfono
Los números se almacenan y envían **sin el símbolo `+`**, en formato E.164:
- Colombia: `573001234567` (código país 57 + número sin prefijo)
- Argentina: `5491123456789`
El sistema normaliza automáticamente:
- Elimina caracteres no numéricos.
- Si el número tiene 10 dígitos y empieza con `3` → agrega prefijo `57` (Colombia).
- Si ya tiene 12 dígitos → lo usa tal cual.
---
## 9. Lógica de estados del usuario
El bot controla el flujo mediante campos en la tabla `users`:
```
Estado normal:
bot_enabled=1, in_service=0, on_hold=0, advisor_requested=0, bot_paused_until=NULL
→ Bot responde normalmente
En menú:
current_menu_id=<id> → Bot espera número de opción
Atendido por asesor:
in_service=1, in_service_by=<admin_id>
→ Bot NO responde, asesor humano escribe directamente
En espera (hold activo):
on_hold=1, bot_paused_until=<datetime>
→ Bot notifica que está en espera, solo permite MENU/ATRÁS/números
Asesor solicitado:
advisor_requested=1, bot_paused_until=<datetime>
→ Bot espera al asesor, solo permite navegación explícita
Bot pausado:
bot_paused_until=<datetime futura>
→ Bot no responde mensajes libres, solo menús/comandos
Pendiente de términos:
terms_pending=1
→ Bot solo procesa respuesta "acepto" o "rechazo"
Usuario bloqueado:
status='blocked'
→ Todos los mensajes se descartan silenciosamente
```
---
## 10. Estados de conversación (tabla `user_states`)
Estados clave/valor persistentes por usuario (como sesión):
| `state_key` | Descripción |
|----------------|---------------------------------------------------------------|
| `menu_history` | Array JSON con historial de IDs de menús visitados |
| `current_menu` | ID del menú actual |
| `form_data` | Datos temporales de un formulario en curso |
---
## 11. Endpoints internos del sistema (API interna)
Estos endpoints son para el **panel de administración**, no son de WhatsApp. Se implementarían como API REST en el nuevo sistema:
| Método | Ruta interna | Descripción |
|--------|-------------------------------|--------------------------------------------------|
| GET | `/api/get_conversations.php` | Lista de conversaciones con paginación |
| GET | `/api/get_conversation_detail.php?user_id=X` | Mensajes de un usuario |
| POST | `/api/send_message.php` | Enviar mensaje desde el panel |
| POST | `/api/send_reply.php` | Responder a un mensaje |
| POST | `/api/attend.php` | Marcar usuario como "en atención" por asesor |
| POST | `/api/finish_attend.php` | Liberar usuario de la atención |
| POST | `/api/release_hold.php` | Liberar usuario del estado on_hold |
| GET | `/api/get_templates.php` | Lista de plantillas aprobadas |
| POST | `/api/send_broadcast.php` | Enviar mensaje masivo a múltiples usuarios |
| POST | `/api/schedule_message.php` | Programar un mensaje |
| GET | `/api/get_menus.php` | Lista de menús configurados |
| POST | `/api/save_menu.php` | Crear/editar menú |
| GET | `/api/get_settings.php` | Configuración del sistema |
| POST | `/api/save_settings.php` | Guardar configuración |
| GET | `/api/sse_events.php` | Server-Sent Events para tiempo real |
---
## 12. Flujo de términos y condiciones
```
Usuario envía mensaje
¿needsTermsAcceptance()?
│ SÍ
sendTermsMessage()
→ Envía texto con el mensaje de términos + URL del documento
→ Guarda users.terms_pending = 1
→ Inserta en terms_acceptance (estado: 'pendiente')
Usuario responde
processTermsResponse()
├─ Si respuesta contiene "acepto", "si", "sí", "ok", "1" → ACEPTA
│ → Actualiza users.terms_accepted_at, terms_version_id
│ → Actualiza terms_acceptance.estado = 'aceptado'
│ → Envía mensaje de confirmación
└─ Si respuesta contiene "no", "rechazo", "2" → RECHAZA
→ Actualiza terms_acceptance.estado = 'rechazado'
→ Envía mensaje de rechazo
```
---
## 13. Worker de mensajes programados
Un proceso cron (o worker) ejecuta cada minuto:
1. Consulta `scheduled_messages` donde `status = 'pending'` y `scheduled_date + scheduled_time <= NOW()`.
2. Para cada registro:
- Si `message_type = 'template'` → llama a `WhatsAppService::sendTemplateMessage()`.
- Si `message_type = 'text'` → llama a `WhatsAppService::sendTextMessage()`.
3. Actualiza `status = 'sent'` y registra `sent_at`.
4. Si falla → `status = 'failed'` + registra `error_message`.
---
## 14. Diagrama de clases del bot
```
WhatsAppWebhook (api/webhook.php)
├──uses──▶ WhatsAppService (services/WhatsAppService.php)
│ ├── sendTextMessage($to, $message)
│ ├── sendTemplateMessage($to, $name, $lang, $bodyParams, $headerParams, $rawComponents)
│ ├── sendInteractiveMessage($to, $body, $buttons, $header, $footer)
│ ├── sendListMessage($to, $body, $buttonText, $sections, $header, $footer)
│ ├── sendImageMessage($to, $url, $caption)
│ ├── sendVideoMessage($to, $url, $caption)
│ ├── sendAudioMessage($to, $url)
│ ├── sendDocumentMessage($to, $url, $filename, $caption)
│ ├── sendReactionMessage($to, $messageId, $emoji)
│ ├── markAsRead($messageId)
│ ├── uploadMedia($filePath, $mimeType) → retorna { id: "..." }
│ └── getMediaUrl($mediaId) → retorna URL temporal
└──uses──▶ BotService (services/BotService.php)
├──uses──▶ MenuService (services/MenuService.php)
│ └── getMenu($id), getMenuOptions($menuId)
├──uses──▶ ConversationStateService (services/ConversationStateService.php)
│ ├── getCurrentMenuId($phone)
│ ├── setCurrentMenu($phone, $menuId)
│ └── getStateData($phone, $key)
├──uses──▶ BusinessHoursService (services/BusinessHoursService.php)
│ └── isWithinBusinessHours()
└──uses──▶ NLPService (services/NLPService.php)
└── analyze($text) → detección de intenciones básica
```
---
## 15. Checklist de implementación
Para reimplementar el bot en otro sistema/lenguaje:
- [ ] Configurar la App en Meta for Developers con permisos `whatsapp_business_messaging`, `whatsapp_business_management`.
- [ ] Obtener: `Access Token`, `Phone Number ID`, `WABA ID`, `Webhook Verify Token`.
- [ ] Crear endpoint público HTTPS para el webhook (GET para verificación, POST para mensajes).
- [ ] Crear las tablas de base de datos: `users`, `conversations`, `menus`, `menu_options`, `message_templates`, `autoresponses`, `system_config`, `notifications`, `terms_versions`, `terms_acceptance`, `scheduled_messages`, `webhook_logs`.
- [ ] Implementar `WhatsAppService` con los métodos de envío (ver sección 3).
- [ ] Implementar la lógica de estados del usuario (sección 9).
- [ ] Implementar el flujo de menús dinámicos (mensajes numerados).
- [ ] Implementar el flujo de términos y condiciones (sección 12).
- [ ] Implementar sistema de advisory lock por usuario para evitar mensajes duplicados en procesamiento concurrente.
- [ ] Implementar cron/worker para mensajes programados (sección 13).
- [ ] Implementar reinicio de sesión después de 6h de inactividad.
---
## 16. Notas de seguridad
- **Nunca** expongas el `whatsapp_token` en el frontend.
- Validar siempre el `hub.verify_token` en el handshake del webhook.
- Sanear todos los campos de texto antes de almacenar en BD para evitar XSS/SQLi.
- Los mensajes duplicados se detectan por `message_id` único (`wamid.XXX`) — siempre validar antes de procesar.
- Usar advisory locks a nivel de BD para evitar race conditions cuando WhatsApp envía el mismo webhook dos veces en paralelo.
@@ -245,6 +245,50 @@
<livewire:show-saving-ip />
{{-- Módulo WhatsApp (solo admins) --}}
@if (in_array(auth()->user()->rol->nombre, ['super', 'administrador']))
<div x-data="{ openWa: false }" class="space-y-1">
<button @click="openWa = !openWa"
class="flex items-center justify-between w-full py-2 px-3 rounded text-white hover:bg-white/10 focus:outline-none">
<div class="flex items-center gap-2">
<svg class="w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347z"/><path d="M12 0C5.373 0 0 5.373 0 12c0 2.127.558 4.122 1.532 5.857L.057 23.786a.5.5 0 0 0 .629.628l5.963-1.467A11.944 11.944 0 0 0 12 24c6.627 0 12-5.373 12-12S18.627 0 12 0zm0 22c-1.88 0-3.638-.52-5.145-1.42l-.369-.22-3.818.94.974-3.782-.239-.381A9.944 9.944 0 0 1 2 12c0-5.523 4.477-10 10-10s10 4.477 10 10-4.477 10-10 10z"/>
</svg>
<span>WhatsApp</span>
</div>
<svg :class="openWa ? 'rotate-180' : ''" class="w-4 h-4 transform transition-transform" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div x-show="openWa" x-transition class="space-y-1">
<a href="{{ route('whatsapp.conversaciones') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.conversaciones') ? 'bg-white/20' : 'hover:bg-white/10' }}">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" /></svg>
<span>Conversaciones</span>
</a>
<a href="{{ route('whatsapp.menus') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.menus') ? 'bg-white/20' : 'hover:bg-white/10' }}">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12h14M5 6h14M5 18h14" /></svg>
<span>Menús del Bot</span>
</a>
<a href="{{ route('whatsapp.plantillas') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.plantillas') ? 'bg-white/20' : 'hover:bg-white/10' }}">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5.586a1 1 0 0 1 .707.293l5.414 5.414a1 1 0 0 1 .293.707V19a2 2 0 0 1-2 2z" /></svg>
<span>Plantillas</span>
</a>
<a href="{{ route('whatsapp.programados') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.programados') ? 'bg-white/20' : 'hover:bg-white/10' }}">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>
<span>Mensajes Programados</span>
</a>
<a href="{{ route('whatsapp.configuracion') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.configuracion') ? 'bg-white/20' : 'hover:bg-white/10' }}">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 0 1 0-.255c.007-.378-.138-.75-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>
<span>Configuración Bot</span>
</a>
<a href="{{ route('whatsapp.logs') }}" class="flex items-center gap-2 py-2 px-3 rounded text-white text-left {{ request()->routeIs('whatsapp.logs') ? 'bg-white/20' : 'hover:bg-white/10' }}">
<svg class="w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 0 1 0 3.75H5.625a1.875 1.875 0 0 1 0-3.75Z" /></svg>
<span>Logs Webhook</span>
</a>
</div>
</div>
@endif
<!-- Logout -->
<div>
<form method="POST" action="{{ route('logout') }}">
@@ -0,0 +1,118 @@
<div>
<div class="max-w-3xl mx-auto">
<h1 class="text-2xl font-bold text-gray-800 flex items-center gap-2 mb-6">
<svg class="w-7 h-7 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
Configuración del Bot WhatsApp
</h1>
<form wire:submit.prevent="save" class="space-y-6">
{{-- Credenciales Meta --}}
<div class="bg-white rounded-xl shadow p-6 space-y-4">
<h2 class="text-base font-semibold text-gray-700 border-b border-gray-100 pb-2">Credenciales Meta / Graph API</h2>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Token de acceso (Bearer) *</label>
<input wire:model="whatsapp_token" type="password"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400 font-mono">
@error('whatsapp_token') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Phone Number ID *</label>
<input wire:model="phone_number_id" type="text" placeholder="123456789012345"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400 font-mono">
@error('phone_number_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">URL API (Graph) *</label>
<input wire:model="whatsapp_api_url" type="url"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400 font-mono">
@error('whatsapp_api_url') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Webhook Verify Token *</label>
<input wire:model="webhook_verify_token" type="text"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400 font-mono">
@error('webhook_verify_token') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
{{-- Control del bot --}}
<div class="bg-white rounded-xl shadow p-6 space-y-4">
<h2 class="text-base font-semibold text-gray-700 border-b border-gray-100 pb-2">Control del Bot</h2>
<div class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-700">Estado del bot</label>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" wire:model="bot_enabled" value="1"
class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-green-500"></div>
<span class="ml-3 text-sm text-gray-600">{{ $bot_enabled ? 'Activo' : 'Inactivo' }}</span>
</label>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Mensaje de bienvenida *</label>
<textarea wire:model="welcome_message" rows="3"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-green-400"></textarea>
@error('welcome_message') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Respuesta cuando no entiende *</label>
<textarea wire:model="default_no_match" rows="2"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-green-400"></textarea>
@error('default_no_match') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Mensaje al solicitar asesor *</label>
<textarea wire:model="advisor_message" rows="2"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-green-400"></textarea>
@error('advisor_message') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
{{-- Horarios --}}
<div class="bg-white rounded-xl shadow p-6 space-y-4">
<h2 class="text-base font-semibold text-gray-700 border-b border-gray-100 pb-2">Horarios de Atención</h2>
<div class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-700">Validar horario</label>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" wire:model="business_hours_enabled" value="1" class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-green-500"></div>
</label>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Hora inicio</label>
<input wire:model="business_hours_start" type="time"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Hora fin</label>
<input wire:model="business_hours_end" type="time"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400">
</div>
</div>
</div>
<div class="flex justify-end">
<button type="submit"
class="bg-green-500 hover:bg-green-600 text-white px-6 py-3 rounded-lg font-semibold text-sm transition flex items-center gap-2">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
Guardar Configuración
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,166 @@
<div class="flex h-[calc(100vh-72px)] overflow-hidden bg-gray-100">
{{-- Panel izquierdo: lista de contactos --}}
<div class="w-80 flex-shrink-0 bg-white border-r border-gray-200 flex flex-col">
{{-- Header --}}
<div class="p-4 border-b border-gray-200">
<h2 class="text-lg font-bold text-gray-800 flex items-center gap-2">
<svg class="w-6 h-6 text-green-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347z"/>
<path d="M12 0C5.373 0 0 5.373 0 12c0 2.127.558 4.122 1.532 5.857L.057 23.786a.5.5 0 0 0 .629.628l5.963-1.467A11.944 11.944 0 0 0 12 24c6.627 0 12-5.373 12-12S18.627 0 12 0zm0 22c-1.88 0-3.638-.52-5.145-1.42l-.369-.22-3.818.94.974-3.782-.239-.381A9.944 9.944 0 0 1 2 12c0-5.523 4.477-10 10-10s10 4.477 10 10-4.477 10-10 10z"/>
</svg>
WhatsApp
</h2>
<div class="mt-2">
<input wire:model.live.debounce.300ms="search"
type="text"
placeholder="Buscar contacto..."
class="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-400">
</div>
</div>
{{-- Lista de usuarios --}}
<div class="flex-1 overflow-y-auto">
@forelse($users as $user)
<button wire:click="selectUser({{ $user->id }})"
class="w-full text-left px-4 py-3 border-b border-gray-100 hover:bg-gray-50 flex items-center gap-3 transition
{{ $selectedUserId === $user->id ? 'bg-green-50 border-l-4 border-l-green-500' : '' }}">
<div class="relative flex-shrink-0">
<img src="https://ui-avatars.com/api/?name={{ urlencode($user->name ?? $user->phone_number) }}&size=40&background=22c55e&color=fff"
class="w-10 h-10 rounded-full" alt="">
<span class="absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-white
{{ $user->status === 'active' ? 'bg-green-400' : ($user->status === 'blocked' ? 'bg-red-400' : 'bg-gray-400') }}">
</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex justify-between items-center">
<span class="text-sm font-semibold text-gray-800 truncate">{{ $user->name ?? $user->phone_number }}</span>
@if($user->unread_count > 0)
<span class="bg-green-500 text-white text-xs rounded-full px-2 py-0.5 ml-1">{{ $user->unread_count }}</span>
@endif
</div>
<p class="text-xs text-gray-500 truncate">
{{ $user->phone_number }}
</p>
@if($user->lastMessage)
<p class="text-xs text-gray-400 truncate">
{{ Str::limit($user->lastMessage->content, 40) }}
</p>
@endif
</div>
</button>
@empty
<div class="p-6 text-center text-gray-400 text-sm">No hay contactos</div>
@endforelse
</div>
{{-- Paginación --}}
<div class="p-2 border-t border-gray-200 text-xs">
{{ $users->links() }}
</div>
</div>
{{-- Panel derecho: chat --}}
<div class="flex-1 flex flex-col">
@if($selectedUser)
{{-- Chat header --}}
<div class="bg-white px-4 py-3 border-b border-gray-200 flex items-center justify-between shadow-sm">
<div class="flex items-center gap-3">
<img src="https://ui-avatars.com/api/?name={{ urlencode($selectedUser->name ?? $selectedUser->phone_number) }}&size=40&background=22c55e&color=fff"
class="w-10 h-10 rounded-full" alt="">
<div>
<div class="font-semibold text-gray-800">{{ $selectedUser->name ?? 'Sin nombre' }}</div>
<div class="text-xs text-gray-500">{{ $selectedUser->phone_number }}</div>
</div>
</div>
<div class="flex items-center gap-2">
@if($selectedUser->in_service)
<span class="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full">En atención</span>
@endif
@if($selectedUser->on_hold)
<span class="text-xs bg-yellow-100 text-yellow-700 px-2 py-1 rounded-full">En espera</span>
@endif
<button wire:click="toggleBlockUser"
class="text-xs px-3 py-1 rounded-full border transition
{{ $selectedUser->status === 'blocked'
? 'bg-red-100 text-red-700 border-red-200 hover:bg-red-200'
: 'bg-gray-100 text-gray-600 border-gray-200 hover:bg-gray-200' }}">
{{ $selectedUser->status === 'blocked' ? 'Desbloquear' : 'Bloquear' }}
</button>
</div>
</div>
{{-- Mensajes --}}
<div id="chat-messages" class="flex-1 overflow-y-auto p-4 space-y-2 bg-[#e5ddd5]"
x-data x-init="$nextTick(() => { let el = document.getElementById('chat-messages'); el.scrollTop = el.scrollHeight; })"
@scroll-to-bottom.window="$nextTick(() => { $el.scrollTop = $el.scrollHeight })">
@forelse($messages as $msg)
<div class="flex {{ $msg->direction === 'outgoing' ? 'justify-end' : 'justify-start' }}">
<div class="max-w-xs lg:max-w-md xl:max-w-lg {{ $msg->direction === 'outgoing' ? 'bg-[#dcf8c6]' : 'bg-white' }} rounded-lg px-3 py-2 shadow-sm">
@if($msg->message_type === 'text')
<p class="text-sm text-gray-800 whitespace-pre-wrap">{{ $msg->content }}</p>
@elseif(in_array($msg->message_type, ['image', 'video', 'document', 'audio']))
@if($msg->media_url)
@if($msg->message_type === 'image')
<img src="{{ $msg->media_url }}" class="rounded max-w-full" alt="Imagen">
@elseif($msg->message_type === 'document')
<a href="{{ $msg->media_url }}" target="_blank" class="text-blue-600 text-sm underline flex items-center gap-1">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0-3-3m3 3 3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg>
{{ $msg->filename ?? 'Documento' }}
</a>
@else
<span class="text-xs text-gray-500 italic">[{{ strtoupper($msg->message_type) }}]</span>
@endif
@else
<span class="text-xs text-gray-400 italic">[{{ strtoupper($msg->message_type) }} sin URL]</span>
@endif
@else
<p class="text-sm text-gray-600 italic">{{ $msg->content }}</p>
@endif
<div class="text-right mt-1">
<span class="text-[10px] text-gray-400">{{ $msg->created_at->format('H:i') }}</span>
@if($msg->direction === 'outgoing')
<span class="text-[10px] ml-1 {{ $msg->status === 'read' ? 'text-blue-500' : 'text-gray-400' }}">✓✓</span>
@endif
</div>
</div>
</div>
@empty
<div class="text-center text-gray-400 text-sm py-10">No hay mensajes</div>
@endforelse
</div>
{{-- Input de respuesta --}}
<div class="bg-white border-t border-gray-200 p-3">
@if($selectedUser->status === 'blocked')
<div class="text-center text-red-500 text-sm py-2">Usuario bloqueado desbloquea para responder</div>
@else
<form wire:submit.prevent="sendReply" class="flex items-end gap-2">
<textarea wire:model="replyText"
placeholder="Escribe un mensaje..."
rows="2"
class="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-green-400"
x-on:keydown.ctrl.enter="$wire.sendReply()"></textarea>
<button type="submit"
class="bg-green-500 hover:bg-green-600 text-white p-3 rounded-lg transition">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
</svg>
</button>
</form>
@error('replyText') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
@if(session('error')) <p class="text-red-500 text-xs mt-1">{{ session('error') }}</p> @endif
@endif
</div>
@else
<div class="flex-1 flex flex-col items-center justify-center text-gray-400 bg-[#e5ddd5]">
<svg class="w-20 h-20 mb-4 opacity-30" fill="currentColor" viewBox="0 0 24 24">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347z"/>
</svg>
<p class="text-lg font-medium">Selecciona una conversación</p>
<p class="text-sm">Elige un contacto de la lista para ver el chat</p>
</div>
@endif
</div>
</div>
@@ -0,0 +1,82 @@
<div>
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold text-gray-800 flex items-center gap-2">
<svg class="w-7 h-7 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
Logs del Webhook
</h1>
<button wire:click="clearAll" wire:confirm="¿Eliminar TODOS los logs? Esta acción no se puede deshacer."
class="bg-red-500 hover:bg-red-600 text-white text-sm px-4 py-2 rounded-lg flex items-center gap-2 transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Limpiar logs
</button>
</div>
<input wire:model.live.debounce.300ms="search" type="text" placeholder="Buscar en el payload..."
class="mb-4 border border-gray-300 rounded-lg px-4 py-2 text-sm w-64 focus:outline-none focus:ring-2 focus:ring-green-400">
<div class="bg-white rounded-xl shadow overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b">
<tr>
<th class="px-4 py-3 text-left text-gray-600">ID</th>
<th class="px-4 py-3 text-left text-gray-600">Fecha</th>
<th class="px-4 py-3 text-center text-gray-600">HTTP</th>
<th class="px-4 py-3 text-left text-gray-600">Payload (preview)</th>
<th class="px-4 py-3 text-center text-gray-600">Acciones</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($logs as $log)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-gray-400 text-xs">{{ $log->id }}</td>
<td class="px-4 py-3 text-gray-600 text-xs">{{ $log->created_at?->format('d/m/Y H:i:s') }}</td>
<td class="px-4 py-3 text-center">
@if($log->status_code)
<span class="text-xs px-2 py-0.5 rounded-full {{ $log->status_code < 300 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600' }}">
{{ $log->status_code }}
</span>
@else
<span class="text-gray-400"></span>
@endif
</td>
<td class="px-4 py-3 font-mono text-xs text-gray-500 max-w-md truncate">
{{ Str::limit($log->payload, 120) }}
</td>
<td class="px-4 py-3">
<div class="flex justify-center gap-2">
<button wire:click="viewLog({{ $log->id }})"
class="text-blue-500 hover:text-blue-700 transition" title="Ver detalle">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</button>
<button wire:click="deleteLog({{ $log->id }})"
class="text-red-400 hover:text-red-600 transition" title="Eliminar">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</td>
</tr>
@empty
<tr><td colspan="5" class="px-4 py-8 text-center text-gray-400">No hay logs registrados</td></tr>
@endforelse
</tbody>
</table>
<div class="p-3 border-t border-gray-100">{{ $logs->links() }}</div>
</div>
{{-- Modal detalle payload --}}
@if($viewId)
<div class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-xl w-full max-w-3xl p-6 max-h-[80vh] flex flex-col">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-gray-800">Log #{{ $viewId }} — Payload completo</h3>
<button wire:click="clearView" class="text-gray-400 hover:text-gray-700">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<pre class="overflow-auto flex-1 bg-gray-900 text-green-400 text-xs rounded-lg p-4 font-mono">{{ $viewPayload }}</pre>
</div>
</div>
@endif
</div>
@@ -0,0 +1,250 @@
<div>
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold text-gray-800 flex items-center gap-2">
<svg class="w-7 h-7 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h10"/>
</svg>
Menús del Bot
</h1>
<button wire:click="openMenuForm()"
class="bg-green-500 hover:bg-green-600 text-white text-sm px-4 py-2 rounded-lg flex items-center gap-2 transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Nuevo Menú
</button>
</div>
{{-- Buscador --}}
<input wire:model.live.debounce.300ms="search" type="text" placeholder="Buscar menú..."
class="mb-4 border border-gray-300 rounded-lg px-4 py-2 text-sm w-full max-w-xs focus:outline-none focus:ring-2 focus:ring-green-400">
{{-- Tabla de menús --}}
<div class="bg-white rounded-xl shadow overflow-hidden mb-6">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-4 py-3 text-left text-gray-600">Nombre</th>
<th class="px-4 py-3 text-left text-gray-600">Título</th>
<th class="px-4 py-3 text-center text-gray-600">Principal</th>
<th class="px-4 py-3 text-center text-gray-600">Activo</th>
<th class="px-4 py-3 text-center text-gray-600">Opciones</th>
<th class="px-4 py-3 text-center text-gray-600">Acciones</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($menus as $menu)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-800">{{ $menu->name }}</td>
<td class="px-4 py-3 text-gray-600">{{ $menu->title }}</td>
<td class="px-4 py-3 text-center">
@if($menu->is_main)
<span class="bg-green-100 text-green-700 text-xs px-2 py-0.5 rounded-full"></span>
@else
<span class="text-gray-400"></span>
@endif
</td>
<td class="px-4 py-3 text-center">
<span class="text-xs px-2 py-0.5 rounded-full {{ $menu->is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600' }}">
{{ $menu->is_active ? 'Activo' : 'Inactivo' }}
</span>
</td>
<td class="px-4 py-3 text-center">
<button wire:click="$set('viewMenuId', {{ $menu->id }})"
class="text-blue-500 hover:underline text-xs">
{{ $menu->options_count }} opcs.
</button>
</td>
<td class="px-4 py-3">
<div class="flex justify-center gap-2">
<button wire:click="openMenuForm({{ $menu->id }})"
class="text-yellow-500 hover:text-yellow-700 transition" title="Editar">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
<button wire:click="openOptionForm(null, {{ $menu->id }})"
class="text-green-500 hover:text-green-700 transition" title="Agregar opción">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
</button>
<button wire:click="deleteMenu({{ $menu->id }})"
wire:confirm="¿Eliminar este menú y todas sus opciones?"
class="text-red-400 hover:text-red-600 transition" title="Eliminar">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</td>
</tr>
@empty
<tr><td colspan="6" class="px-4 py-8 text-center text-gray-400">No hay menús creados</td></tr>
@endforelse
</tbody>
</table>
</div>
{{-- Detalle opciones del menú seleccionado --}}
@if($viewMenu)
<div class="bg-white rounded-xl shadow p-4 mb-6">
<div class="flex items-center justify-between mb-3">
<h3 class="font-bold text-gray-800">Opciones del menú: <span class="text-green-600">{{ $viewMenu->name }}</span></h3>
<div class="flex gap-2">
<button wire:click="openOptionForm(null, {{ $viewMenu->id }})"
class="text-sm bg-green-500 hover:bg-green-600 text-white px-3 py-1 rounded-lg flex items-center gap-1">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Agregar opción
</button>
<button wire:click="$set('viewMenuId', null)" class="text-sm text-gray-500 hover:text-gray-700">Cerrar</button>
</div>
</div>
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b">
<tr>
<th class="px-3 py-2 text-left text-gray-600">#</th>
<th class="px-3 py-2 text-left text-gray-600">Título</th>
<th class="px-3 py-2 text-left text-gray-600">Acción</th>
<th class="px-3 py-2 text-left text-gray-600">Valor</th>
<th class="px-3 py-2 text-center text-gray-600">Activa</th>
<th class="px-3 py-2 text-center text-gray-600">Ops</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($viewMenu->options as $opt)
<tr>
<td class="px-3 py-2 font-bold text-gray-700">{{ $opt->option_number }}</td>
<td class="px-3 py-2">{{ $opt->title }}</td>
<td class="px-3 py-2">
<span class="bg-blue-50 text-blue-700 text-xs px-2 py-0.5 rounded">{{ $opt->action_type }}</span>
</td>
<td class="px-3 py-2 text-gray-500 max-w-xs truncate">{{ $opt->action_value }}</td>
<td class="px-3 py-2 text-center">
<span class="text-xs {{ $opt->is_active ? 'text-green-600' : 'text-red-400' }}">{{ $opt->is_active ? '✓' : '✗' }}</span>
</td>
<td class="px-3 py-2">
<div class="flex justify-center gap-2">
<button wire:click="openOptionForm({{ $opt->id }})" class="text-yellow-500 hover:text-yellow-700">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
<button wire:click="deleteOption({{ $opt->id }})" wire:confirm="¿Eliminar esta opción?"
class="text-red-400 hover:text-red-600">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</td>
</tr>
@empty
<tr><td colspan="6" class="px-3 py-4 text-center text-gray-400">Sin opciones</td></tr>
@endforelse
</tbody>
</table>
</div>
@endif
{{-- Modal: Formulario Menú --}}
@if($showMenuForm)
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-xl w-full max-w-lg p-6">
<h3 class="text-lg font-bold text-gray-800 mb-4">{{ $editMenuId ? 'Editar Menú' : 'Nuevo Menú' }}</h3>
<form wire:submit.prevent="saveMenu" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Nombre interno *</label>
<input wire:model="menuName" type="text" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400">
@error('menuName') <p class="text-red-500 text-xs">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Título visible *</label>
<input wire:model="menuTitle" type="text" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400">
@error('menuTitle') <p class="text-red-500 text-xs">{{ $message }}</p> @enderror
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Mensaje del menú *</label>
<textarea wire:model="menuMessage" rows="4"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400 resize-none"></textarea>
@error('menuMessage') <p class="text-red-500 text-xs">{{ $message }}</p> @enderror
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Menú padre</label>
<select wire:model="menuParentId" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none">
<option value=""> Ninguno </option>
@foreach($allMenus as $m)
@if($m->id !== $editMenuId)
<option value="{{ $m->id }}">{{ $m->name }}</option>
@endif
@endforeach
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Orden</label>
<input wire:model="menuSortOrder" type="number" min="0"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none">
</div>
</div>
<div class="flex gap-4">
<label class="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
<input wire:model="menuIsMain" type="checkbox" class="rounded"> Es menú principal
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
<input wire:model="menuIsActive" type="checkbox" class="rounded"> Activo
</label>
</div>
<div class="flex justify-end gap-3 pt-2 border-t border-gray-100">
<button type="button" wire:click="resetMenuForm()" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">Cancelar</button>
<button type="submit" class="bg-green-500 hover:bg-green-600 text-white text-sm px-4 py-2 rounded-lg">Guardar</button>
</div>
</form>
</div>
</div>
@endif
{{-- Modal: Formulario Opción --}}
@if($showOptionForm)
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-xl w-full max-w-lg p-6">
<h3 class="text-lg font-bold text-gray-800 mb-4">{{ $editOptionId ? 'Editar Opción' : 'Nueva Opción' }}</h3>
<form wire:submit.prevent="saveOption" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Número de opción *</label>
<input wire:model="optionNumber" type="number" min="1"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Título *</label>
<input wire:model="optionTitle" type="text"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400">
@error('optionTitle') <p class="text-red-500 text-xs">{{ $message }}</p> @enderror
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Tipo de acción</label>
<select wire:model="optionAction" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none">
<option value="message">Mensaje</option>
<option value="submenu">Submenú</option>
<option value="template">Plantilla</option>
<option value="url">URL</option>
<option value="advisor">Asesor</option>
<option value="flow">Flujo</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Orden</label>
<input wire:model="optionSort" type="number" min="0"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none">
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Valor de la acción</label>
<input wire:model="optionValue" type="text" placeholder="ID de submenú, texto, URL..."
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400">
</div>
<label class="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
<input wire:model="optionIsActive" type="checkbox" class="rounded"> Opción activa
</label>
<div class="flex justify-end gap-3 pt-2 border-t border-gray-100">
<button type="button" wire:click="resetOptionForm()" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">Cancelar</button>
<button type="submit" class="bg-green-500 hover:bg-green-600 text-white text-sm px-4 py-2 rounded-lg">Guardar</button>
</div>
</form>
</div>
</div>
@endif
</div>
@@ -0,0 +1,141 @@
<div>
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold text-gray-800 flex items-center gap-2">
<svg class="w-7 h-7 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
Plantillas de Mensaje
</h1>
<button wire:click="openForm()" class="bg-green-500 hover:bg-green-600 text-white text-sm px-4 py-2 rounded-lg flex items-center gap-2 transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Nueva Plantilla
</button>
</div>
<div class="flex gap-3 mb-4 flex-wrap">
<input wire:model.live.debounce.300ms="search" type="text" placeholder="Buscar plantilla..."
class="border border-gray-300 rounded-lg px-4 py-2 text-sm w-64 focus:outline-none focus:ring-2 focus:ring-green-400">
<select wire:model.live="filterStatus" class="border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none">
<option value=""> Todos los estados </option>
<option value="pending">Pendiente</option>
<option value="approved">Aprobada</option>
<option value="rejected">Rechazada</option>
<option value="paused">Pausada</option>
</select>
</div>
<div class="bg-white rounded-xl shadow overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b">
<tr>
<th class="px-4 py-3 text-left text-gray-600">Nombre</th>
<th class="px-4 py-3 text-left text-gray-600">Template (Meta)</th>
<th class="px-4 py-3 text-left text-gray-600">Idioma</th>
<th class="px-4 py-3 text-left text-gray-600">Header</th>
<th class="px-4 py-3 text-center text-gray-600">Estado</th>
<th class="px-4 py-3 text-center text-gray-600">Acciones</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($templates as $tpl)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-800">{{ $tpl->name }}</td>
<td class="px-4 py-3 font-mono text-xs text-gray-600">{{ $tpl->template_name }}</td>
<td class="px-4 py-3 uppercase text-gray-500 text-xs">{{ $tpl->language_code }}</td>
<td class="px-4 py-3 text-gray-500 text-xs capitalize">{{ $tpl->header_type }}</td>
<td class="px-4 py-3 text-center">
@php
$colors = ['approved'=>'green','pending'=>'yellow','rejected'=>'red','paused'=>'gray'];
$c = $colors[$tpl->status] ?? 'gray';
@endphp
<span class="text-xs px-2 py-0.5 rounded-full bg-{{ $c }}-100 text-{{ $c }}-700 capitalize">{{ $tpl->status }}</span>
</td>
<td class="px-4 py-3">
<div class="flex justify-center gap-2">
<button wire:click="openForm({{ $tpl->id }})" class="text-yellow-500 hover:text-yellow-700 transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
<button wire:click="delete({{ $tpl->id }})" wire:confirm="¿Eliminar esta plantilla?" class="text-red-400 hover:text-red-600 transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</td>
</tr>
@empty
<tr><td colspan="6" class="px-4 py-8 text-center text-gray-400">No hay plantillas registradas</td></tr>
@endforelse
</tbody>
</table>
<div class="p-3 border-t border-gray-100">{{ $templates->links() }}</div>
</div>
{{-- Modal Formulario --}}
@if($showForm)
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-xl w-full max-w-lg p-6 max-h-screen overflow-y-auto">
<h3 class="text-lg font-bold text-gray-800 mb-4">{{ $editId ? 'Editar Plantilla' : 'Nueva Plantilla' }}</h3>
<form wire:submit.prevent="save" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Nombre interno *</label>
<input wire:model="name" type="text" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400">
@error('name') <p class="text-red-500 text-xs">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Nombre en Meta *</label>
<input wire:model="templateName" type="text" placeholder="solo_minusculas_sin_espacios"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400">
@error('templateName') <p class="text-red-500 text-xs">{{ $message }}</p> @enderror
</div>
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Idioma</label>
<input wire:model="languageCode" type="text" placeholder="es"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Estado</label>
<select wire:model="status" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none">
<option value="pending">Pendiente</option>
<option value="approved">Aprobada</option>
<option value="rejected">Rechazada</option>
<option value="paused">Pausada</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Tipo header</label>
<select wire:model="headerType" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none">
<option value="none">Ninguno</option>
<option value="text">Texto</option>
<option value="image">Imagen</option>
<option value="video">Video</option>
<option value="document">Documento</option>
</select>
</div>
</div>
@if($headerType === 'text')
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Texto del header</label>
<input wire:model="headerText" type="text" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none">
</div>
@endif
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Cuerpo del mensaje *</label>
<textarea wire:model="bodyText" rows="4" placeholder="Hola {{nombre}}, tu pedido {{codigo}} está listo."
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-green-400"></textarea>
@error('bodyText') <p class="text-red-500 text-xs">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Pie de página</label>
<input wire:model="footerText" type="text" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none">
</div>
<div class="flex justify-end gap-3 pt-2 border-t border-gray-100">
<button type="button" wire:click="resetForm()" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">Cancelar</button>
<button type="submit" class="bg-green-500 hover:bg-green-600 text-white text-sm px-4 py-2 rounded-lg">Guardar</button>
</div>
</form>
</div>
</div>
@endif
</div>
@@ -0,0 +1,142 @@
<div>
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold text-gray-800 flex items-center gap-2">
<svg class="w-7 h-7 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Mensajes Programados
</h1>
<button wire:click="$set('showForm', true)"
class="bg-green-500 hover:bg-green-600 text-white text-sm px-4 py-2 rounded-lg flex items-center gap-2 transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Programar Mensaje
</button>
</div>
<div class="flex gap-3 mb-4 flex-wrap">
<input wire:model.live.debounce.300ms="search" type="text" placeholder="Buscar por teléfono..."
class="border border-gray-300 rounded-lg px-4 py-2 text-sm w-64 focus:outline-none focus:ring-2 focus:ring-green-400">
<select wire:model.live="filterStatus" class="border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none">
<option value=""> Todos </option>
<option value="pending">Pendiente</option>
<option value="sent">Enviado</option>
<option value="failed">Fallido</option>
<option value="cancelled">Cancelado</option>
</select>
</div>
<div class="bg-white rounded-xl shadow overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b">
<tr>
<th class="px-4 py-3 text-left text-gray-600">Teléfono</th>
<th class="px-4 py-3 text-left text-gray-600">Tipo</th>
<th class="px-4 py-3 text-left text-gray-600">Contenido / Plantilla</th>
<th class="px-4 py-3 text-left text-gray-600">Fecha programada</th>
<th class="px-4 py-3 text-center text-gray-600">Estado</th>
<th class="px-4 py-3 text-center text-gray-600">Acciones</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($messages as $msg)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-mono text-xs">{{ $msg->phone_number }}</td>
<td class="px-4 py-3">
<span class="text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded uppercase">{{ $msg->message_type }}</span>
</td>
<td class="px-4 py-3 text-gray-600 max-w-xs truncate">
{{ $msg->message_type === 'text' ? Str::limit($msg->message_content, 50) : $msg->template_name }}
</td>
<td class="px-4 py-3 text-gray-600">
{{ \Carbon\Carbon::parse($msg->scheduled_date)->format('d/m/Y') }}
{{ $msg->scheduled_time }}
</td>
<td class="px-4 py-3 text-center">
@php
$colors = ['pending'=>'yellow','sent'=>'green','failed'=>'red','cancelled'=>'gray'];
$c = $colors[$msg->status] ?? 'gray';
@endphp
<span class="text-xs px-2 py-0.5 rounded-full bg-{{ $c }}-100 text-{{ $c }}-700 capitalize">{{ $msg->status }}</span>
</td>
<td class="px-4 py-3">
<div class="flex justify-center gap-2">
@if($msg->status === 'pending')
<button wire:click="cancel({{ $msg->id }})" wire:confirm="¿Cancelar este mensaje?"
class="text-yellow-500 hover:text-yellow-700 text-xs">Cancelar</button>
@endif
<button wire:click="delete({{ $msg->id }})" wire:confirm="¿Eliminar este registro?"
class="text-red-400 hover:text-red-600 transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</td>
</tr>
@empty
<tr><td colspan="6" class="px-4 py-8 text-center text-gray-400">No hay mensajes programados</td></tr>
@endforelse
</tbody>
</table>
<div class="p-3 border-t border-gray-100">{{ $messages->links() }}</div>
</div>
{{-- Modal formulario --}}
@if($showForm)
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-xl w-full max-w-md p-6">
<h3 class="text-lg font-bold text-gray-800 mb-4">Programar Mensaje</h3>
<form wire:submit.prevent="save" class="space-y-4">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Número de teléfono *</label>
<input wire:model="phoneNumber" type="text" placeholder="573001234567"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400">
@error('phoneNumber') <p class="text-red-500 text-xs">{{ $message }}</p> @enderror
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Fecha *</label>
<input wire:model="scheduledDate" type="date"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400">
@error('scheduledDate') <p class="text-red-500 text-xs">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Hora *</label>
<input wire:model="scheduledTime" type="time"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-400">
@error('scheduledTime') <p class="text-red-500 text-xs">{{ $message }}</p> @enderror
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Tipo de mensaje</label>
<select wire:model.live="messageType" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none">
<option value="text">Texto libre</option>
<option value="template">Plantilla</option>
</select>
</div>
@if($messageType === 'text')
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Mensaje *</label>
<textarea wire:model="messageContent" rows="3"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-green-400"></textarea>
@error('messageContent') <p class="text-red-500 text-xs">{{ $message }}</p> @enderror
</div>
@else
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Plantilla *</label>
<select wire:model="templateId" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none">
<option value=""> Seleccionar </option>
@foreach($templates as $tpl)
<option value="{{ $tpl->id }}">{{ $tpl->name }} ({{ $tpl->template_name }})</option>
@endforeach
</select>
@error('templateId') <p class="text-red-500 text-xs">{{ $message }}</p> @enderror
</div>
@endif
<div class="flex justify-end gap-3 pt-2 border-t border-gray-100">
<button type="button" wire:click="resetForm()" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">Cancelar</button>
<button type="submit" class="bg-green-500 hover:bg-green-600 text-white text-sm px-4 py-2 rounded-lg">Programar</button>
</div>
</form>
</div>
</div>
@endif
</div>
@@ -0,0 +1,6 @@
<x-app-layout>
<div class="p-4">
<livewire:whatsapp.show-configuracion-bot />
</div>
@include('layouts.footer')
</x-app-layout>
@@ -0,0 +1,3 @@
<x-app-layout>
<livewire:whatsapp.show-conversaciones />
</x-app-layout>
+6
View File
@@ -0,0 +1,6 @@
<x-app-layout>
<div class="p-4">
<livewire:whatsapp.show-logs-webhook />
</div>
@include('layouts.footer')
</x-app-layout>
+6
View File
@@ -0,0 +1,6 @@
<x-app-layout>
<div class="p-4">
<livewire:whatsapp.show-menus-bot />
</div>
@include('layouts.footer')
</x-app-layout>
@@ -0,0 +1,6 @@
<x-app-layout>
<div class="p-4">
<livewire:whatsapp.show-plantillas />
</div>
@include('layouts.footer')
</x-app-layout>
@@ -0,0 +1,6 @@
<x-app-layout>
<div class="p-4">
<livewire:whatsapp.show-programados />
</div>
@include('layouts.footer')
</x-app-layout>
+11
View File
@@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\MenuController;
use App\Http\Controllers\WhatsappController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
@@ -71,6 +72,16 @@ Route::middleware(["auth", "solo_usuario_administrador"])->group(function () {
Route::any('/log-historial', [MenuController::class, 'showLogHistorial'])->name('logHistorial'); Route::get('/diagnostico', [MenuController::class, 'showDiagnostico'])->name('diagnostico'); Route::get('/utilidades', [MenuController::class, 'showutilidad'])->name('utilidades');
Route::get('/mantenimiento', [MenuController::class, 'mantenimiento'])->name('mantenimiento');
Route::get('/rank-management', [MenuController::class, 'showgestionrangos'])->name('gestion-rangos');
// Módulo WhatsApp
Route::prefix('whatsapp')->name('whatsapp.')->group(function () {
Route::get('/conversaciones', [WhatsappController::class, 'conversaciones'])->name('conversaciones');
Route::get('/menus', [WhatsappController::class, 'menus'])->name('menus');
Route::get('/plantillas', [WhatsappController::class, 'plantillas'])->name('plantillas');
Route::get('/programados', [WhatsappController::class, 'programados'])->name('programados');
Route::get('/configuracion', [WhatsappController::class, 'configuracion'])->name('configuracion');
Route::get('/logs', [WhatsappController::class, 'logs'])->name('logs');
});
});
Route::post('/registrar-accion-compra', [MenuController::class, 'click_compra'])->name('click_compra');