Клиент теперь общается с поддержкой прямо на странице /v2 — SupportButton открывает панель чата (SupportChatPanel) вместо перехода на t.me. Под капотом сообщения по-прежнему зеркалятся в тот же Telegram-топик (backend contract: feat/tradein-web-chat-backend, /api/v1/trade-in/support/*), оператор отвечает как раньше, клиент этого не видит. - useSupportChat.ts (src/lib): TanStack Query polling — сообщения только пока панель открыта (5-10с), unread-бейдж только пока закрыта (20с); pause на неактивной вкладке — встроенное поведение refetchInterval (refetchIntervalInBackground по умолчанию false), без ручной visibilitychange-логики. - SupportChatContext.tsx: общее open/close состояние поверх React Context — TopNav («Помощь») и SupportButton не являются родителем/потомком друг друга, оба открывают ОДНО и то же окно. - SupportChatPanel.tsx: лента + композер (Enter отправляет, Shift+Enter — перенос, лимит 4000 символов со счётчиком), честные состояния (loading / error+retry / empty-welcome / 429 / 503 — тексты уже человекочитаемы на бэкенде), focus-trap + Esc (мирроринг LocationDrawer), plain-text рендер сообщений оператора (untrusted input, без dangerouslySetInnerHTML). Telegram-ссылка остаётся маленьким запасным вариантом внутри окна — у веб-чата нет push, поэтому это осознанный fallback, не удаление. - z-index 45 для панели: выше кнопки (25) и LocationDrawer (40/41), ниже TopNav (50) и UserMenu (200) — не ворует клики у верхней навигации.
475 lines
16 KiB
TypeScript
475 lines
16 KiB
TypeScript
"use client";
|
||
|
||
// SupportChatPanel — the actual chat window opened by SupportButton's
|
||
// floating trigger / TopNav's "Помощь" item. Messages are mirrored to the
|
||
// same Telegram support topic as before this feature (backend
|
||
// `feat/tradein-web-chat-backend`, app/api/v1/support.py — a mirror of the
|
||
// pre-existing Telegram support bridge, backend PR #2526): the operator still
|
||
// answers from Telegram exactly as before, only the client-facing surface
|
||
// moved on-site.
|
||
//
|
||
// Modal-dialog plumbing (focus trap / Escape / restore-focus-on-close)
|
||
// mirrors LocationDrawer.tsx, with one difference: initial focus goes to the
|
||
// message textarea, not the dialog shell — visitors open this panel to type,
|
||
// not to read a landing focus target.
|
||
import { useEffect, useRef, useState } from "react";
|
||
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
||
|
||
import { tokens } from "./tokens";
|
||
import { SUPPORT_BOT_URL } from "./SupportChatContext";
|
||
import {
|
||
MAX_SUPPORT_MESSAGE_LENGTH,
|
||
describeSupportError,
|
||
useMarkSupportRead,
|
||
useSendSupportMessage,
|
||
useSupportMessages,
|
||
} from "@/lib/useSupportChat";
|
||
import type { SupportMessage } from "@/lib/useSupportChat";
|
||
|
||
interface SupportChatPanelProps {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
}
|
||
|
||
const TIME_FORMAT = new Intl.DateTimeFormat("ru-RU", {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
|
||
function formatTime(iso: string): string {
|
||
const parsed = new Date(iso);
|
||
if (Number.isNaN(parsed.getTime())) return "";
|
||
return TIME_FORMAT.format(parsed);
|
||
}
|
||
|
||
// Show the char counter once the visitor is close enough to the limit that it
|
||
// becomes actionable — no point cluttering the footer for a 20-char message.
|
||
const COUNTER_THRESHOLD = MAX_SUPPORT_MESSAGE_LENGTH - 500;
|
||
|
||
const PANEL_STYLES = `
|
||
.support-chat-send:disabled { opacity: .45; cursor: default; }
|
||
.support-chat-send:not(:disabled):hover { background: ${tokens.accentDeep}; }
|
||
.support-chat-retry:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
|
||
.support-chat-textarea:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(46,139,255,.3); }
|
||
.support-chat-close:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
|
||
`;
|
||
|
||
export function SupportChatPanel({ open, onClose }: SupportChatPanelProps) {
|
||
const dialogRef = useRef<HTMLDivElement>(null);
|
||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||
const listEndRef = useRef<HTMLDivElement>(null);
|
||
const lastFocused = useRef<Element | null>(null);
|
||
const onCloseRef = useRef(onClose);
|
||
onCloseRef.current = onClose;
|
||
|
||
const [draft, setDraft] = useState("");
|
||
|
||
const messagesQuery = useSupportMessages(open);
|
||
const sendMessage = useSendSupportMessage();
|
||
const markRead = useMarkSupportRead();
|
||
|
||
// Mark the thread read on BOTH the closed->open and open->closed edges (the
|
||
// effect fires on mount-while-open and its cleanup fires on the reverse
|
||
// transition/unmount) — see useMarkSupportRead docstring for why both
|
||
// directions matter. Read via a ref so this doesn't need `markRead.mutate`
|
||
// in the dep array (a fresh function identity every render would otherwise
|
||
// re-fire the effect every render).
|
||
const markReadRef = useRef(markRead.mutate);
|
||
markReadRef.current = markRead.mutate;
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
markReadRef.current();
|
||
return () => {
|
||
markReadRef.current();
|
||
};
|
||
}, [open]);
|
||
|
||
// Focus trap + Escape + initial focus into the textarea (req: focus goes to
|
||
// the input on open, not the dialog shell). Mirrors LocationDrawer.tsx.
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const dialog = dialogRef.current;
|
||
lastFocused.current = document.activeElement;
|
||
inputRef.current?.focus();
|
||
|
||
function getFocusable(): HTMLElement[] {
|
||
if (!dialog) return [];
|
||
const nodes = dialog.querySelectorAll<HTMLElement>(
|
||
'button, textarea, [href], input, select, [tabindex]:not([tabindex="-1"])',
|
||
);
|
||
return Array.from(nodes).filter((el) => !el.hasAttribute("disabled"));
|
||
}
|
||
|
||
function onKeyDown(e: KeyboardEvent) {
|
||
if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
onCloseRef.current();
|
||
return;
|
||
}
|
||
if (e.key !== "Tab" || !dialog) return;
|
||
|
||
const focusable = getFocusable();
|
||
if (focusable.length === 0) {
|
||
e.preventDefault();
|
||
dialog.focus();
|
||
return;
|
||
}
|
||
|
||
const first = focusable[0];
|
||
const last = focusable[focusable.length - 1];
|
||
const activeEl = document.activeElement;
|
||
const inside = dialog.contains(activeEl);
|
||
|
||
if (e.shiftKey) {
|
||
if (activeEl === first || activeEl === dialog || !inside) {
|
||
e.preventDefault();
|
||
last.focus();
|
||
}
|
||
} else if (activeEl === last || !inside) {
|
||
e.preventDefault();
|
||
first.focus();
|
||
}
|
||
}
|
||
|
||
document.addEventListener("keydown", onKeyDown);
|
||
return () => {
|
||
document.removeEventListener("keydown", onKeyDown);
|
||
(lastFocused.current as HTMLElement | null)?.focus?.();
|
||
};
|
||
}, [open]);
|
||
|
||
const messages = messagesQuery.data ?? [];
|
||
|
||
// Autoscroll to the latest message on open and whenever the transcript
|
||
// grows (new poll result / own message appended after send).
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
listEndRef.current?.scrollIntoView({ block: "end" });
|
||
}, [open, messages.length]);
|
||
|
||
const trimmedLength = draft.trim().length;
|
||
const overLimit = draft.length > MAX_SUPPORT_MESSAGE_LENGTH;
|
||
const canSend = trimmedLength > 0 && !overLimit && !sendMessage.isPending;
|
||
|
||
function handleSend() {
|
||
if (!canSend) return;
|
||
sendMessage.mutate(draft.trim(), {
|
||
onSuccess: () => setDraft(""),
|
||
});
|
||
}
|
||
|
||
function handleTextareaKeyDown(e: ReactKeyboardEvent<HTMLTextAreaElement>) {
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleSend();
|
||
}
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<style>{PANEL_STYLES}</style>
|
||
<div
|
||
ref={dialogRef}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="Чат поддержки МЕРА"
|
||
tabIndex={-1}
|
||
// Off-screen when closed → keep it out of the a11y tree and inert
|
||
// (mirrors LocationDrawer.tsx — same axe "aria-hidden-focus" concern).
|
||
aria-hidden={!open}
|
||
inert={!open ? true : undefined}
|
||
style={{
|
||
position: "fixed",
|
||
right: 20,
|
||
bottom: 76,
|
||
width: 360,
|
||
height: 520,
|
||
maxHeight: "calc(100vh - 96px)",
|
||
// Below TopNav (50) / UserMenu (200) so it never steals their
|
||
// clicks, but above the floating trigger button (25) and the
|
||
// LocationDrawer scrim/panel (40/41) — the chat window should sit
|
||
// on top of everything except the persistent top chrome.
|
||
zIndex: 45,
|
||
transform: open ? "translateY(0)" : "translateY(10px)",
|
||
opacity: open ? 1 : 0,
|
||
pointerEvents: open ? "auto" : "none",
|
||
transition: "opacity .18s ease, transform .18s ease",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
background: tokens.surface.w98,
|
||
border: `1px solid ${tokens.line}`,
|
||
borderRadius: 14,
|
||
boxShadow: "0 20px 60px rgba(23,38,58,.28)",
|
||
overflow: "hidden",
|
||
fontFamily: tokens.font.sans,
|
||
}}
|
||
>
|
||
{/* header */}
|
||
<div
|
||
style={{
|
||
flex: "0 0 auto",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
padding: "12px 14px",
|
||
borderBottom: `1px solid ${tokens.lineSoft}`,
|
||
background: tokens.surface.w70,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontSize: 13,
|
||
fontWeight: 600,
|
||
color: tokens.ink2,
|
||
letterSpacing: ".2px",
|
||
}}
|
||
>
|
||
Поддержка МЕРА
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="support-chat-close"
|
||
onClick={onClose}
|
||
aria-label="Закрыть чат поддержки"
|
||
style={{
|
||
width: 28,
|
||
height: 28,
|
||
flex: "0 0 auto",
|
||
padding: 0,
|
||
border: `1px solid ${tokens.line}`,
|
||
borderRadius: 6,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
cursor: "pointer",
|
||
color: tokens.muted,
|
||
background: tokens.surface.w60,
|
||
fontFamily: "inherit",
|
||
fontSize: 14,
|
||
lineHeight: 1,
|
||
transition: "all .15s",
|
||
}}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
{/* message list */}
|
||
<div
|
||
style={{
|
||
flex: "1 1 auto",
|
||
overflowY: "auto",
|
||
padding: "12px 14px",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 8,
|
||
}}
|
||
>
|
||
{messagesQuery.isLoading ? (
|
||
<div
|
||
style={{ fontSize: 12, color: tokens.muted3, textAlign: "center", marginTop: 24 }}
|
||
>
|
||
Загрузка сообщений…
|
||
</div>
|
||
) : messagesQuery.isError ? (
|
||
<div
|
||
style={{
|
||
fontSize: 12,
|
||
color: tokens.body2,
|
||
background: tokens.infoSoftBg,
|
||
border: `1px solid ${tokens.infoSoftBorder}`,
|
||
borderRadius: 8,
|
||
padding: "10px 12px",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<span>{describeSupportError(messagesQuery.error)}</span>
|
||
<button
|
||
type="button"
|
||
className="support-chat-retry"
|
||
onClick={() => messagesQuery.refetch()}
|
||
style={{
|
||
alignSelf: "flex-start",
|
||
border: `1px solid ${tokens.line}`,
|
||
borderRadius: 6,
|
||
padding: "4px 10px",
|
||
fontSize: 11,
|
||
color: tokens.body2,
|
||
background: tokens.surface.w80,
|
||
cursor: "pointer",
|
||
fontFamily: "inherit",
|
||
transition: "all .15s",
|
||
}}
|
||
>
|
||
Повторить
|
||
</button>
|
||
</div>
|
||
) : messages.length === 0 ? (
|
||
<div
|
||
style={{
|
||
fontSize: 12.5,
|
||
lineHeight: 1.6,
|
||
color: tokens.body2,
|
||
background: tokens.infoSoftBg,
|
||
border: `1px solid ${tokens.infoSoftBorder}`,
|
||
borderRadius: 8,
|
||
padding: "12px 14px",
|
||
}}
|
||
>
|
||
Здравствуйте! Опишите вопрос — оператор отвечает в рабочее время,
|
||
обычно в течение часа.
|
||
</div>
|
||
) : (
|
||
messages.map((m) => <SupportMessageBubble key={m.id} message={m} />)
|
||
)}
|
||
<div ref={listEndRef} />
|
||
</div>
|
||
|
||
{/* send-error banner */}
|
||
{sendMessage.isError && (
|
||
<div
|
||
style={{
|
||
flex: "0 0 auto",
|
||
margin: "0 14px 8px",
|
||
fontSize: 11.5,
|
||
color: tokens.danger,
|
||
background: "rgba(205,104,104,.08)",
|
||
border: "1px solid rgba(205,104,104,.3)",
|
||
borderRadius: 7,
|
||
padding: "7px 10px",
|
||
}}
|
||
>
|
||
{describeSupportError(sendMessage.error)}
|
||
</div>
|
||
)}
|
||
|
||
{/* composer */}
|
||
<div
|
||
style={{
|
||
flex: "0 0 auto",
|
||
borderTop: `1px solid ${tokens.lineSoft}`,
|
||
padding: "10px 12px",
|
||
background: tokens.surface.w80,
|
||
}}
|
||
>
|
||
<textarea
|
||
ref={inputRef}
|
||
className="support-chat-textarea"
|
||
value={draft}
|
||
onChange={(e) => setDraft(e.target.value)}
|
||
onKeyDown={handleTextareaKeyDown}
|
||
placeholder="Напишите сообщение…"
|
||
aria-label="Сообщение в поддержку"
|
||
rows={2}
|
||
style={{
|
||
width: "100%",
|
||
resize: "none",
|
||
boxSizing: "border-box",
|
||
border: `1px solid ${overLimit ? tokens.danger : tokens.line}`,
|
||
borderRadius: 8,
|
||
padding: "8px 10px",
|
||
fontFamily: "inherit",
|
||
fontSize: 12.5,
|
||
color: tokens.ink2,
|
||
background: tokens.surface.w98,
|
||
}}
|
||
/>
|
||
<div
|
||
style={{
|
||
marginTop: 8,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<a
|
||
href={SUPPORT_BOT_URL}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{
|
||
fontSize: 10.5,
|
||
color: tokens.muted3,
|
||
textDecoration: "underline",
|
||
}}
|
||
>
|
||
написать в Telegram
|
||
</a>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||
{draft.length > COUNTER_THRESHOLD && (
|
||
<span
|
||
style={{
|
||
fontSize: 10,
|
||
fontFamily: tokens.font.mono,
|
||
color: overLimit ? tokens.danger : tokens.muted3,
|
||
}}
|
||
>
|
||
{draft.length}/{MAX_SUPPORT_MESSAGE_LENGTH}
|
||
</span>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="support-chat-send"
|
||
onClick={handleSend}
|
||
disabled={!canSend}
|
||
aria-label="Отправить сообщение"
|
||
style={{
|
||
border: "none",
|
||
borderRadius: 7,
|
||
padding: "7px 16px",
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
color: tokens.onAccent,
|
||
background: tokens.accent,
|
||
cursor: canSend ? "pointer" : "default",
|
||
fontFamily: "inherit",
|
||
transition: "background .15s",
|
||
}}
|
||
>
|
||
Отправить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function SupportMessageBubble({ message }: { message: SupportMessage }) {
|
||
const isOwn = message.direction === "in";
|
||
return (
|
||
<div style={{ display: "flex", justifyContent: isOwn ? "flex-end" : "flex-start" }}>
|
||
<div
|
||
style={{
|
||
maxWidth: "78%",
|
||
background: isOwn ? tokens.accent : tokens.surface.w80,
|
||
color: isOwn ? tokens.onAccent : tokens.ink2,
|
||
border: isOwn ? "none" : `1px solid ${tokens.lineSoft}`,
|
||
borderRadius: 10,
|
||
padding: "8px 10px",
|
||
}}
|
||
>
|
||
{/* Plain text node — operator replies are untrusted input, never
|
||
rendered as HTML. `pre-wrap` preserves newlines/whitespace without
|
||
needing dangerouslySetInnerHTML. */}
|
||
<div style={{ fontSize: 12.5, lineHeight: 1.5, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||
{message.text_body}
|
||
</div>
|
||
<div
|
||
style={{
|
||
marginTop: 4,
|
||
fontSize: 9,
|
||
opacity: 0.75,
|
||
textAlign: isOwn ? "right" : "left",
|
||
}}
|
||
>
|
||
{formatTime(message.created_at)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|