feat(tradein/ui): окно чата поддержки на сайте вместо ухода в Telegram (#2533)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m11s
Deploy Trade-In / deploy (push) Successful in 51s
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m11s
Deploy Trade-In / deploy (push) Successful in 51s
This commit is contained in:
parent
ca1015bd4e
commit
a0647a53a9
6 changed files with 776 additions and 53 deletions
|
|
@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||||
import { IBM_Plex_Mono, Manrope } from "next/font/google";
|
import { IBM_Plex_Mono, Manrope } from "next/font/google";
|
||||||
|
|
||||||
import { SupportButton } from "@/components/trade-in/v2/SupportButton";
|
import { SupportButton } from "@/components/trade-in/v2/SupportButton";
|
||||||
|
import { SupportChatProvider } from "@/components/trade-in/v2/SupportChatContext";
|
||||||
import { pageBg } from "@/components/trade-in/v2/tokens";
|
import { pageBg } from "@/components/trade-in/v2/tokens";
|
||||||
|
|
||||||
// Manrope — primary sans typeface of the МЕРА HUD. next/font is bundled
|
// Manrope — primary sans typeface of the МЕРА HUD. next/font is bundled
|
||||||
|
|
@ -41,11 +42,17 @@ export default function TradeInV2Layout({
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* 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}
|
{children}
|
||||||
{/* Mounted here (not the global app/layout.tsx) — that layout also
|
{/* Mounted here (not the global app/layout.tsx) — that layout also
|
||||||
covers the admin `/scrapers/**` area and `/sale-share`, unrelated
|
covers the admin `/scrapers/**` area and `/sale-share`, unrelated
|
||||||
products without the МЕРА brand that don't need a support link. */}
|
products without the МЕРА brand that don't need a support link. */}
|
||||||
<SupportButton />
|
<SupportButton />
|
||||||
|
</SupportChatProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,39 @@
|
||||||
"use client";
|
"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.
|
// WHY (original v1, before this window): before the plain-link version of
|
||||||
// `LeadForm` (LeadForm.tsx) only mounts once `hasEstimate` is true
|
// 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)
|
// (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
|
// an estimate had no contact path. This was upgraded from a plain external
|
||||||
// UI is NoAccessScreen.tsx:84, and that only renders on the "access expired"
|
// Telegram link to an in-page chat window (SupportChatPanel.tsx) so a visitor
|
||||||
// screen. The support bot itself is already live on the backend (support
|
// answers/receives replies without leaving the site — the message still
|
||||||
// bridge, PR #2526) — this button is purely the missing entry point on the
|
// mirrors into the same Telegram support topic and the operator still
|
||||||
// site side. Plain external link, no chat widget / no state / no API calls.
|
// 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
|
// Portal to document.body (pattern mirrors MapPicker.tsx:104-108 and
|
||||||
// BuildingListingsDrawer.tsx): `/v2` renders its HUD inside a fixed
|
// BuildingListingsDrawer.tsx): `/v2` renders its HUD inside a fixed
|
||||||
// 1536×1024 "artboard" that gets `transform: scale(...)` on narrow viewports
|
// 1536×1024 "artboard" that gets `transform: scale(...)` on narrow viewports
|
||||||
// (app/v2/page.tsx:860-871). A `position: fixed` descendant of a
|
// (app/v2/page.tsx:860-871). A `position: fixed` descendant of a
|
||||||
// `transform`-ed ancestor is positioned relative to that ancestor, not the
|
// `transform`-ed ancestor is positioned relative to that ancestor, not the
|
||||||
// viewport — so without a portal the button would drift/scale with the HUD
|
// viewport — so without a portal the button (and the chat panel it opens)
|
||||||
// instead of staying pinned to the real viewport corner.
|
// would drift/scale with the HUD instead of staying pinned to the real
|
||||||
|
// viewport corner.
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
import { tokens } from "./tokens";
|
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
|
const { accent, accentDeep, onAccent, surface, font, danger } = tokens;
|
||||||
// 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 styles = `
|
const styles = `
|
||||||
.support-btn{background:linear-gradient(90deg,${accentDeep},${accent});transition:box-shadow .18s,transform .18s,background .18s;}
|
.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);
|
const [mounted, setMounted] = useState(false);
|
||||||
useEffect(() => setMounted(true), []);
|
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;
|
if (!mounted) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<>
|
<>
|
||||||
<style>{styles}</style>
|
<style>{styles}</style>
|
||||||
<a
|
<button
|
||||||
href={SUPPORT_BOT_URL}
|
type="button"
|
||||||
target="_blank"
|
onClick={toggleChat}
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="support-btn"
|
className="support-btn"
|
||||||
aria-label="Написать в поддержку МЕРА в Telegram"
|
aria-label={open ? "Закрыть чат поддержки МЕРА" : "Открыть чат поддержки МЕРА"}
|
||||||
title="Поддержка МЕРА в Telegram"
|
aria-expanded={open}
|
||||||
|
title="Поддержка МЕРА"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
right: 20,
|
right: 20,
|
||||||
bottom: 20,
|
bottom: 20,
|
||||||
// z-index 25 — deliberately BELOW every v2 HUD overlay: SectionOverlay
|
// z-index 25 — deliberately BELOW every v2 HUD overlay: SectionOverlay
|
||||||
// (29/30), LocationDrawer (40/41), TopNav (50), UserMenu (200), and
|
// (29/30), LocationDrawer (40/41), the chat panel this button opens
|
||||||
// the legacy MapPicker dialog (1000). When a modal/drawer is open the
|
// (45), TopNav (50), UserMenu (200). When a modal/drawer is open the
|
||||||
// button should disappear under it, not float on top and steal
|
// button should disappear under it, not float on top and steal
|
||||||
// clicks/tab order. It never collides with TopNav geographically
|
// clicks/tab order. It never collides with TopNav geographically
|
||||||
// (top of screen vs. bottom-right corner here), so this only matters
|
// (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,
|
zIndex: 25,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|
@ -90,7 +96,6 @@ export function SupportButton() {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
letterSpacing: 0.4,
|
letterSpacing: 0.4,
|
||||||
textDecoration: "none",
|
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
|
|
@ -109,7 +114,32 @@ export function SupportButton() {
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Поддержка</span>
|
<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,
|
document.body,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ import type { CSSProperties } from "react";
|
||||||
|
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { navLabels, version } from "./fixtures";
|
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()
|
// Real logged-in user identity, derived by the page from useMe()
|
||||||
// ({username, role, brand}). Deliberately excludes `reports` — the «Мои
|
// ({username, role, brand}). Deliberately excludes `reports` — the «Мои
|
||||||
|
|
@ -59,8 +59,9 @@ const menuItemStyle: CSSProperties = {
|
||||||
|
|
||||||
// Профиль / Настройки have no pages yet — render them dimmed and
|
// Профиль / Настройки have no pages yet — render them dimmed and
|
||||||
// non-interactive (no hover class, default cursor) so they read as disabled.
|
// 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
|
// «Помощь» used to be in this group too, then linked out to the Telegram
|
||||||
// support bot (see SUPPORT_BOT_URL below) — it renders as a live item.
|
// support bot; it now opens the on-site chat window (SupportChatPanel.tsx via
|
||||||
|
// SupportChatContext) — it renders as a live item.
|
||||||
const menuItemDisabledStyle: CSSProperties = {
|
const menuItemDisabledStyle: CSSProperties = {
|
||||||
...menuItemStyle,
|
...menuItemStyle,
|
||||||
cursor: "default",
|
cursor: "default",
|
||||||
|
|
@ -75,6 +76,7 @@ export default function TopNav({
|
||||||
onLogout,
|
onLogout,
|
||||||
}: TopNavProps) {
|
}: TopNavProps) {
|
||||||
const [userOpen, setUserOpen] = useState(false);
|
const [userOpen, setUserOpen] = useState(false);
|
||||||
|
const { openChat } = useSupportChat();
|
||||||
const u = user ?? GUEST_USER;
|
const u = user ?? GUEST_USER;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -447,19 +449,28 @@ export default function TopNav({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Live item (issue: two support entry points — the other is
|
{/* Live item (issue: two support entry points — the other is
|
||||||
SupportButton.tsx's floating button). Same bot, imported
|
SupportButton.tsx's floating button). Both open the same
|
||||||
constant — single source of truth for the URL. Styled like
|
chat window (SupportChatContext) — no more external
|
||||||
the other live row above ("Мои отчёты"), not the dimmed
|
Telegram navigation from here. Styled like the other live
|
||||||
Профиль/Настройки rows. */}
|
row above ("Мои отчёты"), not the dimmed Профиль/Настройки
|
||||||
<a
|
rows. */}
|
||||||
href={SUPPORT_BOT_URL}
|
<button
|
||||||
target="_blank"
|
type="button"
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="tnav-menuitem"
|
className="tnav-menuitem"
|
||||||
style={{ ...menuItemStyle, textDecoration: "none" }}
|
style={{
|
||||||
aria-label="Написать в поддержку МЕРА в Telegram"
|
...menuItemStyle,
|
||||||
title="Написать в поддержку МЕРА в Telegram"
|
width: "100%",
|
||||||
onClick={() => setUserOpen(false)}
|
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">
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
||||||
<circle
|
<circle
|
||||||
|
|
@ -476,7 +487,7 @@ export default function TopNav({
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Помощь
|
Помощь
|
||||||
</a>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
138
tradein-mvp/frontend/src/lib/useSupportChat.ts
Normal file
138
tradein-mvp/frontend/src/lib/useSupportChat.ts
Normal 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 "Не удалось связаться с сервером. Проверьте подключение и повторите.";
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue