From f43d593cf6c05819d1ab820e15d255248ae30f0b Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 30 May 2026 06:45:14 +0000 Subject: [PATCH] feat(tradein): brand-by-account + result IA redesign (distinct asking/sale prices) (#683) Co-authored-by: bot-backend Co-committed-by: bot-backend --- tradein-mvp/backend/app/core/auth.py | 23 ++ tradein-mvp/backend/tests/test_me_brand.py | 50 +++++ tradein-mvp/frontend/src/app/page.tsx | 4 +- .../src/components/trade-in/HeroSummary.tsx | 200 ++++++++++++------ .../src/components/trade-in/OfferCard.tsx | 36 +++- .../src/components/trade-in/trade-in.css | 57 +++++ tradein-mvp/frontend/src/lib/useBrand.ts | 29 ++- tradein-mvp/frontend/src/lib/useMe.ts | 3 + 8 files changed, 321 insertions(+), 81 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_me_brand.py diff --git a/tradein-mvp/backend/app/core/auth.py b/tradein-mvp/backend/app/core/auth.py index c7c88736..b386de13 100644 --- a/tradein-mvp/backend/app/core/auth.py +++ b/tradein-mvp/backend/app/core/auth.py @@ -41,6 +41,28 @@ class UserScope(TypedDict): role: Role allowed_paths: list[str] deny_paths: list[str] + # #657 white-label: brand slug, привязанный к аккаунту. None = generic UI. + # Фронт (useBrand.ts) применяет бренд автоматически на login (без ?brand=). + brand: str | None + + +# --------------------------------------------------------------------------- +# Account → brand mapping (#657 brand-by-account) +# --------------------------------------------------------------------------- +# +# Пилот-аккаунты агентства «Практика» получают брендирование автоматически +# при логине (без ?brand=). Резолвится в /me как UserScope.brand. Остальные +# юзеры → None (стандартный generic UI). Slug должен существовать в таблице +# `brands` (см. services/brand.py) — иначе useBrand fallback на generic. +_USERNAME_BRAND: dict[str, str] = { + "praktika": "praktika", + "kopylov": "praktika", +} + + +def get_brand_for_user(username: str) -> str | None: + """Return the brand slug bound to *username*, or None for generic UI.""" + return _USERNAME_BRAND.get(username) # --------------------------------------------------------------------------- @@ -176,6 +198,7 @@ def get_user_scope(username: str) -> UserScope: role=role, allowed_paths=list(role_def.get("paths", []) or []), deny_paths=list(role_def.get("deny", []) or []), + brand=get_brand_for_user(username), ) diff --git a/tradein-mvp/backend/tests/test_me_brand.py b/tradein-mvp/backend/tests/test_me_brand.py new file mode 100644 index 00000000..75b4a340 --- /dev/null +++ b/tradein-mvp/backend/tests/test_me_brand.py @@ -0,0 +1,50 @@ +"""Tests for #657 brand-by-account — UserScope.brand resolution. + +Pure-function tests against app.core.auth: brand slug привязывается к аккаунту +(praktika/kopylov → "praktika"), остальные юзеры → None. get_user_scope +прокидывает brand в scope, который /me отдаёт фронту. + +Без БД и сети: читается реальный auth/roles.yaml (ancestor-walk внутри репо). +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import MagicMock + +# DATABASE_URL required by config before any app import (см. test_dadata.py). +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +# WeasyPrint stub — not installed без GTK (consistent с прочими тестами). +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) + +import pytest # noqa: E402 + +from app.core.auth import get_brand_for_user, get_user_scope # noqa: E402 + + +@pytest.mark.parametrize( + ("username", "expected"), + [ + ("praktika", "praktika"), + ("kopylov", "praktika"), + ("admin", None), + ("user1", None), + ("unknown_user", None), + ], +) +def test_get_brand_for_user(username: str, expected: str | None) -> None: + assert get_brand_for_user(username) == expected + + +def test_user_scope_carries_brand_for_praktika() -> None: + scope = get_user_scope("praktika") + assert scope["brand"] == "praktika" + assert scope["username"] == "praktika" + + +def test_user_scope_brand_none_for_generic_user() -> None: + scope = get_user_scope("user1") + assert scope["brand"] is None diff --git a/tradein-mvp/frontend/src/app/page.tsx b/tradein-mvp/frontend/src/app/page.tsx index 8580b179..16ff3f9c 100644 --- a/tradein-mvp/frontend/src/app/page.tsx +++ b/tradein-mvp/frontend/src/app/page.tsx @@ -29,7 +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"; +import { useBrand, useActiveBrandSlug } from "@/lib/useBrand"; function useEstimateId() { if (typeof window === "undefined") return null; @@ -45,7 +45,7 @@ export default function TradeInPage() { // #657 white-label: «отдельная страничка под Практику» = re-skin того же // UI при ?brand=praktika. Бренд резолвится из ?brand= slug (см. useBrand.ts). const { data: brand } = useBrand(); - const activeBrandSlug = getActiveBrandSlug(); + const activeBrandSlug = useActiveBrandSlug(); // Переопределяем CSS-переменные палитры в рантайме + вешаем .brand-active // (переключает --font-sans на Manrope, см. trade-in.css). Cleanup снимает diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index d9d2ef33..74256518 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -31,6 +31,24 @@ const REPAIR_LABELS: Record = { excellent: "Евроремонт", }; +const SOURCE_DOTS: Record = { + cian: "cian", + avito: "avito", + yandex: "yandex", + domklik: "dom", + rosreestr: "rosreestr", + n1: "n1", +}; + +const SOURCE_LABELS: Record = { + cian: "Циан", + avito: "Avito", + yandex: "Я.Недвижимость", + domklik: "ДомКлик", + rosreestr: "Росреестр", + n1: "N1.ru", +}; + const CONF_LABELS: Record = { high: { txt: "высокая", color: "var(--success)" }, medium: { txt: "средняя", color: "var(--success)" }, @@ -266,15 +284,53 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals -
+ {/* ── ЕДИНЫЙ итог: «Ожидаемая цена сделки» (S2). Доминирующий headline. ── + При наличии expected_sold показываем его как главный outcome (ниже + запроса, с −X%). Без него (старые оценки / пустая ratio) — headline = + asking-медиана, чтобы блок не оставался без главного числа. */} +
+
+ {hasSold ? "Ожидаемая цена сделки" : "Рекомендованная цена"} + {showDiscount && ( + + −{discountPct}% к объявлению + + )} +
+
+ {formatMln(hasSold ? (sold as number) : m)} ₽ +
+ {hasSold && typeof soldLo === "number" && typeof soldHi === "number" && ( +
+ ожидаемый диапазон {formatMln(soldLo)} – {formatMln(soldHi)} ₽ + {typeof soldPerM2 === "number" + ? ` · ${soldPerM2.toLocaleString("ru-RU")} ₽/м²` + : ""} +
+ )} + {hasSold && ( +

+ Реальные сделки проходят на 5–12% ниже цен в объявлениях — это + ожидаемая цена сделки по данным ДКП Росреестра, а не цена запроса. +

+ )} +
+ + {/* ── Два сопоставимых ценовых ориентира рынка (РАЗНЫЕ источники) ── + asking (объявления) vs реальные сделки ДКП. Оба — рыночный контекст + под headline-итогом, поэтому одинакового веса. */} +
-
Ориентир запроса
+
Рекомендованная цена в объявлении
{formatMln(m)} ₽
{formatMln(lo)} – {formatMln(hi)} ₽
- {estimate.median_price_per_m2.toLocaleString("ru-RU")} ₽/м² + {estimate.median_price_per_m2.toLocaleString("ru-RU")} ₽/м² · по объявлениям
{imvPrice !== null && (
- {hasSold && ( -
+ {showDkp && dkp && ( +
- Ожидаемая цена продажи - {showDiscount && ( - - −{discountPct}% - - )} + Цены по фактическим сделкам (ДКП · Росреестр) +
+
+ {formatMln(dkp.median_ppm2 * dkpArea)} ₽ +
+
+ {formatMln(dkp.low_ppm2 * dkpArea)} – {formatMln(dkp.high_ppm2 * dkpArea)} ₽ +
+
+ {dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} ДКП за{" "} + {dkp.period_months} мес
-
{formatMln(sold as number)} ₽
- {typeof soldLo === "number" && typeof soldHi === "number" && ( -
- {formatMln(soldLo)} – {formatMln(soldHi)} ₽ -
- )} - {typeof soldPerM2 === "number" && ( -
- {soldPerM2.toLocaleString("ru-RU")} ₽/м² -
- )}
)}
- {hasSold && ( -

- Запрос — по чему выставлены сопоставимые квартиры в объявлениях.{" "} - Ожидаемая цена продажи — реалистичная цена сделки по данным ДКП Росреестра, обычно - ниже запроса. -

- )} - - {showDkp && dkp && ( -

- Коридор реальных сделок:{" "} - - {formatMln(dkp.low_ppm2 * dkpArea)} – {formatMln(dkp.high_ppm2 * dkpArea)} ₽ - {" "} - · {dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² ·{" "} - {dkp.count} ДКП (Росреестр) -

- )} -
@@ -408,8 +436,24 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals })()}
- {estimate.confidence_explanation && ( -
+ {/* ── Источники данных (#648 IA, task #5) — какие источники участвовали ── */} +
+ Источники +
+ {(estimate.sources_used.length > 0 + ? estimate.sources_used + : ["avito", "cian", "yandex", "rosreestr"] + ).map((src) => ( + + {" "} + {SOURCE_LABELS[src] ?? src} + + ))} +
+
+ +
+ {estimate.confidence_explanation && (
@@ -420,32 +464,48 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
{estimate.confidence_explanation}
-
- - - - - - - -
- Цены в объявлениях ≠ реальная сделка — разница 5–12% по данным Росреестра. -
-
-
- - - - - - - -
- Самостоятельная продажа = до 15% потерь на торге, риелторе, нотариусе и аренде. -
+ )} + {/* 3 статичных explainer-карточки (#648 IA, всегда показываем). */} +
+ + + + + + + +
+ Цены в объявлениях — это ожидания собственников. Реальные сделки + проходят на 5–12% ниже по данным ДКП Росреестра.
- )} +
+ + + + + + + +
+ Ремонт оценивается по состоянию, а не по вложенным суммам.{" "} + Рынок платит за итоговый вид квартиры, а не за чеки на материалы. +
+
+
+ + + + + + + +
+ Скрытые расходы при самостоятельной продаже — до 15% на торге, + риелторе, нотариусе и аренде жилья на время сделки. +
+
+
{showEnrichment && (
+ {/* STUB: выкупная цена ещё не считается (нужны коммерческие условия + «Практики»). Ниже — только illustrative сравнение издержек, не оффер. */} +
+ Итоговая выкупная цена по trade-in (сколько получит клиент) + формируется индивидуально на основе ожидаемой цены сделки и коммерческих + условий — в этой версии не рассчитывается автоматически. Ниже — + ориентировочное сравнение издержек самостоятельной продажи. +
+ 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 c4ce2c7c..7a9c6778 100644 --- a/tradein-mvp/frontend/src/components/trade-in/trade-in.css +++ b/tradein-mvp/frontend/src/components/trade-in/trade-in.css @@ -661,6 +661,63 @@ background: var(--accent-soft); border-color: color-mix(in oklch, var(--accent) 22%, var(--border)); } + /* ДКП-сделки (Росреестр) — нейтральный «факт-рынок» оттенок, отличный от + asking, чтобы читались как ДВА разных источника. */ + .price-figure[data-kind="dkp"] { + background: var(--surface-3); + border-color: var(--border-strong, var(--border)); + } + + /* ── Единый headline-итог: «Ожидаемая цена сделки» (#648 IA redesign) ── + Доминирует над двумя reference-figure ниже: больший размер value, + акцентный фон. Снимает «два одинаковых числа» — это ОДНО главное число. */ + .hero-headline { + padding: 20px 22px 16px; + display: flex; + flex-direction: column; + gap: 6px; + background: var(--accent-soft); + border-bottom: 1px solid color-mix(in oklch, var(--accent) 18%, var(--border)); + } + .headline-label { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.01em; + color: var(--accent-ink); + } + .headline-delta { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + padding: 2px 8px; + border-radius: var(--radius-sm); + background: color-mix(in oklch, var(--accent) 16%, transparent); + color: var(--accent-ink); + } + .headline-value { + font-size: 38px; + font-weight: 700; + line-height: 1.05; + letter-spacing: -0.025em; + color: var(--fg); + } + .headline-range { + font-size: 12px; + color: var(--muted); + letter-spacing: 0.01em; + } + .headline-note { + margin: 4px 0 0; + font-size: 12px; + line-height: 1.5; + color: var(--fg-2); + max-width: 60ch; + } + .headline-note b { font-weight: 700; } .price-figure-label { display: flex; align-items: center; diff --git a/tradein-mvp/frontend/src/lib/useBrand.ts b/tradein-mvp/frontend/src/lib/useBrand.ts index 67425f3f..4df5b972 100644 --- a/tradein-mvp/frontend/src/lib/useBrand.ts +++ b/tradein-mvp/frontend/src/lib/useBrand.ts @@ -3,9 +3,11 @@ /** * useBrand — white-label resolver для #657 («Практика» и пр.). * - * Активный бренд определяется query-параметром `?brand=` в URL - * (НЕ из /me — у юзера нет brand/account-поля). Default — нет бренда - * (стандартный UI prinzip/generic), хук возвращает {data: null}. + * Активный бренд резолвится из ДВУХ источников (param wins): + * 1. query-параметр `?brand=` в URL — явный override (демо/превью); + * 2. `me.brand` из /me — привязка бренда к аккаунту (#657 brand-by-account): + * praktika/kopylov логинятся → брендированный UI автоматически, без ?brand=. + * Если ни один не задан — нет бренда (generic UI), хук возвращает {data: null}. * * Mirror паттерна useMe.ts: TanStack Query, staleTime/gcTime Infinity * (бренд статичен на сессию). Backend: GET /api/v1/brand/{slug} @@ -16,6 +18,7 @@ import { useQuery } from "@tanstack/react-query"; import { apiFetch } from "@/lib/api"; +import { useMe } from "@/lib/useMe"; export interface Brand { slug: string; @@ -28,22 +31,34 @@ export interface Brand { } /** Читает `?brand=` slug из URL. SSR-safe (null на сервере). */ -export function getActiveBrandSlug(): string | null { +export function getBrandSlugFromUrl(): 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; } +/** + * Активный slug бренда: `?brand=` param ПЕРЕВЕШИВАЕТ me.brand (account binding). + * Hook (читает useMe) — вызывать только в React-компонентах. + */ +export function useActiveBrandSlug(): string | null { + const { data: me } = useMe(); + const urlSlug = getBrandSlugFromUrl(); + if (urlSlug) return urlSlug; + const accountSlug = me?.brand; + return accountSlug ? accountSlug.trim().toLowerCase() : null; +} + async function fetchBrand(slug: string): Promise { return apiFetch(`/api/v1/brand/${encodeURIComponent(slug)}`); } /** - * Возвращает активный бренд или {data: null}, если `?brand=` отсутствует. - * Запрос включён только при наличии slug — без бренда хук no-op. + * Возвращает активный бренд или {data: null}, если бренд не задан ни в URL, + * ни в аккаунте. Запрос включён только при наличии slug — иначе хук no-op. */ export function useBrand() { - const slug = getActiveBrandSlug(); + const slug = useActiveBrandSlug(); return useQuery({ queryKey: ["brand", slug], queryFn: () => (slug ? fetchBrand(slug) : Promise.resolve(null)), diff --git a/tradein-mvp/frontend/src/lib/useMe.ts b/tradein-mvp/frontend/src/lib/useMe.ts index 7952aab6..2c753c0e 100644 --- a/tradein-mvp/frontend/src/lib/useMe.ts +++ b/tradein-mvp/frontend/src/lib/useMe.ts @@ -21,6 +21,9 @@ export interface UserScope { role: Role; allowed_paths: string[]; deny_paths: string[]; + // #657 brand-by-account: slug бренда, привязанный к аккаунту (praktika→"praktika"). + // null/undefined = generic UI. useBrand применяет его автоматически на login. + brand?: string | null; } export const ME_QUERY_KEY = ["auth", "me"] as const;