Клиент теперь общается с поддержкой прямо на странице /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) — не ворует клики у верхней навигации.
146 lines
5.8 KiB
TypeScript
146 lines
5.8 KiB
TypeScript
"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,
|
||
);
|
||
}
|