feat: chat público con sensación de WhatsApp/Telegram real

Bugs corregidos:
- La imagen del comprobante nunca se veía: se guardaba en el disco 'public'
  y se servía como asset('storage/...'), pero este proyecto no tiene el
  symlink public/storage. Ahora usa store('photos') + asset($path), la
  convención del resto del código (config/filesystems.php links).
- Dos wire:poll en el mismo nodo: Livewire v2 solo respeta el primero, así
  que autoReintentarPago nunca se ejecutaba. Ahora va en su propio elemento
  y solo se renderiza mientras hay un pago pendiente.
- Sin wire:key en los mensajes, morphdom recreaba toda la lista cada 3s:
  parpadeo, scroll saltando y estado Alpine perdido (el "¡Copiado!" de las
  credenciales desaparecía solo).
- pollMensajes cargaba la conversación entera cada 3s. Ahora consulta solo
  MAX(id) y recarga únicamente si llegó algo nuevo; el historial se limita
  a los 60 mensajes más recientes.
- El upload no se validaba: se añade regla image/mimes/max 8MB.
- Se elimina $procesandoImagen, propiedad muerta que viajaba en cada payload.

Interfaz:
- Envío optimista: la burbuja del usuario aparece al instante, sin esperar
  el round-trip, con reloj de "pendiente" y checks al confirmarse.
- Indicador "escribiendo…" con puntos animados mientras el bot responde.
- Visor de imagen a pantalla completa al tocar el comprobante.
- Textarea que crece con el texto (Shift+Enter para salto de línea),
  con wire:ignore para que el poll no le reinicie la altura al escribir.
- Colas de burbuja, fondo con textura y animación de entrada, esta última
  activada solo tras pintar el historial para no animarlo entero al abrir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro
2026-08-01 13:26:01 +00:00
co-authored by Claude Sonnet 4.6
parent d88f67ab5b
commit f4f689891c
6 changed files with 248 additions and 54 deletions
+70 -34
View File
@@ -60,7 +60,12 @@ class PublicChat extends Component
// ── Upload comprobante ────────────────────────────────────
public $fotoComprobante = null;
public bool $procesandoImagen = false;
// Último id de mensaje ya cargado — evita re-render en cada poll
public int $ultimoMsgId = 0;
/** Mensajes recientes que se mantienen en memoria/DOM. */
private const MAX_MENSAJES = 60;
public function mount(): void
{
@@ -397,13 +402,19 @@ class PublicChat extends Component
// Envío de texto libre
// ─────────────────────────────────────────────────────────
public function enviar(): void
/**
* El texto llega como argumento desde Alpine para poder pintar la burbuja
* del usuario al instante en el cliente (sin esperar el round-trip).
* Se mantiene el fallback a $this->input por compatibilidad.
*/
public function enviar(?string $texto = null): void
{
if (! $this->convId || trim($this->input) === '') {
$texto = trim($texto ?? $this->input);
if (! $this->convId || $texto === '') {
return;
}
$texto = trim($this->input);
$this->input = '';
$this->guardarMensajeUsuario($this->convId, $texto);
@@ -532,21 +543,32 @@ class PublicChat extends Component
return;
}
$this->procesandoImagen = true;
try {
$this->validate([
'fotoComprobante' => 'image|mimes:jpg,jpeg,png,webp,heic|max:8192',
]);
} catch (\Illuminate\Validation\ValidationException $e) {
$this->fotoComprobante = null;
$this->guardarMensajeBot($this->convId, "Ese archivo no sirve como comprobante. Envía una imagen (JPG o PNG) de máximo 8 MB.");
$this->cargarMensajes();
return;
}
try {
// Verificar solicitud activa ANTES de cualquier llamada de API
$solicitud = $this->userId ? \App\Models\SolicitudRecarga::pendiente($this->userId) : null;
if (! $solicitud) {
$this->procesandoImagen = false;
$this->fotoComprobante = null;
$this->fotoComprobante = null;
$this->guardarMensajeBot($this->convId, "⚠️ No tienes ninguna solicitud de recarga activa. Primero inicia el proceso de recarga desde el menú.");
$this->cargarMensajes();
return;
}
$path = $this->fotoComprobante->store('comprobantes', 'public');
$url = asset('storage/' . $path);
// Disco 'local' + carpeta photos: es el único symlink publicado
// (config/filesystems.php links). El disco 'public' no tiene symlink
// en este proyecto, por eso las imágenes daban 404.
$path = $this->fotoComprobante->store('photos');
$url = asset($path);
ChatMessage::create([
'conversation_id' => $this->convId,
@@ -620,10 +642,10 @@ class PublicChat extends Component
}
} catch (\Throwable $e) {
Log::error('[PublicChat] Error procesando comprobante: ' . $e->getMessage());
$this->guardarMensajeBot($this->convId, "Error analizando el comprobante. Por favor intenta de nuevo.");
} finally {
$this->procesandoImagen = false;
$this->fotoComprobante = null;
$this->fotoComprobante = null;
$this->cargarMensajes();
$this->dispatchBrowserEvent('scroll-chat');
}
@@ -1656,45 +1678,59 @@ class PublicChat extends Component
// Cargar mensajes
// ─────────────────────────────────────────────────────────
/**
* Trae los últimos MAX_MENSAJES en orden cronológico.
* El 'id' es imprescindible: alimenta el wire:key de la vista para que
* morphdom reutilice los nodos existentes en vez de recrear toda la lista.
*/
private function consultarMensajes(): array
{
return ChatMessage::where('conversation_id', $this->convId)
->orderByDesc('id')
->limit(self::MAX_MENSAJES)
->get()
->reverse()
->map(fn ($m) => [
'id' => $m->id,
'tipo' => $m->tipo,
'tipo_ui' => $m->tipo_ui ?? 'text',
'contenido' => $m->contenido,
'payload' => $m->payload ?? [],
'created_at' => $m->created_at->format('H:i'),
])
->values()
->toArray();
}
public function cargarMensajes(): void
{
if (! $this->convId) {
return;
}
$this->mensajes = ChatMessage::where('conversation_id', $this->convId)
->orderBy('id')
->get()
->map(fn ($m) => [
'tipo' => $m->tipo,
'tipo_ui' => $m->tipo_ui ?? 'text',
'contenido' => $m->contenido,
'payload' => $m->payload ?? [],
'created_at' => $m->created_at->format('H:i'),
])
->toArray();
$this->mensajes = $this->consultarMensajes();
$this->ultimoMsgId = (int) (end($this->mensajes)['id'] ?? 0);
$this->dispatchBrowserEvent('scroll-chat');
}
// Llamado por wire:poll — igual que cargarMensajes pero sin forzar scroll
// Llamado por wire:poll cada 3s. Consulta barata: solo pregunta el último id
// y recarga la lista únicamente si llegó algo nuevo. Sin cambios en $mensajes
// el diff de Livewire queda vacío y el DOM no parpadea.
public function pollMensajes(): void
{
if (! $this->convId) {
return;
}
$this->mensajes = ChatMessage::where('conversation_id', $this->convId)
->orderBy('id')
->get()
->map(fn ($m) => [
'tipo' => $m->tipo,
'tipo_ui' => $m->tipo_ui ?? 'text',
'contenido' => $m->contenido,
'payload' => $m->payload ?? [],
'created_at' => $m->created_at->format('H:i'),
])
->toArray();
$ultimo = (int) ChatMessage::where('conversation_id', $this->convId)->max('id');
if ($ultimo === $this->ultimoMsgId) {
return;
}
$this->mensajes = $this->consultarMensajes();
$this->ultimoMsgId = $ultimo;
// No dispatches scroll-chat — el MutationObserver del JS maneja el badge
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
"isEntry": true
},
"resources/css/app.css": {
"file": "assets/app.ab91d6ef.css",
"file": "assets/app.1c7d2d7a.css",
"src": "resources/css/app.css",
"isEntry": true
}
+62
View File
@@ -87,6 +87,63 @@
.safe-bottom { padding-bottom: env(safe-area-inset-bottom, 0px); }
.safe-left { padding-left: env(safe-area-inset-left, 0px); }
.safe-right { padding-right: env(safe-area-inset-right, 0px); }
/* ── Fondo con textura tipo WhatsApp ─────────────────────────── */
.chat-bg {
background-color: #0b141a;
background-image:
radial-gradient(circle at 25% 25%, rgba(255,255,255,.022) 1.2px, transparent 1.4px),
radial-gradient(circle at 75% 75%, rgba(255,255,255,.018) 1.2px, transparent 1.4px);
background-size: 34px 34px, 46px 46px;
}
/* ── Colas de burbuja ────────────────────────────────────────── */
.bubble-in, .bubble-out { position: relative; }
.bubble-in::before, .bubble-out::before {
content: ''; position: absolute; top: 0; width: 9px; height: 13px;
}
.bubble-in::before {
left: -8px;
background: #202c33;
clip-path: polygon(100% 0, 100% 100%, 0 0);
}
.bubble-out::before {
right: -8px;
background: #005c4b;
clip-path: polygon(0 0, 100% 0, 0 100%);
}
/* ── Entrada de mensajes ─────────────────────────────────────── */
@keyframes msgIn {
from { opacity: 0; transform: translateY(8px) scale(.98); }
to { opacity: 1; transform: none; }
}
/* Solo se anima una vez pintado el historial, para que al abrir el chat
los mensajes viejos no entren todos a la vez. La bandera vive en <body>
(fuera del DOM de Livewire) para que morphdom no la revierta. */
body.chat-listo .msg-in { animation: msgIn .18s ease-out; }
/* ── Puntos de "escribiendo…" ────────────────────────────────── */
.typing-dot {
width: 7px; height: 7px; border-radius: 9999px;
background: #8696a0; display: inline-block;
animation: typingBounce 1.3s infinite ease-in-out;
}
.typing-dot:nth-child(2) { animation-delay: .18s; }
.typing-dot:nth-child(3) { animation-delay: .36s; }
@keyframes typingBounce {
0%, 60%, 100% { transform: translateY(0); opacity: .45; }
30% { transform: translateY(-5px); opacity: 1; }
}
/* Imagen que no cargó: no dejar un hueco enorme */
.img-rota { min-height: 0 !important; opacity: .35; }
@media (prefers-reduced-motion: reduce) {
.msg-in, .typing-dot { animation: none; }
}
[x-cloak] { display: none !important; }
</style>
@livewireStyles
</head>
@@ -165,6 +222,11 @@
}
scrollBottom();
/* Historial ya pintado: a partir de aquí sí animamos los mensajes nuevos */
requestAnimationFrame(function () {
requestAnimationFrame(function () { document.body.classList.add('chat-listo'); });
});
}
/* Bajar al fondo cuando el componente emite scroll-chat */
@@ -160,8 +160,36 @@
{{-- ══ PASO 3: Interfaz de chat ══ --}}
@else
<div class="flex-1 flex flex-col min-h-0 bg-[#0b141a] relative"
x-data="{ uploading: false, previewUrl: null }"
<div class="flex-1 flex flex-col min-h-0 chat-bg relative"
x-data="{
uploading: false,
previewUrl: null,
pendiente: null,
lightbox: null,
enviarTexto() {
const ta = this.$refs.entrada;
const t = (ta?.value ?? '').trim();
if (! t) return;
this.pendiente = t; // burbuja optimista: se ve al instante
ta.value = '';
this.autoGrow();
this.$nextTick(() => window.dispatchEvent(new CustomEvent('scroll-chat')));
try {
Promise.resolve(this.$wire.enviar(t)).finally(() => this.pendiente = null);
} catch (e) {
// Si el envío no sale, devolvemos el texto al cuadro para no perderlo
this.pendiente = null;
ta.value = t;
this.autoGrow();
}
},
autoGrow() {
const ta = this.$refs.entrada;
if (! ta) return;
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 112) + 'px';
}
}"
x-on:livewire-upload-start.window="uploading = true"
x-on:livewire-upload-finish.window="uploading = false; previewUrl = null"
x-on:livewire-upload-error.window="uploading = false; previewUrl = null">
@@ -235,41 +263,58 @@
</button>
</div>
{{-- Reintento automático de validación de pago.
Va en su propio elemento: Livewire v2 solo respeta el PRIMER
wire:poll de un mismo nodo, así que compartirlo con el poll de
mensajes hacía que este nunca se ejecutara. Solo se renderiza
mientras hay un pago pendiente, así no gasta requests. --}}
@if ($flujo === 'pago.pendiente')
<div wire:poll.20000ms="autoReintentarPago" class="w-0 h-0 overflow-hidden" aria-hidden="true"></div>
@endif
{{-- Mensajes --}}
<div class="flex-1 min-h-0 overflow-y-auto px-3 py-4 space-y-3 ios-scroll"
<div class="flex-1 min-h-0 overflow-y-auto px-3 py-4 space-y-2 ios-scroll"
id="pub-messages"
wire:poll.3000ms="pollMensajes"
wire:poll.20000ms="autoReintentarPago"
style="overscroll-behavior-y:contain;-webkit-overflow-scrolling:touch;">
@forelse($mensajes as $msg)
{{-- ── Mensaje del USUARIO ── --}}
@if ($msg['tipo'] === 'usuario')
<div class="flex justify-end">
<div class="flex justify-end msg-in" wire:key="msg-{{ $msg['id'] }}">
@if (($msg['tipo_ui'] ?? 'text') === 'imagen')
<div class="max-w-[75%] rounded-xl rounded-tr-sm overflow-hidden shadow">
<img src="{{ $msg['payload']['url'] ?? '' }}" class="w-full object-cover" style="max-height:220px;">
<div class="bg-[#005c4b] px-3 py-1 text-right">
<span class="text-[#8696a0] text-[11px]">{{ $msg['created_at'] }}</span>
<div class="max-w-[75%] bg-[#005c4b] rounded-xl rounded-tr-sm overflow-hidden shadow bubble-out">
<img src="{{ $msg['payload']['url'] ?? '' }}"
loading="lazy" decoding="async" alt="Comprobante enviado"
class="w-full object-cover cursor-zoom-in bg-[#0b141a]"
style="max-height:260px; min-height:120px;"
x-on:click="lightbox = '{{ $msg['payload']['url'] ?? '' }}'"
x-on:error="$el.classList.add('img-rota')">
<div class="px-3 py-1 flex items-center justify-end gap-1">
<span class="text-white/60 text-[11px]">{{ $msg['created_at'] }}</span>
<svg class="w-4 h-4 text-[#53bdeb]" viewBox="0 0 16 15" fill="currentColor"><path d="M15.01 3.316l-.478-.372a.365.365 0 0 0-.51.063L8.666 9.879a.32.32 0 0 1-.484.033l-.358-.325a.319.319 0 0 0-.484.032l-.378.483a.418.418 0 0 0 .036.541l1.32 1.266c.143.14.361.125.484-.033l6.272-8.048a.366.366 0 0 0-.064-.512zm-4.1 0l-.478-.372a.365.365 0 0 0-.51.063L4.566 9.879a.32.32 0 0 1-.484.033L1.891 7.769a.366.366 0 0 0-.515.006l-.423.433a.364.364 0 0 0 .006.514l3.258 3.185c.143.14.361.125.484-.033l6.272-8.048a.365.365 0 0 0-.063-.51z"/></svg>
</div>
</div>
@else
<div class="max-w-[80%] bg-[#005c4b] text-white rounded-xl rounded-tr-sm px-4 py-2 shadow">
<div class="max-w-[80%] bg-[#005c4b] text-white rounded-xl rounded-tr-sm px-3 py-2 shadow bubble-out">
<p class="whitespace-pre-line break-words leading-relaxed" style="font-size:15px;">{{ $msg['contenido'] }}</p>
<span class="text-[#8696a0] text-[11px] float-right mt-1 ml-2">{{ $msg['created_at'] }}</span>
<span class="inline-flex items-center gap-1 float-right mt-1 ml-2 translate-y-1">
<span class="text-white/60 text-[11px]">{{ $msg['created_at'] }}</span>
<svg class="w-4 h-4 text-[#53bdeb]" viewBox="0 0 16 15" fill="currentColor"><path d="M15.01 3.316l-.478-.372a.365.365 0 0 0-.51.063L8.666 9.879a.32.32 0 0 1-.484.033l-.358-.325a.319.319 0 0 0-.484.032l-.378.483a.418.418 0 0 0 .036.541l1.32 1.266c.143.14.361.125.484-.033l6.272-8.048a.366.366 0 0 0-.064-.512zm-4.1 0l-.478-.372a.365.365 0 0 0-.51.063L4.566 9.879a.32.32 0 0 1-.484.033L1.891 7.769a.366.366 0 0 0-.515.006l-.423.433a.364.364 0 0 0 .006.514l3.258 3.185c.143.14.361.125.484-.033l6.272-8.048a.365.365 0 0 0-.063-.51z"/></svg>
</span>
</div>
@endif
</div>
{{-- ── Mensajes del BOT / AGENTE ── --}}
@else
<div class="flex justify-start">
<div class="flex justify-start msg-in" wire:key="msg-{{ $msg['id'] }}">
@php $tipoUi = $msg['tipo_ui'] ?? 'text'; $payload = $msg['payload'] ?? []; @endphp
{{-- TEXTO normal --}}
@if ($tipoUi === 'text')
<div class="max-w-[80%] bg-[#202c33] text-white rounded-xl rounded-tl-sm px-4 py-2 shadow">
<div class="max-w-[80%] bg-[#202c33] text-white rounded-xl rounded-tl-sm px-3 py-2 shadow bubble-in">
@if ($msg['tipo'] === 'agente')
<span class="text-amber-400 text-[11px] font-semibold block mb-0.5">Agente</span>
@endif
@@ -611,6 +656,34 @@
<div class="text-center text-[#8696a0] text-sm py-10">Iniciando chat...</div>
@endforelse
{{-- Burbuja optimista: el mensaje del usuario aparece antes de
que el servidor responda, igual que en WhatsApp. Se borra
sola cuando Livewire devuelve la lista real. --}}
<template x-if="pendiente">
<div class="flex justify-end msg-in">
<div class="max-w-[80%] bg-[#005c4b] text-white rounded-xl rounded-tr-sm px-3 py-2 shadow opacity-80 bubble-out">
<p class="whitespace-pre-line break-words leading-relaxed" style="font-size:15px;" x-text="pendiente"></p>
<span class="inline-flex items-center gap-1 float-right mt-1 ml-2 translate-y-1">
<span class="text-white/60 text-[11px]">ahora</span>
<svg class="w-3.5 h-3.5 text-white/50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="12" cy="12" r="9" stroke-dasharray="2.5 2.5"/>
</svg>
</span>
</div>
</div>
</template>
{{-- "Escribiendo…" mientras el bot procesa cualquier acción --}}
<div wire:loading.flex wire:target="enviar, clickBoton, fotoComprobante" class="justify-start msg-in">
<div class="bg-[#202c33] rounded-xl rounded-tl-sm px-4 py-3 shadow bubble-in">
<div class="flex items-center gap-1">
<span class="typing-dot"></span>
<span class="typing-dot"></span>
<span class="typing-dot"></span>
</div>
</div>
</div>
<div id="pub-messages-end"></div>
</div>
@@ -643,7 +716,7 @@
</div>
</div>
{{-- Indicador: analizando con IA (fase Livewire server) --}}
<div wire:loading wire:target="fotoComprobante" class="px-4 pt-2 pb-1">
<div wire:loading.block wire:target="fotoComprobante" class="px-4 pt-2 pb-1">
<div class="flex items-center gap-2 text-[#8696a0] text-xs">
<svg class="w-4 h-4 animate-spin text-[#00a884]" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
@@ -662,16 +735,21 @@
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
</svg>
</label>
{{-- wire:ignore: su contenido y su altura los maneja Alpine.
Sin esto, el poll de 3s le devolvería la altura original
mientras el usuario está escribiendo. --}}
<textarea
wire:model.defer="input"
wire:keydown.enter.prevent="enviar"
wire:ignore
x-ref="entrada"
x-on:input="autoGrow()"
x-on:keydown.enter.prevent="if (! $event.shiftKey) enviarTexto()"
rows="1"
placeholder="Escribe o usa los botones..."
class="flex-1 bg-[#2a3942] text-white rounded-xl px-4 py-3 resize-none outline-none placeholder-[#8696a0]"
style="font-size:16px; overflow-y:auto; max-height:7rem; line-height:1.4; color-scheme:dark;"
></textarea>
<button wire:click="enviar"
class="bg-[#00a884] active:bg-[#06cf9c] rounded-full flex items-center justify-center transition flex-shrink-0"
<button type="button" x-on:click="enviarTexto()"
class="bg-[#00a884] active:bg-[#06cf9c] active:scale-95 rounded-full flex items-center justify-center transition flex-shrink-0"
style="width:44px; height:44px; min-width:44px;">
<svg class="w-5 h-5 text-white" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
@@ -685,6 +763,24 @@
</div>
@endif
{{-- Visor de imagen a pantalla completa --}}
<template x-if="lightbox">
<div class="fixed inset-0 z-50 bg-black/95 flex flex-col"
x-on:click="lightbox = null"
x-on:keydown.escape.window="lightbox = null">
<div class="flex justify-end safe-top px-4 py-3">
<button type="button" class="p-2 rounded-full bg-white/10 text-white active:bg-white/20">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 flex items-center justify-center px-3 pb-6 safe-bottom">
<img :src="lightbox" alt="Comprobante" class="max-w-full max-h-full object-contain rounded-lg">
</div>
</div>
</template>
</div>
@endif