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:
Lizandro
2026-07-14 04:36:03 +00:00
co-authored by Claude Sonnet 4.6
parent a3bd91c204
commit 2fc2aba8a3
4 changed files with 1262 additions and 59 deletions
@@ -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]);
+62 -45
View File
@@ -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">