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
}