diff --git a/tradein-mvp/frontend/src/app/v2/layout.tsx b/tradein-mvp/frontend/src/app/v2/layout.tsx
index ecc487d8..bf603195 100644
--- a/tradein-mvp/frontend/src/app/v2/layout.tsx
+++ b/tradein-mvp/frontend/src/app/v2/layout.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { IBM_Plex_Mono, Manrope } from "next/font/google";
import { SupportButton } from "@/components/trade-in/v2/SupportButton";
+import { SupportChatProvider } from "@/components/trade-in/v2/SupportChatContext";
import { pageBg } from "@/components/trade-in/v2/tokens";
// Manrope — primary sans typeface of the МЕРА HUD. next/font is bundled
@@ -41,11 +42,17 @@ export default function TradeInV2Layout({
alignItems: "flex-start",
}}
>
- {children}
- {/* Mounted here (not the global app/layout.tsx) — that layout also
- covers the admin `/scrapers/**` area and `/sale-share`, unrelated
- products without the МЕРА brand that don't need a support link. */}
-
+ {/* SupportChatProvider wraps both {children} (TopNav's "Помощь" item
+ lives deep inside it, in page.tsx) and (mounted
+ here as a sibling, portaled to document.body) — they share one
+ open/closed chat state via context, see SupportChatContext.tsx. */}
+
+ {children}
+ {/* Mounted here (not the global app/layout.tsx) — that layout also
+ covers the admin `/scrapers/**` area and `/sale-share`, unrelated
+ products without the МЕРА brand that don't need a support link. */}
+
+
);
}
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SupportButton.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SupportButton.tsx
index 2a41b0f4..e3cd0b8b 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/SupportButton.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/SupportButton.tsx
@@ -1,39 +1,39 @@
"use client";
-// SupportButton — floating link to the МЕРА Telegram support bot.
+// SupportButton — floating trigger for the on-site МЕРА support chat.
//
-// WHY: before this, there was no way to reach support from the site.
-// `LeadForm` (LeadForm.tsx) only mounts once `hasEstimate` is true
+// WHY (original v1, before this window): before the plain-link version of
+// this button existed, there was no way to reach support from the site at
+// all. `LeadForm` (LeadForm.tsx) only mounts once `hasEstimate` is true
// (app/v2/page.tsx:1064), so a visitor who hasn't finished (or can't finish)
-// an estimate has no contact path at all. The only other link anywhere in the
-// UI is NoAccessScreen.tsx:84, and that only renders on the "access expired"
-// screen. The support bot itself is already live on the backend (support
-// bridge, PR #2526) — this button is purely the missing entry point on the
-// site side. Plain external link, no chat widget / no state / no API calls.
+// an estimate had no contact path. This was upgraded from a plain external
+// Telegram link to an in-page chat window (SupportChatPanel.tsx) so a visitor
+// answers/receives replies without leaving the site — the message still
+// mirrors into the same Telegram support topic and the operator still
+// answers from Telegram exactly as before (backend `feat/tradein-web-chat-
+// backend`, itself layered on the Telegram support bridge, backend PR #2526).
+// The Telegram link survives as a small fallback INSIDE the chat panel
+// (SupportChatPanel.tsx) — deliberate, since there's no push notification for
+// the web chat: a visitor who leaves the page won't see a reply that arrives
+// an hour later, so the Telegram escape hatch stays available.
//
// Portal to document.body (pattern mirrors MapPicker.tsx:104-108 and
// BuildingListingsDrawer.tsx): `/v2` renders its HUD inside a fixed
// 1536×1024 "artboard" that gets `transform: scale(...)` on narrow viewports
// (app/v2/page.tsx:860-871). A `position: fixed` descendant of a
// `transform`-ed ancestor is positioned relative to that ancestor, not the
-// viewport — so without a portal the button would drift/scale with the HUD
-// instead of staying pinned to the real viewport corner.
+// viewport — so without a portal the button (and the chat panel it opens)
+// would drift/scale with the HUD instead of staying pinned to the real
+// viewport corner.
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { tokens } from "./tokens";
+import { useSupportChat } from "./SupportChatContext";
+import { SupportChatPanel } from "./SupportChatPanel";
+import { useSupportUnread } from "@/lib/useSupportChat";
-// Same bot as the backend support bridge (PR #2526). A plain module constant
-// rather than `NEXT_PUBLIC_*`: the URL is public and stable, and env vars are
-// inlined at build time — swapping the value would need a rebuild for no
-// benefit over just editing this line.
-//
-// Exported: this is the single source of truth for the bot URL. The «Помощь»
-// item in TopNav.tsx's user menu links to the same bot and imports this
-// constant rather than duplicating the string.
-export const SUPPORT_BOT_URL = "https://t.me/MERAsupport_bot";
-
-const { accent, accentDeep, onAccent, surface, font } = tokens;
+const { accent, accentDeep, onAccent, surface, font, danger } = tokens;
const styles = `
.support-btn{background:linear-gradient(90deg,${accentDeep},${accent});transition:box-shadow .18s,transform .18s,background .18s;}
@@ -51,29 +51,35 @@ export function SupportButton() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
+ const { open, toggleChat, closeChat } = useSupportChat();
+ // Unread badge only matters while the panel is closed — see
+ // useSupportUnread's docstring for why polling stops entirely once open.
+ const unreadQuery = useSupportUnread(!open);
+ const unread = unreadQuery.data?.unread ?? 0;
+
if (!mounted) return null;
return createPortal(
<>
- Поддержка
-
+ {!open && unread > 0 && (
+
+ {unread > 9 ? "9+" : unread}
+
+ )}
+
+
>,
document.body,
);
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatContext.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatContext.tsx
new file mode 100644
index 00000000..0e1552ee
--- /dev/null
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatContext.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+// Shared open/closed state for the on-site support chat window. It needs a
+// single source of truth reachable from two components that are NOT parent/
+// child of each other in the render tree: `SupportButton` (mounted once in
+// app/v2/layout.tsx, portals the floating trigger + the chat panel to
+// document.body) and `TopNav`'s "Помощь" menu item (mounted deep inside
+// app/v2/page.tsx, i.e. inside `{children}` of that same layout). A React
+// Context provided by the layout, wrapping both `{children}` and
+// ``, is the plain way to bridge that without prop-drilling
+// through everything page.tsx passes down to TopNav.
+import { createContext, useContext, useMemo, useState } from "react";
+
+// Same bot as the Telegram support bridge (backend PR #2526). Kept here (not
+// in SupportButton.tsx) so SupportChatPanel can import it without a
+// SupportButton <-> SupportChatPanel circular import (SupportButton renders
+// the panel). This is still the single source of truth for the URL — the
+// panel's small "написать в Telegram" fallback link imports it from here.
+export const SUPPORT_BOT_URL = "https://t.me/MERAsupport_bot";
+
+interface SupportChatContextValue {
+ open: boolean;
+ openChat: () => void;
+ closeChat: () => void;
+ toggleChat: () => void;
+}
+
+const SupportChatContext = createContext(null);
+
+export function SupportChatProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const [open, setOpen] = useState(false);
+
+ const value = useMemo(
+ () => ({
+ open,
+ openChat: () => setOpen(true),
+ closeChat: () => setOpen(false),
+ toggleChat: () => setOpen((o) => !o),
+ }),
+ [open],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useSupportChat(): SupportChatContextValue {
+ const ctx = useContext(SupportChatContext);
+ if (!ctx) {
+ throw new Error(
+ "useSupportChat must be used within (app/v2/layout.tsx)",
+ );
+ }
+ return ctx;
+}
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatPanel.tsx
new file mode 100644
index 00000000..dc00586a
--- /dev/null
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatPanel.tsx
@@ -0,0 +1,475 @@
+"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 ? (
+
+ Здравствуйте! Опишите вопрос — оператор отвечает в рабочее время,
+ обычно в течение часа.
+
+ {/* Plain text node — operator replies are untrusted input, never
+ rendered as HTML. `pre-wrap` preserves newlines/whitespace without
+ needing dangerouslySetInnerHTML. */}
+
+ {message.text_body}
+
+
+ {formatTime(message.created_at)}
+
+
+
+ );
+}
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx
index a4cf0e04..fd7f78ea 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx
@@ -12,7 +12,7 @@ import type { CSSProperties } from "react";
import { tokens } from "./tokens";
import { navLabels, version } from "./fixtures";
-import { SUPPORT_BOT_URL } from "./SupportButton";
+import { useSupportChat } from "./SupportChatContext";
// Real logged-in user identity, derived by the page from useMe()
// ({username, role, brand}). Deliberately excludes `reports` — the «Мои
@@ -59,8 +59,9 @@ const menuItemStyle: CSSProperties = {
// Профиль / Настройки have no pages yet — render them dimmed and
// non-interactive (no hover class, default cursor) so they read as disabled.
-// «Помощь» used to be in this group too, but now links out to the Telegram
-// support bot (see SUPPORT_BOT_URL below) — it renders as a live item.
+// «Помощь» used to be in this group too, then linked out to the Telegram
+// support bot; it now opens the on-site chat window (SupportChatPanel.tsx via
+// SupportChatContext) — it renders as a live item.
const menuItemDisabledStyle: CSSProperties = {
...menuItemStyle,
cursor: "default",
@@ -75,6 +76,7 @@ export default function TopNav({
onLogout,
}: TopNavProps) {
const [userOpen, setUserOpen] = useState(false);
+ const { openChat } = useSupportChat();
const u = user ?? GUEST_USER;
return (
@@ -447,19 +449,28 @@ export default function TopNav({
{/* Live item (issue: two support entry points — the other is
- SupportButton.tsx's floating button). Same bot, imported
- constant — single source of truth for the URL. Styled like
- the other live row above ("Мои отчёты"), not the dimmed
- Профиль/Настройки rows. */}
- setUserOpen(false)}
+ style={{
+ ...menuItemStyle,
+ width: "100%",
+ border: "none",
+ background: "none",
+ fontFamily: "inherit",
+ textAlign: "left",
+ }}
+ aria-label="Открыть чат поддержки МЕРА"
+ title="Открыть чат поддержки МЕРА"
+ onClick={() => {
+ setUserOpen(false);
+ openChat();
+ }}
>
Помощь
-
+
SupportMessage
+ * GET /api/v1/trade-in/support/messages?since= -> SupportMessage[]
+ * GET /api/v1/trade-in/support/unread -> {unread}
+ * POST /api/v1/trade-in/support/read -> {status:"ok"}
+ *
+ * Thread is resolved server-side from X-Authenticated-User (injected by Caddy
+ * basic_auth upstream) — no thread_id is ever sent from the client.
+ *
+ * Polling model (see SupportChatPanel.tsx / SupportButton.tsx for the two call
+ * sites):
+ * - Messages: polled only while the chat panel is OPEN (`enabled` gate below)
+ * — no point polling a transcript nobody is looking at.
+ * - Unread count: polled only while the panel is CLOSED — it feeds the badge
+ * on the floating button; once open, the transcript itself is the "seen"
+ * signal (see markSupportRead below) and the badge doesn't render.
+ * - Tab-inactive pause is NOT reimplemented here — `refetchInterval`'s
+ * `refetchIntervalInBackground` defaults to `false` in TanStack Query, so
+ * polling already stops while `document` is hidden/backgrounded without
+ * any extra visibilitychange plumbing. Only the panel-open/closed gate
+ * above is bespoke.
+ *
+ * Always re-fetches the FULL thread (`since=0`) rather than incrementally
+ * merging `since=` pages into the query cache: at MVP support-chat
+ * volume (a handful to a few dozen messages per thread) the bandwidth cost of
+ * refetching everything every 6s is negligible, and it avoids a manual
+ * setQueryData-merge path that would be pure incidental complexity here.
+ */
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+import { apiFetch, HTTPError } from "@/lib/api";
+
+const BASE = "/api/v1/trade-in/support";
+
+// Mirrors backend `MAX_MESSAGE_LENGTH` (app/api/v1/support.py) — enforced
+// client-side too so the send button disables before the round-trip 422/400.
+export const MAX_SUPPORT_MESSAGE_LENGTH = 4000;
+
+export type SupportMessageDirection = "in" | "out";
+
+export interface SupportMessage {
+ id: number;
+ direction: SupportMessageDirection;
+ text_body: string;
+ operator_tg_id: number | null;
+ created_at: string;
+}
+
+export interface SupportUnread {
+ unread: number;
+}
+
+const SUPPORT_MESSAGES_KEY = ["trade-in", "support", "messages"] as const;
+const SUPPORT_UNREAD_KEY = ["trade-in", "support", "unread"] as const;
+
+const MESSAGES_POLL_MS = 6_000;
+const UNREAD_POLL_MS = 20_000;
+
+/**
+ * Polls the caller's own thread. `enabled` should be the chat-panel `open`
+ * flag — see module docstring.
+ */
+export function useSupportMessages(enabled: boolean) {
+ return useQuery({
+ queryKey: SUPPORT_MESSAGES_KEY,
+ queryFn: () => apiFetch(`${BASE}/messages?since=0`),
+ enabled,
+ staleTime: 0,
+ refetchInterval: enabled ? MESSAGES_POLL_MS : false,
+ });
+}
+
+/**
+ * Feeds the unread badge on the closed floating button. `enabled` should be
+ * `!open` — see module docstring.
+ */
+export function useSupportUnread(enabled: boolean) {
+ return useQuery({
+ queryKey: SUPPORT_UNREAD_KEY,
+ queryFn: () => apiFetch(`${BASE}/unread`),
+ enabled,
+ staleTime: 0,
+ refetchInterval: enabled ? UNREAD_POLL_MS : false,
+ });
+}
+
+export function useSendSupportMessage() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (text) =>
+ apiFetch(`${BASE}/messages`, {
+ method: "POST",
+ body: JSON.stringify({ text }),
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: SUPPORT_MESSAGES_KEY });
+ },
+ });
+}
+
+/**
+ * Marks the thread read (moves backend `last_read_at` forward). Called on the
+ * open->closed AND closed->open transition (see SupportChatPanel) so the
+ * unread badge never counts messages the visitor demonstrably already saw
+ * while the panel was open.
+ */
+export function useMarkSupportRead() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async () => {
+ await apiFetch<{ status: string }>(`${BASE}/read`, { method: "POST" });
+ },
+ onSuccess: () => {
+ queryClient.setQueryData(SUPPORT_UNREAD_KEY, { unread: 0 });
+ },
+ });
+}
+
+/**
+ * Human-readable error text for both the messages-query error state and the
+ * send-mutation error banner. Backend already returns RU human text in
+ * `detail` for 429/503/502 (SERVICE_UNAVAILABLE_TEXT / rate-limit message) —
+ * `HTTPError.message` is that `detail` (see lib/api.ts). A non-HTTPError means
+ * `fetch` itself threw (offline / DNS / CORS) — apiFetch never got a response.
+ */
+export function describeSupportError(error: unknown): string {
+ if (error instanceof HTTPError) {
+ return error.message;
+ }
+ return "Не удалось связаться с сервером. Проверьте подключение и повторите.";
+}