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:
co-authored by
Claude Sonnet 4.6
parent
d88f67ab5b
commit
f4f689891c
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user