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) — не ворует клики у верхней навигации.
This commit is contained in:
bot-backend 2026-07-26 23:14:24 +03:00
parent d56103219a
commit d9e675e7ae
6 changed files with 776 additions and 53 deletions

View file

@ -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. */}
<SupportButton />
{/* SupportChatProvider wraps both {children} (TopNav's "Помощь" item
lives deep inside it, in page.tsx) and <SupportButton /> (mounted
here as a sibling, portaled to document.body) they share one
open/closed chat state via context, see SupportChatContext.tsx. */}
<SupportChatProvider>
{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. */}
<SupportButton />
</SupportChatProvider>
</div>
);
}

View file

@ -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(
<>
<style>{styles}</style>
<a
href={SUPPORT_BOT_URL}
target="_blank"
rel="noopener noreferrer"
<button
type="button"
onClick={toggleChat}
className="support-btn"
aria-label="Написать в поддержку МЕРА в Telegram"
title="Поддержка МЕРА в Telegram"
aria-label={open ? "Закрыть чат поддержки МЕРА" : "Открыть чат поддержки МЕРА"}
aria-expanded={open}
title="Поддержка МЕРА"
style={{
position: "fixed",
right: 20,
bottom: 20,
// z-index 25 — deliberately BELOW every v2 HUD overlay: SectionOverlay
// (29/30), LocationDrawer (40/41), TopNav (50), UserMenu (200), and
// the legacy MapPicker dialog (1000). When a modal/drawer is open the
// (29/30), LocationDrawer (40/41), the chat panel this button opens
// (45), TopNav (50), UserMenu (200). When a modal/drawer is open the
// button should disappear under it, not float on top and steal
// clicks/tab order. It never collides with TopNav geographically
// (top of screen vs. bottom-right corner here), so this only matters
// for the bottom-sheet-style overlays.
// for the bottom-sheet-style overlays and the chat panel itself.
zIndex: 25,
display: "flex",
alignItems: "center",
@ -90,7 +96,6 @@ export function SupportButton() {
fontSize: 12,
fontWeight: 600,
letterSpacing: 0.4,
textDecoration: "none",
whiteSpace: "nowrap",
cursor: "pointer",
}}
@ -109,7 +114,32 @@ export function SupportButton() {
/>
</svg>
<span>Поддержка</span>
</a>
{!open && unread > 0 && (
<span
aria-hidden="true"
style={{
position: "absolute",
top: -5,
right: -5,
minWidth: 18,
height: 18,
padding: "0 4px",
borderRadius: 999,
background: danger,
color: onAccent,
fontSize: 10,
fontWeight: 700,
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 0 0 2px #fff",
}}
>
{unread > 9 ? "9+" : unread}
</span>
)}
</button>
<SupportChatPanel open={open} onClose={closeChat} />
</>,
document.body,
);

View file

@ -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
// `<SupportButton />`, 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<SupportChatContextValue | null>(null);
export function SupportChatProvider({
children,
}: {
children: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
const value = useMemo<SupportChatContextValue>(
() => ({
open,
openChat: () => setOpen(true),
closeChat: () => setOpen(false),
toggleChat: () => setOpen((o) => !o),
}),
[open],
);
return (
<SupportChatContext.Provider value={value}>
{children}
</SupportChatContext.Provider>
);
}
export function useSupportChat(): SupportChatContextValue {
const ctx = useContext(SupportChatContext);
if (!ctx) {
throw new Error(
"useSupportChat must be used within <SupportChatProvider> (app/v2/layout.tsx)",
);
}
return ctx;
}

View file

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

View file

@ -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({
</div>
{/* 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. */}
<a
href={SUPPORT_BOT_URL}
target="_blank"
rel="noopener noreferrer"
SupportButton.tsx's floating button). Both open the same
chat window (SupportChatContext) no more external
Telegram navigation from here. Styled like the other live
row above ("Мои отчёты"), not the dimmed Профиль/Настройки
rows. */}
<button
type="button"
className="tnav-menuitem"
style={{ ...menuItemStyle, textDecoration: "none" }}
aria-label="Написать в поддержку МЕРА в Telegram"
title="Написать в поддержку МЕРА в Telegram"
onClick={() => setUserOpen(false)}
style={{
...menuItemStyle,
width: "100%",
border: "none",
background: "none",
fontFamily: "inherit",
textAlign: "left",
}}
aria-label="Открыть чат поддержки МЕРА"
title="Открыть чат поддержки МЕРА"
onClick={() => {
setUserOpen(false);
openChat();
}}
>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
<circle
@ -476,7 +487,7 @@ export default function TopNav({
/>
</svg>
Помощь
</a>
</button>
<div
style={{

View file

@ -0,0 +1,138 @@
"use client";
/**
* Data layer for the on-site МЕРА support chat (issue: "окно чата поддержки на
* сайте МЕРА"). Talks to `feat/tradein-web-chat-backend` (app/api/v1/support.py,
* not yet in main at the time this was written contract is stable/reviewed):
*
* POST /api/v1/trade-in/support/messages {text} -> SupportMessage
* GET /api/v1/trade-in/support/messages?since=<id> -> 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=<lastId>` 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<SupportMessage[]>({
queryKey: SUPPORT_MESSAGES_KEY,
queryFn: () => apiFetch<SupportMessage[]>(`${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<SupportUnread>({
queryKey: SUPPORT_UNREAD_KEY,
queryFn: () => apiFetch<SupportUnread>(`${BASE}/unread`),
enabled,
staleTime: 0,
refetchInterval: enabled ? UNREAD_POLL_MS : false,
});
}
export function useSendSupportMessage() {
const queryClient = useQueryClient();
return useMutation<SupportMessage, Error, string>({
mutationFn: (text) =>
apiFetch<SupportMessage>(`${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<void, Error, void>({
mutationFn: async () => {
await apiFetch<{ status: string }>(`${BASE}/read`, { method: "POST" });
},
onSuccess: () => {
queryClient.setQueryData<SupportUnread>(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 "Не удалось связаться с сервером. Проверьте подключение и повторите.";
}