feat(tradein/ui): manual-review banner + premium-building badge on estimate (#2002) #2022
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-03", ppm2: 176_500 },
|
||||||
{ month: "2026-04", ppm2: 178_000 },
|
{ 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 = {
|
export const FIXTURE_INPUT: TradeInEstimateInput = {
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,22 @@ const CONF_LABELS: Record<string, { txt: string; color: string }> = {
|
||||||
low: { txt: "низкая", color: "var(--danger)" },
|
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 {
|
function formatMln(rub: number): string {
|
||||||
return `${(rub / 1_000_000).toFixed(2).replace(".", ",")} млн`;
|
return `${(rub / 1_000_000).toFixed(2).replace(".", ",")} млн`;
|
||||||
}
|
}
|
||||||
|
|
@ -126,6 +142,13 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
||||||
|
|
||||||
const showMockDisclaimer = isMockAddress(estimate);
|
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 — реальная рыночная оценка дома. Маркер у цены. ──
|
// ── #651: Avito IMV anchor — реальная рыночная оценка дома. Маркер у цены. ──
|
||||||
const imv = estimate.avito_imv;
|
const imv = estimate.avito_imv;
|
||||||
const imvPrice =
|
const imvPrice =
|
||||||
|
|
@ -254,6 +277,14 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
||||||
<div className="hero-address">
|
<div className="hero-address">
|
||||||
{estimate.target_address ?? input.address}
|
{estimate.target_address ?? input.address}
|
||||||
</div>
|
</div>
|
||||||
|
{showPremiumBadge && (
|
||||||
|
<span
|
||||||
|
className="premium-badge"
|
||||||
|
title="Объект в премиальном жилом комплексе"
|
||||||
|
>
|
||||||
|
{premiumLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div className="hero-cad">
|
<div className="hero-cad">
|
||||||
ЭТАЖ {floor}/{totalFloors}
|
ЭТАЖ {floor}/{totalFloors}
|
||||||
{yearBuilt ? ` · ${yearBuilt}` : ""}
|
{yearBuilt ? ` · ${yearBuilt}` : ""}
|
||||||
|
|
@ -314,6 +345,51 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
||||||
</div>
|
</div>
|
||||||
</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). ──
|
{/* ── Две равноправные hero-цены: «Выставить» (asking) + «Получить» (sold). ──
|
||||||
При hasSold=false (старые оценки / пустая ratio) — одиночный layout
|
При hasSold=false (старые оценки / пустая ratio) — одиночный layout
|
||||||
только с «Выставить». Empty-state при insufficientData. */}
|
только с «Выставить». Empty-state при insufficientData. */}
|
||||||
|
|
|
||||||
|
|
@ -2094,6 +2094,69 @@
|
||||||
color: var(--accent-2);
|
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) ──
|
/* ── «Что-если» интерактивный пересчёт (web-преимущество над PDF) ──
|
||||||
Компактная панель над HeroSummary: segmented ремонт + stepper/slider этаж +
|
Компактная панель над HeroSummary: segmented ремонт + stepper/slider этаж +
|
||||||
numeric площадь + балкон. Делает тот же POST /estimate (debounced) и диффит
|
numeric площадь + балкон. Делает тот же POST /estimate (debounced) и диффит
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,22 @@ export interface AggregatedEstimate {
|
||||||
// (PriceTrendCard). null / <2 точек → карточка не рендерится.
|
// (PriceTrendCard). null / <2 точек → карточка не рендерится.
|
||||||
// last_scraped_at объявлен выше (был дубль #688+#689 → ломал build).
|
// last_scraped_at объявлен выше (был дубль #688+#689 → ломал build).
|
||||||
price_trend?: PriceTrendPoint[] | null;
|
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 ──
|
// ── Stage 4a/4b response types ──
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue