feat(tradein/ui): manual-review banner + premium-building badge on estimate (#2002)
This commit is contained in:
parent
96d5794b5c
commit
482d3caf08
4 changed files with 166 additions and 0 deletions
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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. */}
|
||||
|
|
|
|||
|
|
@ -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) и диффит
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue