gendesign/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatPanel.tsx
bot-backend d9e675e7ae feat(tradein/support): on-site chat window instead of Telegram redirect
Клиент теперь общается с поддержкой прямо на странице /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) — не ворует клики у верхней навигации.
2026-07-26 23:14:24 +03:00

475 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}