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
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user