feat: Telegram transactional flows + role pricing + services slider
- New TelegramBotService: full OTP auth, registration, main menu with inline keyboards, buy plans, buy promos, recharge (MP + Bre-B), credentials, history, profile, agent transfer, photo receipt analysis - TelegramWebhookController: now handles text, callback_query and photo - Role-based pricing: tarifas filtered by user rol_id in both web chat and Telegram; Preferencial price overrides base tarifa valor - Services and promos now render as horizontal scroll slider (cards_slider tipo_ui) instead of stacked individual cards Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
a3bd91c204
commit
2fc2aba8a3
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\ChatBotEngine;
|
||||
use App\Services\TelegramBotService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TelegramWebhookController extends Controller
|
||||
@@ -10,24 +10,35 @@ class TelegramWebhookController extends Controller
|
||||
public function handle(Request $request)
|
||||
{
|
||||
$update = $request->all();
|
||||
$bot = new TelegramBotService();
|
||||
|
||||
// Solo procesar mensajes de texto
|
||||
if (! isset($update['message']['text'])) {
|
||||
// Inline keyboard button taps
|
||||
if (isset($update['callback_query'])) {
|
||||
$cq = $update['callback_query'];
|
||||
$chatId = (string) $cq['message']['chat']['id'];
|
||||
$bot->handleCallback($chatId, $cq['id'], $cq['data'] ?? '');
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
$chatId = (string) $update['message']['chat']['id'];
|
||||
$text = $update['message']['text'];
|
||||
$nombre = trim(
|
||||
($update['message']['from']['first_name'] ?? '') . ' ' .
|
||||
($update['message']['from']['last_name'] ?? '')
|
||||
);
|
||||
// Photos (payment receipts)
|
||||
if (isset($update['message']['photo'])) {
|
||||
$chatId = (string) $update['message']['chat']['id'];
|
||||
$photos = $update['message']['photo'];
|
||||
$fileId = end($photos)['file_id'];
|
||||
$bot->handlePhoto($chatId, $fileId);
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
$engine = new ChatBotEngine();
|
||||
$replies = $engine->handle('telegram', $chatId, $text, $nombre);
|
||||
|
||||
foreach ($replies as $reply) {
|
||||
$engine->enviarTelegram($chatId, $reply);
|
||||
// Text messages
|
||||
if (isset($update['message']['text'])) {
|
||||
$chatId = (string) $update['message']['chat']['id'];
|
||||
$text = $update['message']['text'];
|
||||
$nombre = trim(
|
||||
($update['message']['from']['first_name'] ?? '') . ' ' .
|
||||
($update['message']['from']['last_name'] ?? '')
|
||||
);
|
||||
$bot->handleText($chatId, $text, $nombre);
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Models\ChatConversation;
|
||||
use App\Models\ChatMessage;
|
||||
use App\Models\Historiale;
|
||||
use App\Models\Historial_cuenta;
|
||||
use App\Models\Preferencial;
|
||||
use App\Models\Promociones;
|
||||
use App\Models\recarga;
|
||||
use App\Models\Saldo;
|
||||
@@ -495,7 +496,11 @@ class PublicChat extends Component
|
||||
|
||||
private function listarServicios(): void
|
||||
{
|
||||
$servicios = Servicio::where('estado', 'activo')->orderBy('ubicacion')->get();
|
||||
$rolId = $this->userId ? User::find($this->userId)?->rol_id : Role::where('nombre', 'cliente')->value('id');
|
||||
$servicios = Servicio::where('estado', 'activo')
|
||||
->whereHas('tarifas', fn ($q) => $q->where('estado', 'activo')->where('rol_id', $rolId))
|
||||
->orderBy('ubicacion')
|
||||
->get();
|
||||
|
||||
if ($servicios->isEmpty()) {
|
||||
$this->guardarMensajeBot($this->convId, "No hay servicios disponibles en este momento.");
|
||||
@@ -503,36 +508,36 @@ class PublicChat extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$this->guardarMensajeBot($this->convId, "Selecciona el servicio:");
|
||||
$cards = $servicios->map(fn ($s) => [
|
||||
'imagen' => $s->img_inicio,
|
||||
'titulo' => $s->nombre,
|
||||
'detalle' => $s->por_tiempo ? 'Por tiempo' : 'Por pantallas',
|
||||
'accion' => ['label' => 'Ver planes', 'action' => 'servicios.ver', 'data' => ['id' => $s->id]],
|
||||
])->values()->all();
|
||||
|
||||
foreach ($servicios as $s) {
|
||||
ChatMessage::create([
|
||||
'conversation_id' => $this->convId,
|
||||
'tipo' => 'bot',
|
||||
'tipo_ui' => 'card',
|
||||
'contenido' => '',
|
||||
'payload' => [
|
||||
'imagen' => $s->img_inicio,
|
||||
'titulo' => $s->nombre,
|
||||
'detalle' => $s->por_tiempo ? 'Por tiempo' : 'Por pantallas',
|
||||
'accion' => ['label' => 'Ver planes', 'action' => 'servicios.ver', 'data' => ['id' => $s->id]],
|
||||
],
|
||||
'leido' => true,
|
||||
]);
|
||||
}
|
||||
ChatMessage::create([
|
||||
'conversation_id' => $this->convId,
|
||||
'tipo' => 'bot',
|
||||
'tipo_ui' => 'cards_slider',
|
||||
'contenido' => 'Selecciona el servicio:',
|
||||
'payload' => ['cards' => $cards],
|
||||
'leido' => true,
|
||||
]);
|
||||
|
||||
$this->volver('menu.principal');
|
||||
}
|
||||
|
||||
private function verServicio(int $id): void
|
||||
{
|
||||
$servicio = Servicio::with('tarifas')->find($id);
|
||||
$rolId = $this->userId ? User::find($this->userId)?->rol_id : Role::where('nombre', 'cliente')->value('id');
|
||||
$servicio = Servicio::with(['tarifas' => fn ($q) => $q->where('estado', 'activo')->where('rol_id', $rolId)])->find($id);
|
||||
|
||||
if (! $servicio) {
|
||||
$this->guardarMensajeBot($this->convId, "Servicio no encontrado.");
|
||||
return;
|
||||
}
|
||||
|
||||
$tarifas = $servicio->tarifas->where('estado', 'activo');
|
||||
$tarifas = $servicio->tarifas;
|
||||
|
||||
if ($tarifas->isEmpty()) {
|
||||
$this->guardarMensajeBot($this->convId, "No hay planes para {$servicio->nombre} en este momento.");
|
||||
@@ -542,9 +547,10 @@ class PublicChat extends Component
|
||||
|
||||
$botones = [];
|
||||
foreach ($tarifas as $t) {
|
||||
$label = $servicio->por_tiempo
|
||||
? "{$t->dias} dias - $" . number_format($t->valor)
|
||||
: "{$t->pantallas} pantalla(s) / {$t->dias} dias - $" . number_format($t->valor);
|
||||
$precio = $this->efectivePrice($t->id, $this->userId, $t->valor);
|
||||
$label = $servicio->por_tiempo
|
||||
? "{$t->dias} dias - $" . number_format($precio)
|
||||
: "{$t->pantallas} pantalla(s) / {$t->dias} dias - $" . number_format($precio);
|
||||
$botones[] = ['label' => $label, 'action' => 'compra.iniciar', 'data' => ['tarifa_id' => $t->id]];
|
||||
}
|
||||
$botones[] = ['label' => 'Volver', 'action' => 'servicios.listar', 'data' => []];
|
||||
@@ -578,10 +584,12 @@ class PublicChat extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$precio = $this->efectivePrice($tarifaId, $this->userId, $tarifa->valor);
|
||||
|
||||
$this->flujo = 'compra';
|
||||
$this->flujoData = [
|
||||
'tarifa_id' => $tarifaId,
|
||||
'valor' => $tarifa->valor,
|
||||
'valor' => $precio,
|
||||
'servicio' => $tarifa->servicio->nombre,
|
||||
'dias' => $tarifa->dias,
|
||||
'pantallas' => $tarifa->pantallas,
|
||||
@@ -590,15 +598,15 @@ class PublicChat extends Component
|
||||
$detalle = $tarifa->servicio->por_tiempo
|
||||
? "{$tarifa->dias} dias"
|
||||
: "{$tarifa->pantallas} pantalla(s) / {$tarifa->dias} dias";
|
||||
$saldoInsuficiente = ! $this->userId || ($this->saldoUsuario < $tarifa->valor);
|
||||
$saldoInsuficiente = ! $this->userId || ($this->saldoUsuario < $precio);
|
||||
|
||||
ChatMessage::create([
|
||||
'conversation_id' => $this->convId,
|
||||
'tipo' => 'bot',
|
||||
'tipo_ui' => 'buttons',
|
||||
'contenido' => "{$tarifa->servicio->nombre} - {$detalle}\nPrecio: $" . number_format($tarifa->valor) . "\n\nElige como pagar:",
|
||||
'contenido' => "{$tarifa->servicio->nombre} - {$detalle}\nPrecio: $" . number_format($precio) . "\n\nElige como pagar:",
|
||||
'payload' => ['botones' => [
|
||||
['label' => 'Saldo ($' . number_format($tarifa->valor) . ')',
|
||||
['label' => 'Saldo ($' . number_format($precio) . ')',
|
||||
'action' => 'compra.pagar.saldo',
|
||||
'data' => [],
|
||||
'disabled' => $saldoInsuficiente],
|
||||
@@ -716,7 +724,7 @@ class PublicChat extends Component
|
||||
private function listarPromociones(): void
|
||||
{
|
||||
$promos = Promociones::where('visible', true)
|
||||
->where(fn($q) => $q->whereNull('fecha_limite')->orWhere('fecha_limite', '>=', now()))
|
||||
->where(fn ($q) => $q->whereNull('fecha_limite')->orWhere('fecha_limite', '>=', now()))
|
||||
->get();
|
||||
|
||||
if ($promos->isEmpty()) {
|
||||
@@ -725,25 +733,23 @@ class PublicChat extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$this->guardarMensajeBot($this->convId, "Promociones disponibles:");
|
||||
$cards = $promos->map(fn ($p) => [
|
||||
'imagen' => $p->img_publicidad,
|
||||
'titulo' => $p->nombre ?? 'Promocion especial',
|
||||
'descripcion' => $p->descripcion ?? null,
|
||||
'precio' => $p->precio,
|
||||
'detalle' => $p->fecha_limite ? 'Hasta ' . Carbon::parse($p->fecha_limite)->format('d/m/Y') : null,
|
||||
'accion' => ['label' => 'Comprar', 'action' => 'promo.ver', 'data' => ['id' => $p->id]],
|
||||
])->values()->all();
|
||||
|
||||
foreach ($promos as $p) {
|
||||
ChatMessage::create([
|
||||
'conversation_id' => $this->convId,
|
||||
'tipo' => 'bot',
|
||||
'tipo_ui' => 'card',
|
||||
'contenido' => '',
|
||||
'payload' => [
|
||||
'imagen' => $p->img_publicidad,
|
||||
'titulo' => $p->nombre ?? 'Promocion especial',
|
||||
'descripcion' => $p->descripcion ?? null,
|
||||
'precio' => $p->precio,
|
||||
'detalle' => $p->fecha_limite ? 'Hasta ' . Carbon::parse($p->fecha_limite)->format('d/m/Y') : null,
|
||||
'accion' => ['label' => 'Comprar', 'action' => 'promo.ver', 'data' => ['id' => $p->id]],
|
||||
],
|
||||
'leido' => true,
|
||||
]);
|
||||
}
|
||||
ChatMessage::create([
|
||||
'conversation_id' => $this->convId,
|
||||
'tipo' => 'bot',
|
||||
'tipo_ui' => 'cards_slider',
|
||||
'contenido' => 'Promociones disponibles:',
|
||||
'payload' => ['cards' => $cards],
|
||||
'leido' => true,
|
||||
]);
|
||||
|
||||
$this->volver('menu.principal');
|
||||
}
|
||||
@@ -1233,6 +1239,17 @@ class PublicChat extends Component
|
||||
]);
|
||||
}
|
||||
|
||||
private function efectivePrice(int $tarifaId, ?int $userId, float $base): float
|
||||
{
|
||||
if ($userId) {
|
||||
$pref = Preferencial::where('usuario_id', $userId)->where('tarifa_id', $tarifaId)->first();
|
||||
if ($pref) {
|
||||
return (float) $pref->valor;
|
||||
}
|
||||
}
|
||||
return (float) $base;
|
||||
}
|
||||
|
||||
private function volver(string $action): void
|
||||
{
|
||||
ChatMessage::create([
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -292,6 +292,53 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- CARDS SLIDER: servicios / promos en carrusel horizontal --}}
|
||||
@elseif ($tipoUi === 'cards_slider')
|
||||
<div class="w-full max-w-full space-y-2">
|
||||
@if ($msg['contenido'])
|
||||
<div class="bg-[#202c33] text-white rounded-xl rounded-tl-sm px-4 py-2 shadow inline-block max-w-[80%]">
|
||||
<p class="text-sm">{{ $msg['contenido'] }}</p>
|
||||
<span class="text-[#8696a0] text-[11px] float-right mt-1 ml-2">{{ $msg['created_at'] }}</span>
|
||||
</div>
|
||||
@endif
|
||||
{{-- Scroll horizontal de cards --}}
|
||||
<div class="flex gap-3 overflow-x-auto pb-2"
|
||||
style="-webkit-overflow-scrolling:touch; scroll-snap-type:x mandatory; scrollbar-width:none;">
|
||||
@foreach ($payload['cards'] ?? [] as $card)
|
||||
<div class="flex-shrink-0 bg-[#202c33] rounded-xl shadow overflow-hidden"
|
||||
style="width:180px; scroll-snap-align:start;">
|
||||
@if (!empty($card['imagen']))
|
||||
<img src="{{ $card['imagen'] }}" class="w-full object-cover" style="height:100px;">
|
||||
@else
|
||||
<div class="w-full bg-[#2a3942] flex items-center justify-center" style="height:80px;">
|
||||
<svg class="w-8 h-8 text-[#8696a0]" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
|
||||
</svg>
|
||||
</div>
|
||||
@endif
|
||||
<div class="px-3 py-2.5">
|
||||
<p class="text-white text-sm font-semibold truncate">{{ $card['titulo'] ?? '' }}</p>
|
||||
@if (!empty($card['precio']))
|
||||
<p class="text-[#00a884] font-bold text-sm mt-0.5">${{ number_format($card['precio']) }}</p>
|
||||
@endif
|
||||
@if (!empty($card['detalle']))
|
||||
<p class="text-[#8696a0] text-xs mt-0.5 truncate">{{ $card['detalle'] }}</p>
|
||||
@endif
|
||||
@if (!empty($card['descripcion']))
|
||||
<p class="text-[#8696a0] text-xs mt-0.5 line-clamp-2">{{ $card['descripcion'] }}</p>
|
||||
@endif
|
||||
@if (!empty($card['accion']))
|
||||
<button wire:click="clickBoton('{{ $card['accion']['action'] }}', {{ json_encode($card['accion']['data'] ?? []) }})"
|
||||
class="mt-2.5 w-full bg-[#00a884] text-white text-xs font-semibold rounded-lg py-1.5 active:bg-[#06cf9c] transition">
|
||||
{{ $card['accion']['label'] }}
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- LINK: botón de pago externo --}}
|
||||
@elseif ($tipoUi === 'link')
|
||||
<div class="max-w-[85%] bg-[#202c33] rounded-xl rounded-tl-sm px-4 py-3 shadow">
|
||||
|
||||
Reference in New Issue
Block a user