feat(tradein/ui): вход в поддержку с сайта — плавающая кнопка + живая «Помощь»
All checks were successful
CI / changes (pull_request) Successful in 11s
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m15s

Support-мост (#2526) уже работает на проде, но попасть в бота с сайта было
неоткуда — клиент о нём просто не узнавал.

Два входа, оба ведут в @MERAsupport_bot:
- SupportButton — плавающая кнопка в правом нижнем углу, на всех экранах /v2
- «Помощь» в меню TopNav — была задизейблена с «скоро появится», теперь живая

Закрывает перекос пошире: LeadForm монтируется только при hasEstimate
(v2/page.tsx:1064), т.е. до расчёта оценки у клиента не было НИ ОДНОГО способа
связаться. Единственная другая ссылка на связь во всём UI — NoAccessScreen.tsx:84,
и та лишь на экране «доступ закончился».

Решения:
- Это внешняя ссылка, а не чат-виджет: бот живёт в Telegram, поэтому ни API,
  ни состояния, ни сокетов не нужно.
- URL — module-константа, не NEXT_PUBLIC_*: env инлайнятся на build-time, так что
  переменная не дала бы гибкости рантайма, только лишний build-arg. Источник
  правды один — SUPPORT_BOT_URL в SupportButton.tsx, TopNav импортирует его.
- Монтаж в v2/layout, не в глобальный app/layout: последний накрывает ещё админку
  /scrapers/** и /sale-share — другой продукт без бренда МЕРА.
- createPortal в document.body: /v2 рисует HUD внутри артборда 1536×1024 с
  transform: scale(), а position:fixed внутри трансформированного предка
  позиционируется относительно него, а не вьюпорта — без портала кнопку унесло бы
  вместе с HUD.
- z-index 25 — осознанно НИЖЕ всех оверлеев v2 (SectionOverlay 29/30,
  LocationDrawer 40/41, TopNav 50, UserMenu 200): при открытой модалке кнопка
  должна уходить под неё, а не воровать клики и таб-порядок.
- Стиль только через tokens.ts (файл прямо запрещает хардкодить hex);
  готового <Button> в проекте нет, следуем инлайн-паттерну LeadForm.

Профиль/Настройки в том же меню остаются задизейбленными — страниц под них нет.

A11y: aria-label, тач-таргет 44px, focus-visible ring, инлайновый SVG с aria-hidden.

Проверка: tsc --noEmit чисто, lint чисто (3 warning'а pre-existing, не в этих
файлах), npm run build успешно (/v2 49.4 kB, все 15 страниц prerender).
Визуальная проверка — на проде после деплоя: локально RouteGuard без бекенда
возвращает null (RouteGuard.tsx:46) и рендер не доходит до кнопки.
This commit is contained in:
bot-backend 2026-07-16 22:09:26 +03:00
parent fcfc777baa
commit 7fd40f6ea5
3 changed files with 141 additions and 7 deletions

View file

@ -1,6 +1,7 @@
import type { Metadata } from "next"; 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 { 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,6 +42,10 @@ export default function TradeInV2Layout({
}} }}
> >
{children} {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 />
</div> </div>
); );
} }

View file

@ -0,0 +1,116 @@
"use client";
// SupportButton — floating link to the МЕРА Telegram support bot.
//
// WHY: before this, there was no way to reach support from the site.
// `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.
//
// 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.
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { tokens } from "./tokens";
// 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 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), []);
if (!mounted) return null;
return createPortal(
<>
<style>{styles}</style>
<a
href={SUPPORT_BOT_URL}
target="_blank"
rel="noopener noreferrer"
className="support-btn"
aria-label="Написать в поддержку МЕРА в Telegram"
title="Поддержка МЕРА в Telegram"
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
// 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.
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,
textDecoration: "none",
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>
</a>
</>,
document.body,
);
}

View file

@ -12,6 +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";
// 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 «Мои
@ -56,8 +57,10 @@ const menuItemStyle: CSSProperties = {
transition: "background .12s", transition: "background .12s",
}; };
// Профиль / Настройки / Помощь 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
// support bot (see SUPPORT_BOT_URL below) — it renders as a live item.
const menuItemDisabledStyle: CSSProperties = { const menuItemDisabledStyle: CSSProperties = {
...menuItemStyle, ...menuItemStyle,
cursor: "default", cursor: "default",
@ -374,7 +377,7 @@ export default function TopNav({
{/* L5 `title` gives the dimmed/non-interactive item a hover {/* L5 `title` gives the dimmed/non-interactive item a hover
tooltip explaining WHY it's disabled, instead of a silent dead tooltip explaining WHY it's disabled, instead of a silent dead
row (mirrored on Настройки/Помощь below). */} row (mirrored on Настройки below). */}
<div <div
style={menuItemDisabledStyle} style={menuItemDisabledStyle}
aria-disabled="true" aria-disabled="true"
@ -443,10 +446,20 @@ export default function TopNav({
Настройки Настройки
</div> </div>
<div {/* Live item (issue: two support entry points the other is
style={menuItemDisabledStyle} SupportButton.tsx's floating button). Same bot, imported
aria-disabled="true" constant single source of truth for the URL. Styled like
title="Раздел «Помощь» скоро появится" the other live row above ("Мои отчёты"), not the dimmed
Профиль/Настройки rows. */}
<a
href={SUPPORT_BOT_URL}
target="_blank"
rel="noopener noreferrer"
className="tnav-menuitem"
style={{ ...menuItemStyle, textDecoration: "none" }}
aria-label="Написать в поддержку МЕРА в Telegram"
title="Написать в поддержку МЕРА в Telegram"
onClick={() => setUserOpen(false)}
> >
<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
@ -463,7 +476,7 @@ export default function TopNav({
/> />
</svg> </svg>
Помощь Помощь
</div> </a>
<div <div
style={{ style={{