From 8c4775f8a4f8d5e1d86001bc0512cb6fac5c1771 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 1 Aug 2026 15:59:07 +0300 Subject: [PATCH] =?UTF-8?q?:lipstick:=20feat(webchat):=20pill-=D0=BB=D0=B0?= =?UTF-8?q?=D1=83=D0=BD=D1=87=D0=B5=D1=80,=20=D0=BE=D1=82=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D0=B8=D0=B5=20=D0=BD=D0=B0=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D0=BB=D0=B5=D0=B4=D0=BD=D0=B8=D1=85=20=D1=81=D0=BE=D0=BE=D0=B1?= =?UTF-8?q?=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=D1=85,=20=D0=BE=D0=BA=D0=BD?= =?UTF-8?q?=D0=BE=20=D1=88=D0=B8=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - launcher: круглая кнопка → пилюля с иконкой Tabler brand-hipchat и словом «Чат» (inline SVG — на сайте продукта шрифта Tabler нет), hover/focus-visible, анимации bump/shake переделаны под новую форму - окно 384x600 → 440x720 - фикс: панель открывалась на начале истории. Лоадер держит iframe скрытым (display:none), у ленты scrollHeight === 0 и автоскролл не срабатывал — виджет теперь слушает edevs-chat-opened и доскроллит после показа, лоадер повторяет сигнал на load iframe - лента ближе к мессенджеру: время у сообщений, растущее под текст поле ввода, крупнее типографика и шире пузыри - support-режим переиспользует шапку/пузыри/composer из ChatView вместо своих копий Co-Authored-By: Claude Opus 5 --- apps/backend/hub_platform/webchat/loader.py | 24 +++-- apps/web-chat/src/App.tsx | 7 +- apps/web-chat/src/ChatView.tsx | 53 +++++++--- apps/web-chat/src/SupportApp.tsx | 104 ++++---------------- apps/web-chat/src/useScrollToLatest.ts | 39 ++++++++ 5 files changed, 119 insertions(+), 108 deletions(-) create mode 100644 apps/web-chat/src/useScrollToLatest.ts diff --git a/apps/backend/hub_platform/webchat/loader.py b/apps/backend/hub_platform/webchat/loader.py index 802343f..bf1a35a 100644 --- a/apps/backend/hub_platform/webchat/loader.py +++ b/apps/backend/hub_platform/webchat/loader.py @@ -27,16 +27,20 @@ LOADER_JS = r""" var open = false, frame = null, unread = false, callActive = false; var style = document.createElement("style"); - style.textContent = "@keyframes edevs-chat-message-bump{0%,100%{transform:translateX(0)}35%{transform:translateX(-4px) rotate(-3deg)}70%{transform:translateX(3px) rotate(2deg)}}@keyframes edevs-chat-call-shake{0%,18%,100%{transform:translateX(0) rotate(0)}3%{transform:translateX(-5px) rotate(-5deg)}6%{transform:translateX(5px) rotate(5deg)}9%{transform:translateX(-4px) rotate(-4deg)}12%{transform:translateX(4px) rotate(4deg)}15%{transform:translateX(-2px) rotate(-2deg)}}.edevs-chat-message-bump{animation:edevs-chat-message-bump .42s ease-out}.edevs-chat-call-shake{animation:edevs-chat-call-shake 3.2s ease-in-out infinite}"; + style.textContent = "@keyframes edevs-chat-message-bump{0%,100%{transform:translateY(0)}35%{transform:translateY(-6px)}70%{transform:translateY(-2px)}}@keyframes edevs-chat-call-shake{0%,18%,100%{transform:translateX(0)}3%{transform:translateX(-5px)}6%{transform:translateX(5px)}9%{transform:translateX(-4px)}12%{transform:translateX(4px)}15%{transform:translateX(-2px)}}.edevs-chat-message-bump{animation:edevs-chat-message-bump .42s ease-out}.edevs-chat-call-shake{animation:edevs-chat-call-shake 3.2s ease-in-out infinite}.edevs-chat-launcher:hover{transform:translateY(-2px);box-shadow:0 12px 30px rgba(22,119,255,0.45)}.edevs-chat-launcher:focus-visible{outline:3px solid rgba(22,119,255,0.45);outline-offset:2px}"; (document.head || document.documentElement).appendChild(style); + // Launcher — «пилюля» с иконкой (Tabler brand-hipchat) и подписью. Иконка + // вшита как inline SVG: на сайте продукта шрифта Tabler нет. var btn = document.createElement("button"); + btn.className = "edevs-chat-launcher"; + btn.type = "button"; btn.setAttribute("aria-label", "Открыть чат"); - btn.style.cssText = "position:fixed;right:24px;bottom:24px;width:60px;height:60px;border-radius:50%;border:none;background:#1677ff;box-shadow:0 8px 24px rgba(22,119,255,0.4);cursor:pointer;z-index:2147483000;display:flex;align-items:center;justify-content:center;"; - btn.innerHTML = ''; + btn.style.cssText = "position:fixed;right:24px;bottom:24px;height:52px;padding:0 20px;border:none;border-radius:999px;background:#1677ff;box-shadow:0 8px 24px rgba(22,119,255,0.35);cursor:pointer;z-index:2147483000;display:flex;align-items:center;gap:9px;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;font-size:15px;font-weight:600;line-height:1;letter-spacing:0;transition:transform .18s ease,box-shadow .18s ease;"; + btn.innerHTML = 'Чат'; var dot = document.createElement("span"); - dot.style.cssText = "position:absolute;top:8px;right:8px;width:12px;height:12px;border-radius:50%;background:#faad14;border:2px solid #fff;display:none;"; + dot.style.cssText = "position:absolute;top:-2px;right:-2px;width:12px;height:12px;border-radius:50%;background:#faad14;border:2px solid #fff;display:none;"; btn.appendChild(dot); var notification = new Audio(origin + "/chat/audio/notification.mp3"); @@ -78,7 +82,11 @@ LOADER_JS = r""" frame = document.createElement("iframe"); frame.src = panelUrl; frame.title = "Чат"; - frame.style.cssText = "position:fixed;right:24px;bottom:24px;width:min(384px,calc(100vw - 32px));height:min(600px,calc(100vh - 48px));border:none;border-radius:16px;box-shadow:0 12px 40px rgba(0,0,0,0.18);z-index:2147483000;display:none;background:transparent;"; + frame.style.cssText = "position:fixed;right:24px;bottom:24px;width:min(440px,calc(100vw - 32px));height:min(720px,calc(100vh - 48px));border:none;border-radius:16px;box-shadow:0 12px 40px rgba(0,0,0,0.18);z-index:2147483000;display:none;background:transparent;"; + // Панель может открыться раньше, чем загрузится iframe — тогда сообщение + // «открыто» (по нему виджет доскроллит ленту вниз) будет потеряно, поэтому + // повторяем его на load. + frame.addEventListener("load", function () { if (open) notifyOpened(); }); document.body.appendChild(frame); window.addEventListener("message", function (e) { if (e.origin !== origin) return; @@ -99,6 +107,10 @@ LOADER_JS = r""" }); } + function notifyOpened() { + try { frame.contentWindow.postMessage({ type: "edevs-chat-opened" }, origin); } catch (_) {} + } + function setOpen(next) { ensureFrame(); open = next; @@ -108,7 +120,7 @@ LOADER_JS = r""" unread = false; updateDot(); if (callActive) play(ringtone); - try { frame.contentWindow.postMessage({ type: "edevs-chat-opened" }, origin); } catch (_) {} + notifyOpened(); } else updateDot(); } diff --git a/apps/web-chat/src/App.tsx b/apps/web-chat/src/App.tsx index 4c728c3..a2b5a1d 100644 --- a/apps/web-chat/src/App.tsx +++ b/apps/web-chat/src/App.tsx @@ -14,6 +14,7 @@ import { type WebMessage, } from "./api"; import { CallInviteBanner, ChatBody, ChatComposer, ChatHeader, StartChatFooter } from "./ChatView"; +import { useScrollToLatest } from "./useScrollToLatest"; import { useWidgetActivity } from "./widgetActivity"; const CHANNEL = new URLSearchParams(location.search).get("channel") || "edevs"; @@ -39,6 +40,7 @@ export function App() { const openedCallId = useRef(""); const pollingReady = useRef(false); const bodyRef = useRef(null); + const scrollToLatest = useScrollToLatest(bodyRef); const incomingCall = Boolean(call && (call.status === "REQUESTED" || call.status === "RINGING")); const notifyNewMessage = useWidgetActivity(incomingCall); @@ -77,9 +79,8 @@ export function App() { }, [accepted, token]); useEffect(() => { - const node = bodyRef.current; - if (node) node.scrollTop = node.scrollHeight; - }, [messages, pending, awaiting]); + scrollToLatest(); + }, [scrollToLatest, messages, pending, awaiting]); const accent = config?.accent || "#1677ff"; const title = config?.title || "Чат"; diff --git a/apps/web-chat/src/ChatView.tsx b/apps/web-chat/src/ChatView.tsx index 5ede955..374b4da 100644 --- a/apps/web-chat/src/ChatView.tsx +++ b/apps/web-chat/src/ChatView.tsx @@ -1,4 +1,4 @@ -import { useState, type RefObject } from "react"; +import { useEffect, useRef, useState, type RefObject } from "react"; import { isVideoCall, type CallInfo, type WebConfig, type WebMessage } from "./api"; @@ -36,7 +36,7 @@ export function ChatBody({ bodyRef, config, unavailable, accepted, accent, lette onSubmitContact: (phone: string) => Promise; }) { return ( -
+
{config === null &&
Загрузка…
} {unavailable && } {config?.available && !accepted && } @@ -46,7 +46,7 @@ export function ChatBody({ bodyRef, config, unavailable, accepted, accent, lette {config.greeting && } {messages.map((message) => message.author === "system" ? - :
{message.kind === "contact_request" && message.id === lastContactRequestId && showPhoneForm && }
)} + :
{message.kind === "contact_request" && message.id === lastContactRequestId && showPhoneForm && }
)} {pending.map((text, index) => )} {awaiting && } @@ -63,7 +63,7 @@ function Consent({ config, accent, letter, title }: { config: WebConfig; accent: return <>
{letter}

{title}

{config.greeting}

{config.consent?.text} · ред. {config.consent?.version}
; } -function SystemMessage({ text }: { text: string }) { +export function SystemMessage({ text }: { text: string }) { return
{text}
; } @@ -71,13 +71,27 @@ export function StartChatFooter({ accent, starting, onAccept }: { accent: string return
; } -export function ChatComposer({ accent, state, quickReplies, pendingCount, messageCount, input, onInput, onSend }: { accent: string; state: "ai" | "operator" | "waiting"; quickReplies: string[]; pendingCount: number; messageCount: number; input: string; onInput: (value: string) => void; onSend: () => void }) { +const COMPOSER_MAX_HEIGHT = 132; + +export function ChatComposer({ accent, state, quickReplies, pendingCount, messageCount, input, placeholder, onInput, onSend }: { accent: string; state: "ai" | "operator" | "waiting"; quickReplies: string[]; pendingCount: number; messageCount: number; input: string; placeholder?: string; onInput: (value: string) => void; onSend: () => void }) { + const textareaRef = useRef(null); + + // Поле растёт под текст, как в мессенджере (до COMPOSER_MAX_HEIGHT, дальше скролл). + useEffect(() => { + const node = textareaRef.current; + if (!node) return; + node.style.height = "auto"; + // Панель может быть ещё скрыта (у iframe display:none) — тогда scrollHeight + // равен нулю и высоту задавать нельзя, иначе поле схлопнется. + node.style.height = node.scrollHeight > 0 ? `${Math.min(node.scrollHeight, COMPOSER_MAX_HEIGHT)}px` : ""; + }, [input]); + return ( -
- {state === "ai" && quickReplies.length > 0 && pendingCount === 0 && messageCount === 0 &&
{quickReplies.map((reply) => )}
} -
-