"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(null); const inputRef = useRef(null); const listEndRef = useRef(null); const lastFocused = useRef(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( '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) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } } return ( <>
{/* header */}
Поддержка МЕРА
{/* message list */}
{messagesQuery.isLoading ? (
Загрузка сообщений…
) : messagesQuery.isError ? (
{describeSupportError(messagesQuery.error)}
) : messages.length === 0 ? (
Здравствуйте! Опишите вопрос — оператор отвечает в рабочее время, обычно в течение часа.
) : ( messages.map((m) => ) )}
{/* send-error banner */} {sendMessage.isError && (
{describeSupportError(sendMessage.error)}
)} {/* composer */}