feat(tradein): white-label branded page for client «Практика» (#657)

?brand=praktika re-skin: useBrand hook (reads ?brand= slug, fetches
/api/v1/brand/{slug}), runtime CSS-var override of accent palette +
Manrope font (open replacement for paid HalvarMittel), client logo in
Topbar, ?brand passthrough on the PDF link. No-op when ?brand= absent.
Migration 084 fills the praktika brand row (#235F49 / #1FCECB, logo, footer).

Closes #657
This commit is contained in:
lekss361 2026-05-29 18:52:13 +03:00
parent 6508023c29
commit 5f6b0d6d87
6 changed files with 144 additions and 6 deletions

View file

@ -0,0 +1,25 @@
-- 084_brand_praktika_fill.sql
-- #657 White-label для клиента «Практика» (gk-praktika.ru).
--
-- ПРОБЛЕМА: seed бренда 'praktika' в 002_core_tables.sql задан заглушкой —
-- primary_color='#e87722' (orange, НЕ из брендбука клиента), logo_url/accent NULL.
-- Реальные фирменные цвета ГК «Практика» (с gk-praktika.ru):
-- primary #235F49 — тёмно-зелёный (основной),
-- accent #1FCECB — циан (акцент).
-- Логотип хотлинкается с сайта клиента (magenta-метка logo-pink.svg).
--
-- Идемпотентно: UPDATE по PK slug='praktika'. Безопасно ре-применять.
--
-- NUMBERING: highest на main = 082; PR #656 занимает 083 → используем 084,
-- чтобы избежать коллизии нумерации (см. PR body).
BEGIN;
UPDATE brands
SET primary_color = '#235F49', -- тёмно-зелёный (бренд)
accent_color = '#1FCECB', -- циан (акцент)
logo_url = 'https://gk-praktika.ru/images/logo/logo-pink.svg', -- hotlink с сайта клиента
footer_text = 'ГК «Практика» · Екатеринбург'
WHERE slug = 'praktika';
COMMIT;

View file

@ -5,7 +5,7 @@
* С basePath=/trade-in user видит её на gendsgn.ru/trade-in/.
* Использует CSS из /components/trade-in/trade-in.css.
*/
import { useState } from "react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
@ -29,6 +29,7 @@ import { OfferCard } from "@/components/trade-in/OfferCard";
import { TestPresets } from "@/components/trade-in/TestPresets";
import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api";
import { useQuota } from "@/lib/useQuota";
import { useBrand, getActiveBrandSlug } from "@/lib/useBrand";
function useEstimateId() {
if (typeof window === "undefined") return null;
@ -41,6 +42,27 @@ export default function TradeInPage() {
const queryClient = useQueryClient();
const quota = useQuota();
// #657 white-label: «отдельная страничка под Практику» = re-skin того же
// UI при ?brand=praktika. Бренд резолвится из ?brand= slug (см. useBrand.ts).
const { data: brand } = useBrand();
const activeBrandSlug = getActiveBrandSlug();
// Переопределяем CSS-переменные палитры в рантайме + вешаем .brand-active
// (переключает --font-sans на Manrope, см. trade-in.css). Cleanup снимает
// overrides → без ?brand= дефолтный UI полностью нетронут (no-op).
useEffect(() => {
const root = document.documentElement;
if (!brand) return;
root.style.setProperty("--accent", brand.primary_color); // зелёный (бренд)
root.style.setProperty("--accent-2", brand.accent_color); // циан (акцент)
root.classList.add("brand-active");
return () => {
root.style.removeProperty("--accent");
root.style.removeProperty("--accent-2");
root.classList.remove("brand-active");
};
}, [brand]);
const blocked = quota.data
? !quota.data.unlimited && quota.data.remaining <= 0
: false;
@ -205,7 +227,7 @@ export default function TradeInPage() {
<ListingsCard estimate={resultData.estimate} estimateId={currentEstimateId ?? undefined} />
<DealsCard estimate={resultData.estimate} />
<StreetDealsCard estimate={resultData.estimate} />
<OfferCard estimate={resultData.estimate} />
<OfferCard estimate={resultData.estimate} brandSlug={activeBrandSlug} />
</>
) : (
<article className="card">

View file

@ -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) {
</div>
<div className="offer-cta-buttons">
<a
href={`${API_BASE_URL}/api/v1/trade-in/estimate/${estimate.estimate_id}/pdf`}
href={`${API_BASE_URL}/api/v1/trade-in/estimate/${estimate.estimate_id}/pdf${pdfQuery}`}
download={`trade-in-${estimate.estimate_id}.pdf`}
className="btn btn-ghost"
>

View file

@ -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) {
<header className="topbar">
<div className="topbar-inner">
<div className="brand">
<span className="brand-mark">TI</span>
<span className="brand-product">Trade-In</span>
{brand?.logo_url ? (
<>
<img src={brand.logo_url} alt={brand.name} className="brand-logo" />
<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) => (

View file

@ -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;

View file

@ -0,0 +1,57 @@
"use client";
/**
* useBrand white-label resolver для #657 («Практика» и пр.).
*
* Активный бренд определяется query-параметром `?brand=<slug>` в 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<Brand> {
return apiFetch<Brand>(`/api/v1/brand/${encodeURIComponent(slug)}`);
}
/**
* Возвращает активный бренд или {data: null}, если `?brand=` отсутствует.
* Запрос включён только при наличии slug без бренда хук no-op.
*/
export function useBrand() {
const slug = getActiveBrandSlug();
return useQuery<Brand | null, Error>({
queryKey: ["brand", slug],
queryFn: () => (slug ? fetchBrand(slug) : Promise.resolve(null)),
enabled: slug !== null,
staleTime: Infinity,
gcTime: Infinity,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
});
}