"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 ( ); } /** * Кнопка обратной связи в 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 ( Обратная связь ); } /** «Мера» mark — буква М над размерной линией («мера» = измерение). Глиф в currentColor; тёмный фон даёт CSS-бейдж .brand-mark. */ function MeraMark() { return ( ); } 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 (
{brand ? ( /* logo_url из бренд-конфига → safeUrl (блок javascript:/data: схем, #766). Брендовый логотип — полноразмерный wordmark (содержит название), поэтому отдельный brand-product текст НЕ рендерим, иначе дубль названия бренда ×2. Невалидный/пустой URL → fallback TI-марка + текстовое название бренда. */ safeUrl(brand.logo_url) ? ( {brand.name} ) : ( /* #1081 white-label без logo_url: показываем первую букву клиента, НЕ нашу марку MeraMark — бренд не должен утекать в чужой заголовок. */ <> {brand.name.slice(0, 1).toUpperCase()} {brand.name} ) ) : ( <> Мера )}
); }