gendesign/tradein-mvp/frontend/src/components/trade-in/v2/SupportButton.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

146 lines
5.8 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";
// SupportButton — floating trigger for the on-site МЕРА support chat.
//
// 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 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 (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";
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;}
.support-btn:hover{background:${accentDeep};box-shadow:0 6px 20px rgba(13,111,214,.4);}
.support-btn:active{transform:translateY(1px);}
.support-btn:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(46,139,255,.45);}
@media (max-width: 480px){
.support-btn{right:14px !important;bottom:14px !important;padding:0 14px !important;}
}
`;
export function SupportButton() {
// Portal-mount guard (SSR-safe): `document` only exists after mount
// (mirrors MapPicker.tsx:107-108 / BuildingListingsDrawer.tsx:29-30).
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>
<button
type="button"
onClick={toggleChat}
className="support-btn"
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), 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 and the chat panel itself.
zIndex: 25,
display: "flex",
alignItems: "center",
gap: 8,
height: 44,
minWidth: 44,
boxSizing: "border-box",
padding: "0 16px",
borderRadius: 999,
border: `1px solid ${surface.w40}`,
boxShadow: "0 4px 14px rgba(13,111,214,.3)",
color: onAccent,
fontFamily: font.sans,
fontSize: 12,
fontWeight: 600,
letterSpacing: 0.4,
whiteSpace: "nowrap",
cursor: "pointer",
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
focusable="false"
>
<path
d="M21.05 3.16 2.98 10.42c-1.24.51-1.23 1.22-.23 1.53l4.63 1.45 1.79 5.5c.22.6.37.84.75.84.35 0 .5-.16.7-.35l1.7-1.65 3.63 2.68c.67.37 1.15.18 1.32-.62l2.39-11.27c.26-1.03-.39-1.5-1.61-.87z"
fill={onAccent}
/>
</svg>
<span>Поддержка</span>
{!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,
);
}