All checks were successful
Deploy Trade-In / build-frontend (push) Successful in 1m32s
Deploy Trade-In / deploy (push) Successful in 56s
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Co-authored-by: bot-frontend <bot-frontend@gendsgn.local> Co-committed-by: bot-frontend <bot-frontend@gendsgn.local>
137 lines
5.2 KiB
TypeScript
137 lines
5.2 KiB
TypeScript
"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";
|
||
|
||
type ActiveTab =
|
||
| "estimate"
|
||
| "history"
|
||
| "cache"
|
||
| "avito"
|
||
| "cian"
|
||
| "yandex"
|
||
| "cian-cookies";
|
||
|
||
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: "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: "Яндекс",
|
||
},
|
||
{
|
||
key: "cian-cookies",
|
||
href: "/scrapers/cian-cookies",
|
||
scopePath: "/trade-in/api/v1/admin/scrapers/cian-cookies",
|
||
label: "Cian Cookies",
|
||
},
|
||
];
|
||
|
||
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).
|
||
Невалидный URL → fallback на TI-марку, без broken/опасного img. */}
|
||
{safeUrl(brand.logo_url) ? (
|
||
<img
|
||
src={safeUrl(brand.logo_url) as string}
|
||
alt={brand.name}
|
||
className="brand-logo"
|
||
/>
|
||
) : (
|
||
<span className="brand-mark">TI</span>
|
||
)}
|
||
<span className="brand-product">{brand.name}</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span className="brand-mark">TI</span>
|
||
<span className="brand-product">Trade-In</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>
|
||
);
|
||
}
|