feat(tradein): brand-by-account + result IA redesign (distinct asking/sale prices) #683
8 changed files with 321 additions and 81 deletions
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
50
tradein-mvp/backend/tests/test_me_brand.py
Normal file
50
tradein-mvp/backend/tests/test_me_brand.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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 снимает
|
||||
|
|
|
|||
|
|
@ -31,6 +31,24 @@ const REPAIR_LABELS: Record<string, string> = {
|
|||
excellent: "Евроремонт",
|
||||
};
|
||||
|
||||
const SOURCE_DOTS: Record<string, string> = {
|
||||
cian: "cian",
|
||||
avito: "avito",
|
||||
yandex: "yandex",
|
||||
domklik: "dom",
|
||||
rosreestr: "rosreestr",
|
||||
n1: "n1",
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
cian: "Циан",
|
||||
avito: "Avito",
|
||||
yandex: "Я.Недвижимость",
|
||||
domklik: "ДомКлик",
|
||||
rosreestr: "Росреестр",
|
||||
n1: "N1.ru",
|
||||
};
|
||||
|
||||
const CONF_LABELS: Record<string, { txt: string; color: string }> = {
|
||||
high: { txt: "высокая", color: "var(--success)" },
|
||||
medium: { txt: "средняя", color: "var(--success)" },
|
||||
|
|
@ -266,15 +284,53 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`hero-prices${hasSold ? "" : " single"}`}>
|
||||
{/* ── ЕДИНЫЙ итог: «Ожидаемая цена сделки» (S2). Доминирующий headline. ──
|
||||
При наличии expected_sold показываем его как главный outcome (ниже
|
||||
запроса, с −X%). Без него (старые оценки / пустая ratio) — headline =
|
||||
asking-медиана, чтобы блок не оставался без главного числа. */}
|
||||
<div className="hero-headline">
|
||||
<div className="headline-label">
|
||||
{hasSold ? "Ожидаемая цена сделки" : "Рекомендованная цена"}
|
||||
{showDiscount && (
|
||||
<span
|
||||
className="headline-delta"
|
||||
aria-label={`на ${discountPct}% ниже рекомендованной цены в объявлении`}
|
||||
>
|
||||
−{discountPct}% к объявлению
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="headline-value mono">
|
||||
{formatMln(hasSold ? (sold as number) : m)} ₽
|
||||
</div>
|
||||
{hasSold && typeof soldLo === "number" && typeof soldHi === "number" && (
|
||||
<div className="headline-range mono">
|
||||
ожидаемый диапазон {formatMln(soldLo)} – {formatMln(soldHi)} ₽
|
||||
{typeof soldPerM2 === "number"
|
||||
? ` · ${soldPerM2.toLocaleString("ru-RU")} ₽/м²`
|
||||
: ""}
|
||||
</div>
|
||||
)}
|
||||
{hasSold && (
|
||||
<p className="headline-note">
|
||||
Реальные сделки проходят на <b>5–12% ниже</b> цен в объявлениях — это
|
||||
ожидаемая цена сделки по данным ДКП Росреестра, а не цена запроса.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Два сопоставимых ценовых ориентира рынка (РАЗНЫЕ источники) ──
|
||||
asking (объявления) vs реальные сделки ДКП. Оба — рыночный контекст
|
||||
под headline-итогом, поэтому одинакового веса. */}
|
||||
<div className={`hero-prices${showDkp ? "" : " single"}`}>
|
||||
<div className="price-figure" data-kind="asking">
|
||||
<div className="price-figure-label">Ориентир запроса</div>
|
||||
<div className="price-figure-label">Рекомендованная цена в объявлении</div>
|
||||
<div className="price-figure-value mono">{formatMln(m)} ₽</div>
|
||||
<div className="price-figure-range mono">
|
||||
{formatMln(lo)} – {formatMln(hi)} ₽
|
||||
</div>
|
||||
<div className="price-figure-perm2 mono">
|
||||
{estimate.median_price_per_m2.toLocaleString("ru-RU")} ₽/м²
|
||||
{estimate.median_price_per_m2.toLocaleString("ru-RU")} ₽/м² · по объявлениям
|
||||
</div>
|
||||
{imvPrice !== null && (
|
||||
<div
|
||||
|
|
@ -287,53 +343,25 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
|||
)}
|
||||
</div>
|
||||
|
||||
{hasSold && (
|
||||
<div className="price-figure" data-kind="sold">
|
||||
{showDkp && dkp && (
|
||||
<div className="price-figure" data-kind="dkp">
|
||||
<div className="price-figure-label">
|
||||
Ожидаемая цена продажи
|
||||
{showDiscount && (
|
||||
<span className="price-figure-delta" aria-label={`на ${discountPct}% ниже запроса`}>
|
||||
−{discountPct}%
|
||||
</span>
|
||||
)}
|
||||
Цены по фактическим сделкам (ДКП · Росреестр)
|
||||
</div>
|
||||
<div className="price-figure-value mono">
|
||||
{formatMln(dkp.median_ppm2 * dkpArea)} ₽
|
||||
</div>
|
||||
<div className="price-figure-range mono">
|
||||
{formatMln(dkp.low_ppm2 * dkpArea)} – {formatMln(dkp.high_ppm2 * dkpArea)} ₽
|
||||
</div>
|
||||
<div className="price-figure-perm2 mono">
|
||||
{dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} ДКП за{" "}
|
||||
{dkp.period_months} мес
|
||||
</div>
|
||||
<div className="price-figure-value mono">{formatMln(sold as number)} ₽</div>
|
||||
{typeof soldLo === "number" && typeof soldHi === "number" && (
|
||||
<div className="price-figure-range mono">
|
||||
{formatMln(soldLo)} – {formatMln(soldHi)} ₽
|
||||
</div>
|
||||
)}
|
||||
{typeof soldPerM2 === "number" && (
|
||||
<div className="price-figure-perm2 mono">
|
||||
{soldPerM2.toLocaleString("ru-RU")} ₽/м²
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasSold && (
|
||||
<p className="hero-prices-note">
|
||||
<b>Запрос</b> — по чему выставлены сопоставимые квартиры в объявлениях.{" "}
|
||||
<b>Ожидаемая цена продажи</b> — реалистичная цена сделки по данным ДКП Росреестра, обычно
|
||||
ниже запроса.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showDkp && dkp && (
|
||||
<p
|
||||
className="hero-prices-note"
|
||||
title="Коридор реальных ₽/м² по ДКП-сделкам Росреестра (справочно)"
|
||||
>
|
||||
<b>Коридор реальных сделок:</b>{" "}
|
||||
<span className="mono">
|
||||
{formatMln(dkp.low_ppm2 * dkpArea)} – {formatMln(dkp.high_ppm2 * dkpArea)} ₽
|
||||
</span>{" "}
|
||||
· <span className="mono">{dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м²</span> ·{" "}
|
||||
{dkp.count} ДКП (Росреестр)
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="hero-bars">
|
||||
<div className="bar-block">
|
||||
<div className="bar-head">
|
||||
|
|
@ -408,8 +436,24 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
|||
})()}
|
||||
</div>
|
||||
|
||||
{estimate.confidence_explanation && (
|
||||
<div className="hero-warnings">
|
||||
{/* ── Источники данных (#648 IA, task #5) — какие источники участвовали ── */}
|
||||
<div className="filters-row" style={{ padding: "14px 22px 0" }}>
|
||||
<span className="lbl">Источники</span>
|
||||
<div className="sources">
|
||||
{(estimate.sources_used.length > 0
|
||||
? estimate.sources_used
|
||||
: ["avito", "cian", "yandex", "rosreestr"]
|
||||
).map((src) => (
|
||||
<span key={src} className="source-chip">
|
||||
<span className={`src-dot ${SOURCE_DOTS[src] ?? "dom"}`} />{" "}
|
||||
{SOURCE_LABELS[src] ?? src}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hero-warnings">
|
||||
{estimate.confidence_explanation && (
|
||||
<div className="warn-row">
|
||||
<span className="ic">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
|
|
@ -420,32 +464,48 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
|||
</span>
|
||||
<div>{estimate.confidence_explanation}</div>
|
||||
</div>
|
||||
<div className="warn-row">
|
||||
<span className="ic">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
Цены в объявлениях ≠ реальная сделка — разница <b>5–12%</b> по данным Росреестра.
|
||||
</div>
|
||||
</div>
|
||||
<div className="warn-row">
|
||||
<span className="ic">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
Самостоятельная продажа = до <b>15% потерь</b> на торге, риелторе, нотариусе и аренде.
|
||||
</div>
|
||||
)}
|
||||
{/* 3 статичных explainer-карточки (#648 IA, всегда показываем). */}
|
||||
<div className="warn-row">
|
||||
<span className="ic">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<b>Цены в объявлениях — это ожидания собственников.</b> Реальные сделки
|
||||
проходят на <b>5–12% ниже</b> по данным ДКП Росреестра.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="warn-row">
|
||||
<span className="ic">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<b>Ремонт оценивается по состоянию, а не по вложенным суммам.</b>{" "}
|
||||
Рынок платит за итоговый вид квартиры, а не за чеки на материалы.
|
||||
</div>
|
||||
</div>
|
||||
<div className="warn-row">
|
||||
<span className="ic">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<b>Скрытые расходы при самостоятельной продаже — до 15%</b> на торге,
|
||||
риелторе, нотариусе и аренде жилья на время сделки.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showEnrichment && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,8 +1,19 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* OfferCard — Секция 4 «Оффер» из mockup tradein.html.
|
||||
* Сравнение trade-in vs самостоятельная продажа + 4 преимущества + CTA.
|
||||
* OfferCard — Секция 4 «Оффер» (commercial — Brusnika p.4 «формирование
|
||||
* выкупной стоимости»).
|
||||
*
|
||||
* STUB / DEFER (#648 IA redesign): сама ВЫКУПНАЯ цена (сколько девелопер
|
||||
* платит клиенту за квартиру в trade-in) пока НЕ рассчитывается — это
|
||||
* коммерческое решение, требующее условий «Практики» (haircut к ожидаемой
|
||||
* цене сделки, маржа, срок фиксации). Не выдумываем число.
|
||||
*
|
||||
* TODO(#648 commercial): когда «Практика» даст коммерческие параметры —
|
||||
* добавить блок «Выкупная стоимость = ожидаемая цена сделки − выкупной
|
||||
* дисконт N%», feeding от estimate.expected_sold_price_rub. До этого карточка
|
||||
* показывает только illustrative сравнение издержек самостоятельной продажи
|
||||
* (типовые рыночные ставки, НЕ оффер «Практики») + преимущества + PDF CTA.
|
||||
*/
|
||||
import type { AggregatedEstimate } from "@/types/trade-in";
|
||||
import { API_BASE_URL } from "@/lib/api";
|
||||
|
|
@ -47,6 +58,27 @@ export function OfferCard({ estimate, brandSlug }: Props) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* STUB: выкупная цена ещё не считается (нужны коммерческие условия
|
||||
«Практики»). Ниже — только illustrative сравнение издержек, не оффер. */}
|
||||
<div
|
||||
role="note"
|
||||
style={{
|
||||
margin: "0 22px 4px",
|
||||
padding: "10px 14px",
|
||||
border: "1px dashed var(--border-strong, var(--border))",
|
||||
background: "var(--surface-2)",
|
||||
borderRadius: "var(--radius)",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
color: "var(--muted)",
|
||||
}}
|
||||
>
|
||||
Итоговая <b>выкупная цена</b> по trade-in (сколько получит клиент)
|
||||
формируется индивидуально на основе ожидаемой цены сделки и коммерческих
|
||||
условий — в этой версии не рассчитывается автоматически. Ниже —
|
||||
ориентировочное сравнение издержек самостоятельной продажи.
|
||||
</div>
|
||||
|
||||
<table className="offer-tbl" aria-label="Сравнение trade-in и самостоятельной продажи">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@
|
|||
/**
|
||||
* useBrand — white-label resolver для #657 («Практика» и пр.).
|
||||
*
|
||||
* Активный бренд определяется query-параметром `?brand=<slug>` в URL
|
||||
* (НЕ из /me — у юзера нет brand/account-поля). Default — нет бренда
|
||||
* (стандартный UI prinzip/generic), хук возвращает {data: null}.
|
||||
* Активный бренд резолвится из ДВУХ источников (param wins):
|
||||
* 1. query-параметр `?brand=<slug>` в 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<Brand> {
|
||||
return apiFetch<Brand>(`/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<Brand | null, Error>({
|
||||
queryKey: ["brand", slug],
|
||||
queryFn: () => (slug ? fetchBrand(slug) : Promise.resolve(null)),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue