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/.
|
* С basePath=/trade-in user видит её на gendsgn.ru/trade-in/.
|
||||||
* Использует CSS из /components/trade-in/trade-in.css.
|
* Использует CSS из /components/trade-in/trade-in.css.
|
||||||
*/
|
*/
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
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 { TestPresets } from "@/components/trade-in/TestPresets";
|
||||||
import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api";
|
import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api";
|
||||||
import { useQuota } from "@/lib/useQuota";
|
import { useQuota } from "@/lib/useQuota";
|
||||||
|
import { useBrand, getActiveBrandSlug } from "@/lib/useBrand";
|
||||||
|
|
||||||
function useEstimateId() {
|
function useEstimateId() {
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
|
|
@ -41,6 +42,27 @@ export default function TradeInPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const quota = useQuota();
|
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
|
const blocked = quota.data
|
||||||
? !quota.data.unlimited && quota.data.remaining <= 0
|
? !quota.data.unlimited && quota.data.remaining <= 0
|
||||||
: false;
|
: false;
|
||||||
|
|
@ -205,7 +227,7 @@ export default function TradeInPage() {
|
||||||
<ListingsCard estimate={resultData.estimate} estimateId={currentEstimateId ?? undefined} />
|
<ListingsCard estimate={resultData.estimate} estimateId={currentEstimateId ?? undefined} />
|
||||||
<DealsCard estimate={resultData.estimate} />
|
<DealsCard estimate={resultData.estimate} />
|
||||||
<StreetDealsCard estimate={resultData.estimate} />
|
<StreetDealsCard estimate={resultData.estimate} />
|
||||||
<OfferCard estimate={resultData.estimate} />
|
<OfferCard estimate={resultData.estimate} brandSlug={activeBrandSlug} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<article className="card">
|
<article className="card">
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,18 @@ import { API_BASE_URL } from "@/lib/api";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
estimate: AggregatedEstimate;
|
estimate: AggregatedEstimate;
|
||||||
|
/** #657 white-label: активный ?brand= slug — прокидывается в PDF endpoint. */
|
||||||
|
brandSlug?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtRub(v: number): string {
|
function fmtRub(v: number): string {
|
||||||
return Math.round(v).toLocaleString("ru-RU");
|
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 basePrice = estimate.median_price_rub;
|
||||||
const baseMln = basePrice / 1_000_000;
|
const baseMln = basePrice / 1_000_000;
|
||||||
// Расчёт издержек самостоятельной продажи (от рыночной цены)
|
// Расчёт издержек самостоятельной продажи (от рыночной цены)
|
||||||
|
|
@ -175,7 +180,7 @@ export function OfferCard({ estimate }: Props) {
|
||||||
</div>
|
</div>
|
||||||
<div className="offer-cta-buttons">
|
<div className="offer-cta-buttons">
|
||||||
<a
|
<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`}
|
download={`trade-in-${estimate.estimate_id}.pdf`}
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
/* eslint-disable @next/next/no-img-element -- white-label логотип хотлинком с сайта клиента, next/image не нужен */
|
||||||
|
|
||||||
import { UserMenu } from "@/components/auth/UserMenu";
|
import { UserMenu } from "@/components/auth/UserMenu";
|
||||||
import { API_BASE_URL, HTTPError } from "@/lib/api";
|
import { API_BASE_URL, HTTPError } from "@/lib/api";
|
||||||
import { isPathAllowed } from "@/lib/isPathAllowed";
|
import { isPathAllowed } from "@/lib/isPathAllowed";
|
||||||
|
import { useBrand } from "@/lib/useBrand";
|
||||||
import { useMe } from "@/lib/useMe";
|
import { useMe } from "@/lib/useMe";
|
||||||
|
|
||||||
type ActiveTab =
|
type ActiveTab =
|
||||||
|
|
@ -72,6 +75,9 @@ const NAV_ITEMS: Array<{
|
||||||
|
|
||||||
export function Topbar({ active }: TopbarProps) {
|
export function Topbar({ active }: TopbarProps) {
|
||||||
const { data, error } = useMe();
|
const { data, error } = useMe();
|
||||||
|
// #657 white-label: при активном ?brand= с логотипом рендерим логотип
|
||||||
|
// клиента вместо «TI»-метки. Без бренда brand === null → стандартный wordmark.
|
||||||
|
const { data: brand } = useBrand();
|
||||||
|
|
||||||
// 401 (dev без Caddy basic_auth) — показываем все айтемы.
|
// 401 (dev без Caddy basic_auth) — показываем все айтемы.
|
||||||
// Если /me ещё грузится или дал ошибку без scope — fallback на все айтемы
|
// Если /me ещё грузится или дал ошибку без scope — fallback на все айтемы
|
||||||
|
|
@ -88,8 +94,17 @@ export function Topbar({ active }: TopbarProps) {
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="topbar-inner">
|
<div className="topbar-inner">
|
||||||
<div className="brand">
|
<div className="brand">
|
||||||
<span className="brand-mark">TI</span>
|
{brand?.logo_url ? (
|
||||||
<span className="brand-product">Trade-In</span>
|
<>
|
||||||
|
<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>
|
</div>
|
||||||
<nav className="top-nav">
|
<nav className="top-nav">
|
||||||
{items.map((item) => (
|
{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 {
|
:root {
|
||||||
/* surface */
|
/* surface */
|
||||||
--bg: oklch(99% 0.002 240);
|
--bg: oklch(99% 0.002 240);
|
||||||
|
|
@ -32,6 +37,8 @@
|
||||||
/* type */
|
/* type */
|
||||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
/* #657 white-label font (Manrope) — применяется через .brand-active ниже */
|
||||||
|
--font-brand: 'Manrope', var(--font-sans);
|
||||||
/* layout */
|
/* layout */
|
||||||
--radius: 10px;
|
--radius: 10px;
|
||||||
--radius-sm: 6px;
|
--radius-sm: 6px;
|
||||||
|
|
@ -107,6 +114,13 @@
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
text-transform: uppercase;
|
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 {
|
.top-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 22px;
|
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