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
Env-gated Telegram feedback button next to UserMenu; renders only when NEXT_PUBLIC_FEEDBACK_TG_URL is set. safeUrl() sanitized, outside RBAC nav.
219 lines
8.9 KiB
TypeScript
219 lines
8.9 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";
|
||
|
||
/** 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>
|
||
);
|
||
}
|