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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user