feat(tradein/ui): manual-review banner + premium-building badge on estimate (#2002)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m53s
Deploy Trade-In / deploy (push) Successful in 52s

This commit is contained in:
lekss361 2026-06-27 19:09:46 +00:00
parent 96d5794b5c
commit 3187419eb8
4 changed files with 166 additions and 0 deletions

View file

@ -184,6 +184,17 @@ export const FIXTURE_ESTIMATE: AggregatedEstimate = {
{ month: "2026-03", ppm2: 176_500 },
{ month: "2026-04", ppm2: 178_000 },
],
// #2002: премиальный дом + рекомендация ручной оценки — чтобы баннер и бейдж
// рендерились в preview-маршруте (/ui-preview/estimate) для a11y/визуального ревью.
manual_review_recommended: true,
manual_review_reasons: [
"премиальный дом — мало сопоставимых сделок",
"высокая стоимость объекта",
"широкий диапазон цены аналогов",
],
premium_building: true,
premium_building_class: "бизнес",
premium_building_median_ppm2: 182_000,
};
export const FIXTURE_INPUT: TradeInEstimateInput = {

View file

@ -58,6 +58,22 @@ const CONF_LABELS: Record<string, { txt: string; color: string }> = {
low: { txt: "низкая", color: "var(--danger)" },
};
// #2002: человекочитаемые лейблы класса премиального дома. Ключи — как их отдаёт
// бэкенд (lower-case RU). Неизвестный класс → капитализируем первую букву.
const PREMIUM_CLASS_LABELS: Record<string, string> = {
премиум: "Премиум",
элит: "Элит",
бизнес: "Бизнес-класс",
};
function premiumBadgeLabel(cls: string | null | undefined): string {
if (!cls) return "Премиальный дом";
const known = PREMIUM_CLASS_LABELS[cls.trim().toLowerCase()];
if (known) return known;
const t = cls.trim();
return t.charAt(0).toUpperCase() + t.slice(1);
}
function formatMln(rub: number): string {
return `${(rub / 1_000_000).toFixed(2).replace(".", ",")} млн`;
}
@ -126,6 +142,13 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
const showMockDisclaimer = isMockAddress(estimate);
// ── #2002: сигналы «ручной оценки» + «премиального дома» ──
// Старые/кешированные оценки эти поля не содержат → undefined ≈ false / [].
const showManualReview = estimate.manual_review_recommended === true;
const manualReviewReasons = estimate.manual_review_reasons ?? [];
const showPremiumBadge = estimate.premium_building === true;
const premiumLabel = premiumBadgeLabel(estimate.premium_building_class);
// ── #651: Avito IMV anchor — реальная рыночная оценка дома. Маркер у цены. ──
const imv = estimate.avito_imv;
const imvPrice =
@ -254,6 +277,14 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
<div className="hero-address">
{estimate.target_address ?? input.address}
</div>
{showPremiumBadge && (
<span
className="premium-badge"
title="Объект в премиальном жилом комплексе"
>
{premiumLabel}
</span>
)}
<div className="hero-cad">
ЭТАЖ {floor}/{totalFloors}
{yearBuilt ? ` · ${yearBuilt}` : ""}
@ -314,6 +345,51 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
</div>
</div>
{/* #2002: баннер «рекомендуется ручная оценка». Рядом с headline-числом,
amber/non-alarming (токены --accent-2). Не презентуем авто-оценку как
твёрдую оферту, когда бэкенд считает её рискованной. */}
{showManualReview && (
<div
className="manual-review"
role="note"
aria-label="Рекомендуется ручная оценка"
>
<div className="manual-review__head">
<span className="manual-review__icon" aria-hidden="true">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</span>
<span className="manual-review__title">
Рекомендуется ручная оценка
</span>
</div>
<p className="manual-review__lead">
Автоматический расчёт по этому объекту менее надёжен используйте его
как ориентир, а не как окончательную цену. Для точной стоимости уточните
оценку у специалиста.
</p>
{manualReviewReasons.length > 0 && (
<ul className="manual-review__reasons">
{manualReviewReasons.map((reason, i) => (
<li key={`mr-${i}`}>{reason}</li>
))}
</ul>
)}
</div>
)}
{/* Две равноправные hero-цены: «Выставить» (asking) + «Получить» (sold).
При hasSold=false (старые оценки / пустая ratio) одиночный layout
только с «Выставить». Empty-state при insufficientData. */}

View file

@ -2094,6 +2094,69 @@
color: var(--accent-2);
}
/* #2002: бейдж премиального дома (рядом с адресом)
Та же форма, что precision-badge; цвет brand-accent (синий, prestige),
отличается от amber-warn и green-success. Только существующие токены. */
.premium-badge {
display: inline-block;
align-self: flex-start;
margin-bottom: 2px;
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
background: var(--accent-soft);
color: var(--accent-ink);
border: 1px solid color-mix(in oklch, var(--accent) 25%, var(--border));
}
/* #2002: баннер «рекомендуется ручная оценка» (amber, non-alarming)
Под headline-числом. Те же amber-токены, что mock-address disclaimer выше. */
.manual-review {
margin: 16px 22px 0;
padding: 12px 16px;
border: 1px solid var(--accent-2);
background: var(--accent-2-soft);
border-radius: var(--radius);
color: var(--fg);
font-size: 13px;
line-height: 1.5;
}
.manual-review__head {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--accent-2);
}
.manual-review__icon {
display: grid;
place-items: center;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.manual-review__title {
font-size: 14px;
}
.manual-review__lead {
margin: 6px 0 0;
color: var(--fg-2);
}
.manual-review__reasons {
margin: 8px 0 0;
padding-left: 18px;
display: flex;
flex-direction: column;
gap: 3px;
color: var(--fg-2);
}
.manual-review__reasons li {
line-height: 1.45;
}
/* «Что-если» интерактивный пересчёт (web-преимущество над PDF)
Компактная панель над HeroSummary: segmented ремонт + stepper/slider этаж +
numeric площадь + балкон. Делает тот же POST /estimate (debounced) и диффит

View file

@ -196,6 +196,22 @@ export interface AggregatedEstimate {
// (PriceTrendCard). null / <2 точек → карточка не рендерится.
// last_scraped_at объявлен выше (был дубль #688+#689 → ломал build).
price_trend?: PriceTrendPoint[] | null;
// ── #2002: сигналы «ручной оценки» и «премиального дома» ──
// manual_review_recommended — авто-оценка рискованна, рекомендуется ручная оценка
// человеком (не презентовать как твёрдую оферту). manual_review_reasons — RU
// human-readable причины («премиальный дом …», «высокая стоимость», «низкая
// уверенность оценки», «широкий диапазон цены»). Оба optional: старые/кешированные
// оценки до деплоя бэкенда их не содержат → graceful (undefined ≈ false / []).
manual_review_recommended?: boolean;
manual_review_reasons?: string[];
// premium_building — целевой объект в курируемом/известном премиальном доме.
// premium_building_class — тариф класса когда известен: «премиум» | «элит» |
// «бизнес» (null если дом премиальный, но класс не определён).
// premium_building_median_ppm2 — медиана ₽/м² по премиальному дому (existing,
// справочный якорь). null при отсутствии данных.
premium_building?: boolean;
premium_building_class?: string | null;
premium_building_median_ppm2?: number | null;
}
// ── Stage 4a/4b response types ──