gendesign/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx
bot-backend dd339d3302
All checks were successful
CI / changes (pull_request) Successful in 4s
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
feat(tradein-frontend): единая scrapers-страница — табы Avito/Cian/Yandex, proxy-health+rotate, runs-история, консолидация триггеров, удалён cian-cookies редирект
2026-06-20 09:33:07 +03:00

154 lines
6.5 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";
/** «Мера» 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"
| "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: "Оценка" },
{ 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}
>
{item.label}
</a>
))}
{/* UserMenu — личный кабинет справа от nav. В dev (401) или до /me
UserMenu сам рендерит null. */}
<UserMenu />
</nav>
</div>
</header>
);
}