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 {starting ? "Запуск…" : "Принять и начать чат"}; } -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) => onInput(reply)} style={{ padding: "6px 12px", borderRadius: 16, border: "1px solid #d6e4ff", background: "#f0f7ff", color: "#0958d9", fontSize: 12, fontWeight: 500, cursor: "pointer" }}>{reply})}} - - onInput(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); onSend(); } }} placeholder="Напишите сообщение…" style={{ flex: 1, border: "none", outline: "none", resize: "none", fontSize: 13.5, lineHeight: 1.5, color: "#262626", fontFamily: "inherit", padding: "6px 0", maxHeight: 90 }} /> - + + {state === "ai" && quickReplies.length > 0 && pendingCount === 0 && messageCount === 0 && {quickReplies.map((reply) => onInput(reply)} style={{ padding: "7px 13px", borderRadius: 16, border: "1px solid #d6e4ff", background: "#f0f7ff", color: "#0958d9", fontSize: 12.5, fontWeight: 500, cursor: "pointer" }}>{reply})}} + + onInput(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); onSend(); } }} placeholder={placeholder ?? "Напишите сообщение…"} style={{ flex: 1, border: "none", outline: "none", resize: "none", fontSize: 14, lineHeight: 1.5, color: "#262626", fontFamily: "inherit", padding: "7px 0", maxHeight: COMPOSER_MAX_HEIGHT }} /> + ); @@ -93,10 +107,19 @@ export function CallInviteBanner({ call, accent, onAccept, onDecline }: { call: return {inviteIcon}{title}{call.staffName || "Оператор"} приглашает вас на онлайн-звонок{phoneIcon(true)}{phoneIcon(false)}; } -function Bubble({ author, text, accent, pendingState }: { author: "client" | "ai" | "operator"; text: string; accent: string; pendingState?: boolean }) { - if (author === "client") return {text}{pendingState ? "отправка…" : "доставлено"}; +function formatTime(iso: string | undefined): string { + if (!iso) return ""; + const date = new Date(iso); + return Number.isNaN(date.getTime()) ? "" : date.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); +} + +const META: React.CSSProperties = { fontSize: 11, color: "#bfbfbf", marginTop: 4 }; + +export function Bubble({ author, text, accent, time, pendingState }: { author: "client" | "ai" | "operator"; text: string; accent: string; time?: string; pendingState?: boolean }) { + const at = formatTime(time); + if (author === "client") return {text}{pendingState ? "отправка…" : [at, "доставлено"].filter(Boolean).join(" · ")}; const isOperator = author === "operator"; - return {isOperator ? О : }{text}{isOperator ? "Специалист" : "Виртуальный помощник"}; + return {isOperator ? О : }{text}{[isOperator ? "Специалист" : "Виртуальный помощник", at].filter(Boolean).join(" · ")}; } function formatPhone(raw: string): string { @@ -123,6 +146,6 @@ function PhoneForm({ accent, onSubmit }: { accent: string; onSubmit: (phone: str return setPhone(formatPhone(event.target.value))} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void submit(); } }} placeholder="+7 (___) ___-__-__" style={{ width: "100%", boxSizing: "border-box", height: 38, borderRadius: 8, border: error ? "1px solid #ff4d4f" : "1px solid #d9d9d9", padding: "0 10px", outline: "none", fontSize: 13.5 }} /> void submit()} disabled={!valid || sending} style={{ width: "100%", height: 36, marginTop: 8, borderRadius: 8, border: "none", background: valid ? accent : "#d9d9d9", color: "#fff", fontSize: 12.5, fontWeight: 600, cursor: valid ? "pointer" : "default" }}>{sending ? "Отправка…" : "Поделиться номером"}{error && Не удалось отправить. Попробуйте ещё раз.}; } -function Typing() { - return {[0, 0.2, 0.4].map((delay) => )}; +export function Typing() { + return {[0, 0.2, 0.4].map((delay) => )}; } diff --git a/apps/web-chat/src/SupportApp.tsx b/apps/web-chat/src/SupportApp.tsx index 34bf83c..89b6925 100644 --- a/apps/web-chat/src/SupportApp.tsx +++ b/apps/web-chat/src/SupportApp.tsx @@ -1,6 +1,8 @@ import { useEffect, useRef, useState } from "react"; import { pollSupport, sendSupport, startSupportSession, type WebMessage } from "./api"; +import { Bubble, ChatComposer, ChatHeader, SystemMessage, Typing } from "./ChatView"; +import { useScrollToLatest } from "./useScrollToLatest"; import { useWidgetActivity } from "./widgetActivity"; // Support-режим виджета (SPEC-HUB-0010 §7): authenticated in-product чат. @@ -24,6 +26,7 @@ export function SupportApp() { const [input, setInput] = useState(""); const lastId = useRef(0); const bodyRef = useRef(null); + const scrollToLatest = useScrollToLatest(bodyRef); const notifyNewMessage = useWidgetActivity(false); // Старт сессии один раз (SPEC §7.2: нет consent/accept flow). @@ -82,9 +85,8 @@ export function SupportApp() { }, [session]); useEffect(() => { - const node = bodyRef.current; - if (node) node.scrollTop = node.scrollHeight; - }, [messages, pending, awaiting]); + scrollToLatest(); + }, [scrollToLatest, messages, pending, awaiting]); async function send() { const text = input.trim(); @@ -116,10 +118,14 @@ export function SupportApp() { ? { label: "Передаём оператору", dot: "#faad14" } : { label: "Виртуальный помощник", dot: "#52c41a" }; + const header = (statusLabel: string, dot: string) => ( + + ); + if (failed) { return ( - - + + {header("Временно недоступна", "#faad14")} Поддержка временно недоступна. Обновите страницу или обратитесь позже. @@ -129,8 +135,8 @@ export function SupportApp() { if (!session) { return ( - - + + {header("Подключение…", "#52c41a")} Загрузка… @@ -141,36 +147,22 @@ export function SupportApp() { const greeting = displayName ? `Здравствуйте, ${displayName.split(" ")[0]}.` : "Здравствуйте."; return ( - - + + {header(status.label, status.dot)} - + Сегодня {messages.map((m) => (m.author === "system" - ? {m.text} - : ))} + ? + : ))} {pending.map((t, i) => )} {awaiting && } - - - setInput(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void send(); } }} - placeholder="Опишите вопрос…" - style={{ flex: 1, border: "none", outline: "none", resize: "none", fontSize: 13.5, lineHeight: 1.5, color: "#262626", fontFamily: "inherit", padding: "6px 0", maxHeight: 90 }} - /> - void send()} aria-label="Отправить" style={{ width: 34, height: 34, borderRadius: 8, border: "none", background: accent, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "none" }}> - - - - + void send()} /> ); } @@ -181,62 +173,6 @@ function modeOf(controlMode: string): "ai" | "operator" | "waiting" { return "ai"; } -function shell(accent: string): React.CSSProperties { +function shell(): React.CSSProperties { return { display: "flex", flexDirection: "column", height: "100%", width: "100%", background: "#fff", fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif", color: "#1f1f1f", overflow: "hidden" }; } - -function Header({ accent, title, statusLabel, dot }: { accent: string; title: string; statusLabel: string; dot: string }) { - return ( - - П - - {title} - - - {statusLabel} - - - - - - - ); -} - -function Bubble({ author, text, accent, pendingState }: { author: "client" | "ai" | "operator"; text: string; accent: string; pendingState?: boolean }) { - if (author === "client") { - return ( - - - {text} - {pendingState ? "отправка…" : "доставлено"} - - - ); - } - const isOperator = author === "operator"; - return ( - - - {isOperator ? О : } - - - {text} - {isOperator ? "Специалист" : "Виртуальный помощник"} - - - ); -} - -function Typing() { - return ( - - - - - - {[0, 0.2, 0.4].map((d) => )} - - - ); -} diff --git a/apps/web-chat/src/useScrollToLatest.ts b/apps/web-chat/src/useScrollToLatest.ts new file mode 100644 index 0000000..36bd09b --- /dev/null +++ b/apps/web-chat/src/useScrollToLatest.ts @@ -0,0 +1,39 @@ +import { useCallback, useEffect, type RefObject } from "react"; + +/** + * Держит ленту прижатой к последнему сообщению. + * + * Лоадер создаёт iframe заранее и прячет его через `display:none`, поэтому при + * первом рендере у ленты `scrollHeight === 0` и обычный автоскролл ничего не + * делает — панель открывается с началом истории. Поэтому дополнительно слушаем + * `edevs-chat-opened` от лоадера и доскроллим уже после показа панели. + * + * Возвращает функцию скролла — компонент вызывает её из своего эффекта на + * приход новых сообщений. + */ +export function useScrollToLatest(bodyRef: RefObject): () => void { + const scrollToLatest = useCallback(() => { + const node = bodyRef.current; + if (node) node.scrollTop = node.scrollHeight; + }, [bodyRef]); + + useEffect(() => { + let timer = 0; + function onParentMessage(event: MessageEvent) { + const data = event.data as { type?: string } | null; + if (data?.type !== "edevs-chat-opened") return; + scrollToLatest(); + // Панель только что получила display:block: если размеры ленты ещё не + // пересчитаны, повторяем после текущей задачи. Через rAF нельзя — в + // фоновой вкладке кадры не отрисовываются и колбэк не приходит. + timer = window.setTimeout(scrollToLatest, 0); + } + window.addEventListener("message", onParentMessage); + return () => { + window.clearTimeout(timer); + window.removeEventListener("message", onParentMessage); + }; + }, [scrollToLatest]); + + return scrollToLatest; +}
{config.greeting}
Поддержка временно недоступна. Обновите страницу или обратитесь позже.