feat(tradein): white-label branded page for client «Практика» (#657) #672
6 changed files with 144 additions and 6 deletions
25
tradein-mvp/backend/data/sql/084_brand_praktika_fill.sql
Normal file
25
tradein-mvp/backend/data/sql/084_brand_praktika_fill.sql
Normal 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;
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
57
tradein-mvp/frontend/src/lib/useBrand.ts
Normal file
57
tradein-mvp/frontend/src/lib/useBrand.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue