diff --git a/tradein-mvp/frontend/src/components/trade-in/OfferCard.tsx b/tradein-mvp/frontend/src/components/trade-in/OfferCard.tsx index 70052aae..0e8dfdaf 100644 --- a/tradein-mvp/frontend/src/components/trade-in/OfferCard.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/OfferCard.tsx @@ -9,13 +9,18 @@ import { API_BASE_URL } from "@/lib/api"; interface Props { estimate: AggregatedEstimate; + /** #657 white-label: активный ?brand= slug — прокидывается в PDF endpoint. */ + brandSlug?: string | null; } function fmtRub(v: number): string { return Math.round(v).toLocaleString("ru-RU"); } -export function OfferCard({ estimate }: Props) { +export function OfferCard({ estimate, brandSlug }: Props) { + // PDF endpoint (api/v1/trade_in.py) уже honor'ит ?brand= для white-label. + // Без бренда query пустой → стандартный PDF. + const pdfQuery = brandSlug ? `?brand=${encodeURIComponent(brandSlug)}` : ""; const basePrice = estimate.median_price_rub; const baseMln = basePrice / 1_000_000; // Расчёт издержек самостоятельной продажи (от рыночной цены) @@ -175,7 +180,7 @@ export function OfferCard({ estimate }: Props) { diff --git a/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx b/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx index 63117a55..a2737747 100644 --- a/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx @@ -1,8 +1,11 @@ "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 { useBrand } from "@/lib/useBrand"; import { useMe } from "@/lib/useMe"; type ActiveTab = @@ -72,6 +75,9 @@ const NAV_ITEMS: Array<{ 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 на все айтемы @@ -88,8 +94,17 @@ export function Topbar({ active }: TopbarProps) { - TI - Trade-In + {brand?.logo_url ? ( + <> + + {brand.name} + > + ) : ( + <> + TI + Trade-In + > + )} {items.map((item) => ( diff --git a/tradein-mvp/frontend/src/components/trade-in/trade-in.css b/tradein-mvp/frontend/src/components/trade-in/trade-in.css index 6bbe0280..c4ce2c7c 100644 --- a/tradein-mvp/frontend/src/components/trade-in/trade-in.css +++ b/tradein-mvp/frontend/src/components/trade-in/trade-in.css @@ -1,3 +1,8 @@ + /* #657 white-label: Manrope — OPEN geometric grotesk, визуальная замена + платного HalvarMittel из брендбука «Практики». Подключается всегда, но + применяется только при активном бренде (см. --font-sans override ниже). */ + @import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap'); + :root { /* surface */ --bg: oklch(99% 0.002 240); @@ -32,6 +37,8 @@ /* type */ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + /* #657 white-label font (Manrope) — применяется через .brand-active ниже */ + --font-brand: 'Manrope', var(--font-sans); /* layout */ --radius: 10px; --radius-sm: 6px; @@ -107,6 +114,13 @@ color: var(--muted); text-transform: uppercase; } + /* #657 white-label: логотип клиента вместо «TI»-метки (см. Topbar.tsx) */ + .brand-logo { height: 24px; width: auto; display: block; } + /* Активный бренд: переопределяем шрифт всего trade-in scope на Manrope. + --accent / --accent-2 переопределяются в рантайме из page.tsx + (document.documentElement.style.setProperty). Без ?brand= класс не + вешается → стандартный Inter + OKLCH-палитра нетронуты. */ + .brand-active { --font-sans: var(--font-brand); } .top-nav { display: flex; gap: 22px; diff --git a/tradein-mvp/frontend/src/lib/useBrand.ts b/tradein-mvp/frontend/src/lib/useBrand.ts new file mode 100644 index 00000000..67425f3f --- /dev/null +++ b/tradein-mvp/frontend/src/lib/useBrand.ts @@ -0,0 +1,57 @@ +"use client"; + +/** + * useBrand — white-label resolver для #657 («Практика» и пр.). + * + * Активный бренд определяется query-параметром `?brand=` в URL + * (НЕ из /me — у юзера нет brand/account-поля). Default — нет бренда + * (стандартный UI prinzip/generic), хук возвращает {data: null}. + * + * Mirror паттерна useMe.ts: TanStack Query, staleTime/gcTime Infinity + * (бренд статичен на сессию). Backend: GET /api/v1/brand/{slug} + * (см. tradein-mvp/backend/app/api/v1/brand.py) — возвращает поля ниже, + * fallback на generic если slug не найден. + */ + +import { useQuery } from "@tanstack/react-query"; + +import { apiFetch } from "@/lib/api"; + +export interface Brand { + slug: string; + name: string; + logo_url: string | null; + primary_color: string; + accent_color: string; + footer_text: string | null; + pdf_disclaimer: string | null; +} + +/** Читает `?brand=` slug из URL. SSR-safe (null на сервере). */ +export function getActiveBrandSlug(): string | null { + if (typeof window === "undefined") return null; + const slug = new URLSearchParams(window.location.search).get("brand"); + return slug && slug.trim() ? slug.trim().toLowerCase() : null; +} + +async function fetchBrand(slug: string): Promise { + return apiFetch(`/api/v1/brand/${encodeURIComponent(slug)}`); +} + +/** + * Возвращает активный бренд или {data: null}, если `?brand=` отсутствует. + * Запрос включён только при наличии slug — без бренда хук no-op. + */ +export function useBrand() { + const slug = getActiveBrandSlug(); + return useQuery({ + queryKey: ["brand", slug], + queryFn: () => (slug ? fetchBrand(slug) : Promise.resolve(null)), + enabled: slug !== null, + staleTime: Infinity, + gcTime: Infinity, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + }); +}