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;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Services\ChatBotEngine;
|
use App\Services\TelegramBotService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class TelegramWebhookController extends Controller
|
class TelegramWebhookController extends Controller
|
||||||
@@ -10,24 +10,35 @@ class TelegramWebhookController extends Controller
|
|||||||
public function handle(Request $request)
|
public function handle(Request $request)
|
||||||
{
|
{
|
||||||
$update = $request->all();
|
$update = $request->all();
|
||||||
|
$bot = new TelegramBotService();
|
||||||
|
|
||||||
// Solo procesar mensajes de texto
|
// Inline keyboard button taps
|
||||||
if (! isset($update['message']['text'])) {
|
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]);
|
return response()->json(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$chatId = (string) $update['message']['chat']['id'];
|
// Photos (payment receipts)
|
||||||
$text = $update['message']['text'];
|
if (isset($update['message']['photo'])) {
|
||||||
$nombre = trim(
|
$chatId = (string) $update['message']['chat']['id'];
|
||||||
($update['message']['from']['first_name'] ?? '') . ' ' .
|
$photos = $update['message']['photo'];
|
||||||
($update['message']['from']['last_name'] ?? '')
|
$fileId = end($photos)['file_id'];
|
||||||
);
|
$bot->handlePhoto($chatId, $fileId);
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
$engine = new ChatBotEngine();
|
// Text messages
|
||||||
$replies = $engine->handle('telegram', $chatId, $text, $nombre);
|
if (isset($update['message']['text'])) {
|
||||||
|
$chatId = (string) $update['message']['chat']['id'];
|
||||||
foreach ($replies as $reply) {
|
$text = $update['message']['text'];
|
||||||
$engine->enviarTelegram($chatId, $reply);
|
$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]);
|
return response()->json(['ok' => true]);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Models\ChatConversation;
|
|||||||
use App\Models\ChatMessage;
|
use App\Models\ChatMessage;
|
||||||
use App\Models\Historiale;
|
use App\Models\Historiale;
|
||||||
use App\Models\Historial_cuenta;
|
use App\Models\Historial_cuenta;
|
||||||
|
use App\Models\Preferencial;
|
||||||
use App\Models\Promociones;
|
use App\Models\Promociones;
|
||||||
use App\Models\recarga;
|
use App\Models\recarga;
|
||||||
use App\Models\Saldo;
|
use App\Models\Saldo;
|
||||||
@@ -495,7 +496,11 @@ class PublicChat extends Component
|
|||||||
|
|
||||||
private function listarServicios(): void
|
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()) {
|
if ($servicios->isEmpty()) {
|
||||||
$this->guardarMensajeBot($this->convId, "No hay servicios disponibles en este momento.");
|
$this->guardarMensajeBot($this->convId, "No hay servicios disponibles en este momento.");
|
||||||
@@ -503,36 +508,36 @@ class PublicChat extends Component
|
|||||||
return;
|
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([
|
||||||
ChatMessage::create([
|
'conversation_id' => $this->convId,
|
||||||
'conversation_id' => $this->convId,
|
'tipo' => 'bot',
|
||||||
'tipo' => 'bot',
|
'tipo_ui' => 'cards_slider',
|
||||||
'tipo_ui' => 'card',
|
'contenido' => 'Selecciona el servicio:',
|
||||||
'contenido' => '',
|
'payload' => ['cards' => $cards],
|
||||||
'payload' => [
|
'leido' => true,
|
||||||
'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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->volver('menu.principal');
|
$this->volver('menu.principal');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function verServicio(int $id): void
|
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) {
|
if (! $servicio) {
|
||||||
$this->guardarMensajeBot($this->convId, "Servicio no encontrado.");
|
$this->guardarMensajeBot($this->convId, "Servicio no encontrado.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tarifas = $servicio->tarifas->where('estado', 'activo');
|
$tarifas = $servicio->tarifas;
|
||||||
|
|
||||||
if ($tarifas->isEmpty()) {
|
if ($tarifas->isEmpty()) {
|
||||||
$this->guardarMensajeBot($this->convId, "No hay planes para {$servicio->nombre} en este momento.");
|
$this->guardarMensajeBot($this->convId, "No hay planes para {$servicio->nombre} en este momento.");
|
||||||
@@ -542,9 +547,10 @@ class PublicChat extends Component
|
|||||||
|
|
||||||
$botones = [];
|
$botones = [];
|
||||||
foreach ($tarifas as $t) {
|
foreach ($tarifas as $t) {
|
||||||
$label = $servicio->por_tiempo
|
$precio = $this->efectivePrice($t->id, $this->userId, $t->valor);
|
||||||
? "{$t->dias} dias - $" . number_format($t->valor)
|
$label = $servicio->por_tiempo
|
||||||
: "{$t->pantallas} pantalla(s) / {$t->dias} dias - $" . number_format($t->valor);
|
? "{$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' => $label, 'action' => 'compra.iniciar', 'data' => ['tarifa_id' => $t->id]];
|
||||||
}
|
}
|
||||||
$botones[] = ['label' => 'Volver', 'action' => 'servicios.listar', 'data' => []];
|
$botones[] = ['label' => 'Volver', 'action' => 'servicios.listar', 'data' => []];
|
||||||
@@ -578,10 +584,12 @@ class PublicChat extends Component
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$precio = $this->efectivePrice($tarifaId, $this->userId, $tarifa->valor);
|
||||||
|
|
||||||
$this->flujo = 'compra';
|
$this->flujo = 'compra';
|
||||||
$this->flujoData = [
|
$this->flujoData = [
|
||||||
'tarifa_id' => $tarifaId,
|
'tarifa_id' => $tarifaId,
|
||||||
'valor' => $tarifa->valor,
|
'valor' => $precio,
|
||||||
'servicio' => $tarifa->servicio->nombre,
|
'servicio' => $tarifa->servicio->nombre,
|
||||||
'dias' => $tarifa->dias,
|
'dias' => $tarifa->dias,
|
||||||
'pantallas' => $tarifa->pantallas,
|
'pantallas' => $tarifa->pantallas,
|
||||||
@@ -590,15 +598,15 @@ class PublicChat extends Component
|
|||||||
$detalle = $tarifa->servicio->por_tiempo
|
$detalle = $tarifa->servicio->por_tiempo
|
||||||
? "{$tarifa->dias} dias"
|
? "{$tarifa->dias} dias"
|
||||||
: "{$tarifa->pantallas} pantalla(s) / {$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([
|
ChatMessage::create([
|
||||||
'conversation_id' => $this->convId,
|
'conversation_id' => $this->convId,
|
||||||
'tipo' => 'bot',
|
'tipo' => 'bot',
|
||||||
'tipo_ui' => 'buttons',
|
'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' => [
|
'payload' => ['botones' => [
|
||||||
['label' => 'Saldo ($' . number_format($tarifa->valor) . ')',
|
['label' => 'Saldo ($' . number_format($precio) . ')',
|
||||||
'action' => 'compra.pagar.saldo',
|
'action' => 'compra.pagar.saldo',
|
||||||
'data' => [],
|
'data' => [],
|
||||||
'disabled' => $saldoInsuficiente],
|
'disabled' => $saldoInsuficiente],
|
||||||
@@ -716,7 +724,7 @@ class PublicChat extends Component
|
|||||||
private function listarPromociones(): void
|
private function listarPromociones(): void
|
||||||
{
|
{
|
||||||
$promos = Promociones::where('visible', true)
|
$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();
|
->get();
|
||||||
|
|
||||||
if ($promos->isEmpty()) {
|
if ($promos->isEmpty()) {
|
||||||
@@ -725,25 +733,23 @@ class PublicChat extends Component
|
|||||||
return;
|
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([
|
||||||
ChatMessage::create([
|
'conversation_id' => $this->convId,
|
||||||
'conversation_id' => $this->convId,
|
'tipo' => 'bot',
|
||||||
'tipo' => 'bot',
|
'tipo_ui' => 'cards_slider',
|
||||||
'tipo_ui' => 'card',
|
'contenido' => 'Promociones disponibles:',
|
||||||
'contenido' => '',
|
'payload' => ['cards' => $cards],
|
||||||
'payload' => [
|
'leido' => true,
|
||||||
'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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->volver('menu.principal');
|
$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
|
private function volver(string $action): void
|
||||||
{
|
{
|
||||||
ChatMessage::create([
|
ChatMessage::create([
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -292,6 +292,53 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 --}}
|
{{-- LINK: botón de pago externo --}}
|
||||||
@elseif ($tipoUi === 'link')
|
@elseif ($tipoUi === 'link')
|
||||||
<div class="max-w-[85%] bg-[#202c33] rounded-xl rounded-tl-sm px-4 py-3 shadow">
|
<div class="max-w-[85%] bg-[#202c33] rounded-xl rounded-tl-sm px-4 py-3 shadow">
|
||||||
|
|||||||
Reference in New Issue
Block a user