gendesign/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx
lekss361 749c96cd0c
All checks were successful
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 / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Successful in 2m1s
Deploy Trade-In / deploy (push) Successful in 47s
feat(tradein/ui): Telegram feedback button in Topbar (env-gated) (#2514)
Env-gated Telegram feedback button next to UserMenu; renders only when NEXT_PUBLIC_FEEDBACK_TG_URL is set. safeUrl() sanitized, outside RBAC nav.
2026-07-13 19:44:00 +00:00

219 lines
8.9 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";
/* eslint-disable @next/next/no-img-element -- white-label логотип хотлинком с сайта клиента, next/image не нужен */
import { UserMenu } from "@/components/auth/UserMenu";
import { API_BASE_URL, HTTPError } from "@/lib/api";
import { isPathAllowed } from "@/lib/isPathAllowed";
import { safeUrl } from "@/lib/safeUrl";
import { useBrand } from "@/lib/useBrand";
import { useMe } from "@/lib/useMe";
/** Telegram-канал обратной связи для пилота. Build-time env — пусто до тех
пор, пока devops не настроит реальную ссылку (см. FeedbackButton ниже). */
const FEEDBACK_TG_URL = process.env.NEXT_PUBLIC_FEEDBACK_TG_URL ?? "";
/** Inline Send icon (lucide-react `Send` SVG path, stroke 1.5) — та же
конвенция, что и LogOutIcon в UserMenu.tsx: tradein-mvp не тянет
lucide-react в deps, поэтому иконка — inline SVG. */
function TelegramIcon() {
return (
<svg
width={16}
height={16}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m22 2-7 20-4-9-9-4Z" />
<path d="M22 2 11 13" />
</svg>
);
}
/**
* Кнопка обратной связи в Telegram — рендерится для ВСЕХ авторизованных
* пользователей (не зависит от RBAC allowed_paths, в отличие от NAV_ITEMS).
*
* Env-gated: пока `NEXT_PUBLIC_FEEDBACK_TG_URL` не задан на билде, кнопка не
* рендерится вообще — безопасно шипить до появления реальной Telegram-ссылки.
* safeUrl() дополнительно блокирует не-http(s) схемы (#766-class XSS).
*/
function FeedbackButton() {
const href = safeUrl(FEEDBACK_TG_URL);
if (!href) return null;
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="top-cta"
aria-label="Обратная связь в Telegram"
>
<TelegramIcon />
Обратная связь
</a>
);
}
/** «Мера» mark — буква М над размерной линией («мера» = измерение).
Глиф в currentColor; тёмный фон даёт CSS-бейдж .brand-mark. */
function MeraMark() {
return (
<svg viewBox="0 0 28 28" className="brand-mark-svg" aria-hidden="true" focusable="false">
{/* буква М */}
<path d="M8 16V6l6 7 6-7v10" fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
{/* размерная линия под М — «мера» = измерение */}
<path d="M7 21h14M7 19.2v3.6M21 19.2v3.6" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
);
}
export type ActiveTab =
| "estimate"
| "sale-share"
| "history"
| "cache"
| "scrapers"
| "avito"
| "cian"
| "yandex";
interface TopbarProps {
active: ActiveTab;
}
/**
* Nav items с двумя путями:
* `href` — относительный путь, который рендерится в URL (basePath
* `/trade-in` префиксится через `API_BASE_URL`).
* `scopePath` — абсолютный путь как его видит backend RBAC config
* (`auth/roles.yaml`).
*
* Caveat / UX-opinion: pages вида `/trade-in/scrapers/*` сами по себе НЕ
* в `pilot.deny` (там только `/trade-in/api/v1/admin/**`). По букве yaml
* pilot имеет право видеть и UI скраперов — но это admin-функционал, и
* рендерить «битый» dashboard с 403-ошибками на каждый data-fetch — плохой
* UX. Поэтому для фильтрации Topbar мы маппим scraper-таб на admin-API
* путь, чтобы pilot их не видел в навигации. Direct URL access на
* `/trade-in/scrapers/avito` НЕ блокируется (RouteGuard следует yaml). Если
* нужна полная блокировка — добавить `/trade-in/scrapers/**` в pilot.deny.
*/
const NAV_ITEMS: Array<{
key: ActiveTab;
href: string;
scopePath: string;
label: string;
}> = [
{ key: "estimate", href: "/", scopePath: "/trade-in/", label: "Оценка" },
// Доля квартир дома в продаже — доступно pilot (scopePath под /trade-in/**).
{
key: "sale-share",
href: "/sale-share",
scopePath: "/trade-in/sale-share",
label: "Доля в продаже",
},
{ key: "history", href: "/history", scopePath: "/trade-in/history", label: "История" },
{ key: "cache", href: "/cache", scopePath: "/trade-in/cache", label: "Кэш" },
// Скраперы — admin-only UI. Маппим на admin-deny path, чтобы pilot их не видел.
{
key: "scrapers",
href: "/scrapers",
scopePath: "/trade-in/api/v1/admin/scrapers",
label: "Скрапперы",
},
{
key: "avito",
href: "/scrapers/avito",
scopePath: "/trade-in/api/v1/admin/scrapers/avito",
label: "Авито",
},
{
key: "cian",
href: "/scrapers/cian",
scopePath: "/trade-in/api/v1/admin/scrapers/cian",
label: "Циан",
},
{
key: "yandex",
href: "/scrapers/yandex",
scopePath: "/trade-in/api/v1/admin/scrapers/yandex",
label: "Яндекс",
},
];
export function Topbar({ active }: TopbarProps) {
const { data, error } = useMe();
// #657 white-label: при активном ?brand= с логотипом рендерим логотип
// клиента вместо «TI»-метки. Без бренда brand === null → стандартный wordmark.
const { data: brand } = useBrand();
// 401 (dev без Caddy basic_auth) — показываем все айтемы.
// Если /me ещё грузится или дал ошибку без scope — fallback на все айтемы
// (RouteGuard в этом случае сам решит, рендерить ли страницу вообще).
const isDev401 = error instanceof HTTPError && error.status === 401;
const items =
data && !isDev401
? NAV_ITEMS.filter((item) =>
isPathAllowed(data.allowed_paths, data.deny_paths, item.scopePath),
)
: NAV_ITEMS;
return (
<header className="topbar">
<div className="topbar-inner">
<div className="brand">
{brand ? (
/* logo_url из бренд-конфига → safeUrl (блок javascript:/data: схем, #766).
Брендовый логотип — полноразмерный wordmark (содержит название), поэтому
отдельный brand-product текст НЕ рендерим, иначе дубль названия бренда ×2.
Невалидный/пустой URL → fallback TI-марка + текстовое название бренда. */
safeUrl(brand.logo_url) ? (
<img
src={safeUrl(brand.logo_url) as string}
alt={brand.name}
className="brand-logo"
/>
) : (
/* #1081 white-label без logo_url: показываем первую букву клиента,
НЕ нашу марку MeraMark — бренд не должен утекать в чужой заголовок. */
<>
<span className="brand-mark">{brand.name.slice(0, 1).toUpperCase()}</span>
<span className="brand-product">{brand.name}</span>
</>
)
) : (
<>
<span className="brand-mark"><MeraMark /></span>
<span className="brand-product">Мера</span>
</>
)}
</div>
<nav className="top-nav">
{items.map((item) => (
<a
key={item.key}
href={`${API_BASE_URL}${item.href}`}
className={item.key === active ? "is-active" : undefined}
aria-current={item.key === active ? "page" : undefined}
>
{item.label}
</a>
))}
{/* UserMenu — личный кабинет справа от nav. В dev (401) или до /me
UserMenu сам рендерит null. */}
<UserMenu />
{/* FeedbackButton — вне items.map/RBAC: видна всем авторизованным
пользователям независимо от allowed_paths. Сама себя прячет,
пока NEXT_PUBLIC_FEEDBACK_TG_URL не задан. */}
<FeedbackButton />
</nav>
</div>
</header>
);
}