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>
251 lines
9.3 KiB
PHP
Executable File
251 lines
9.3 KiB
PHP
Executable File
<!DOCTYPE html>
|
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
<meta name="color-scheme" content="dark">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
<meta name="apple-mobile-web-app-title" content="Chat">
|
|
<title>Chat en vivo | {{ config('app.name') }}</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; }
|
|
|
|
html, body {
|
|
margin: 0; padding: 0;
|
|
font-family: 'Inter', sans-serif;
|
|
-webkit-tap-highlight-color: transparent;
|
|
overscroll-behavior: none;
|
|
}
|
|
|
|
/* Móvil: body como lienzo neutro */
|
|
body {
|
|
background: #111b21;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* #chat-root ocupa todo el viewport visible con position:fixed
|
|
— esto es la única forma 100% fiable en Android Chrome
|
|
(100dvh falla cuando la barra de URL aparece/desaparece) */
|
|
#chat-root {
|
|
position: fixed;
|
|
inset: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: #111b21;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Desktop: card centrada */
|
|
@media (min-width: 640px) {
|
|
body {
|
|
background: #0d1117;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100vh;
|
|
}
|
|
#chat-root {
|
|
position: relative;
|
|
inset: auto;
|
|
width: 420px;
|
|
height: 90dvh;
|
|
max-height: 820px;
|
|
border-radius: 20px;
|
|
box-shadow: 0 24px 64px rgba(0,0,0,.6);
|
|
}
|
|
}
|
|
|
|
/* Evita zoom en inputs iOS */
|
|
input, textarea, select { font-size: 16px !important; }
|
|
|
|
/* Scroll suave nativo */
|
|
.ios-scroll {
|
|
-webkit-overflow-scrolling: touch;
|
|
overscroll-behavior-y: contain;
|
|
}
|
|
|
|
/* Elimina appearance por defecto iOS */
|
|
input[type="tel"], input[type="text"],
|
|
input[type="email"], textarea {
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
border-radius: 12px;
|
|
}
|
|
|
|
button {
|
|
-webkit-appearance: none;
|
|
touch-action: manipulation;
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Safe areas (solo afectan en iOS con viewport-fit=cover) */
|
|
.safe-top { padding-top: env(safe-area-inset-top, 0px); }
|
|
.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>
|
|
<body>
|
|
|
|
<livewire:chat.public-chat />
|
|
|
|
@livewireScripts
|
|
|
|
<script>
|
|
(function () {
|
|
var msgsEl = null;
|
|
var isAtBottom = true;
|
|
var newMsgCount = 0;
|
|
var lastCount = 0;
|
|
var observer = null;
|
|
|
|
function checkBottom() {
|
|
if (!msgsEl) return true;
|
|
return (msgsEl.scrollHeight - msgsEl.scrollTop - msgsEl.clientHeight) <= 100;
|
|
}
|
|
|
|
function scrollBottom() {
|
|
if (msgsEl) msgsEl.scrollTop = msgsEl.scrollHeight;
|
|
}
|
|
|
|
function updateBadge() {
|
|
var badge = document.getElementById('new-msg-badge');
|
|
var count = document.getElementById('new-msg-count');
|
|
if (!badge) return;
|
|
if (newMsgCount > 0 && !isAtBottom) {
|
|
if (count) count.textContent = newMsgCount;
|
|
badge.classList.remove('hidden');
|
|
badge.classList.add('flex');
|
|
} else {
|
|
badge.classList.add('hidden');
|
|
badge.classList.remove('flex');
|
|
}
|
|
}
|
|
|
|
function countMsgs() {
|
|
return msgsEl ? msgsEl.children.length : 0;
|
|
}
|
|
|
|
function init() {
|
|
var el = document.getElementById('pub-messages');
|
|
if (!el || el === msgsEl) return; // ya inicializado sobre el mismo elemento
|
|
|
|
msgsEl = el;
|
|
lastCount = countMsgs();
|
|
|
|
msgsEl.addEventListener('scroll', function () {
|
|
isAtBottom = checkBottom();
|
|
if (isAtBottom) { newMsgCount = 0; updateBadge(); }
|
|
}, { passive: true });
|
|
|
|
if (observer) observer.disconnect();
|
|
observer = new MutationObserver(function () {
|
|
var current = countMsgs();
|
|
var diff = current - lastCount;
|
|
lastCount = current;
|
|
if (diff <= 0) return;
|
|
if (isAtBottom) { scrollBottom(); }
|
|
else { newMsgCount += diff; updateBadge(); }
|
|
});
|
|
observer.observe(msgsEl, { childList: true });
|
|
|
|
var btn = document.getElementById('new-msg-badge-btn');
|
|
if (btn && !btn._bound) {
|
|
btn._bound = true;
|
|
btn.addEventListener('click', function () {
|
|
isAtBottom = true; newMsgCount = 0;
|
|
msgsEl.scrollTo({ top: msgsEl.scrollHeight, behavior: 'smooth' });
|
|
updateBadge();
|
|
});
|
|
}
|
|
|
|
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 */
|
|
window.addEventListener('scroll-chat', function () {
|
|
isAtBottom = true; newMsgCount = 0; updateBadge(); scrollBottom();
|
|
});
|
|
|
|
document.addEventListener('DOMContentLoaded', function () { init(); });
|
|
|
|
/* livewire:load: Livewire terminó de arrancar — re-init por si el DOM cambió */
|
|
document.addEventListener('livewire:load', function () {
|
|
init();
|
|
/* Usar hook oficial para scroll después de cada update */
|
|
Livewire.hook('message.processed', function () {
|
|
if (isAtBottom) scrollBottom();
|
|
});
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|