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 && ( + + )} + + , 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 ? ( +
+ Здравствуйте! Опишите вопрос — оператор отвечает в рабочее время, + обычно в течение часа. +
+ ) : ( + messages.map((m) => ) + )} +
+
+ + {/* send-error banner */} + {sendMessage.isError && ( +
+ {describeSupportError(sendMessage.error)} +
+ )} + + {/* composer */} +
+