From 482d3caf08c0bde9aacb06e0ad0ba869af516c40 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 27 Jun 2026 22:04:38 +0300 Subject: [PATCH] feat(tradein/ui): manual-review banner + premium-building badge on estimate (#2002) --- .../src/app/ui-preview/estimate/fixture.ts | 11 +++ .../src/components/trade-in/HeroSummary.tsx | 76 +++++++++++++++++++ .../src/components/trade-in/trade-in.css | 63 +++++++++++++++ tradein-mvp/frontend/src/types/trade-in.ts | 16 ++++ 4 files changed, 166 insertions(+) diff --git a/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts b/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts index b9aad584..eadedb6a 100644 --- a/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts +++ b/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts @@ -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 = { diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index 9a9478bd..1b5fa8c0 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -58,6 +58,22 @@ const CONF_LABELS: Record = { low: { txt: "низкая", color: "var(--danger)" }, }; +// #2002: человекочитаемые лейблы класса премиального дома. Ключи — как их отдаёт +// бэкенд (lower-case RU). Неизвестный класс → капитализируем первую букву. +const PREMIUM_CLASS_LABELS: Record = { + премиум: "Премиум", + элит: "Элит", + бизнес: "Бизнес-класс", +}; + +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
{estimate.target_address ?? input.address}
+ {showPremiumBadge && ( + + {premiumLabel} + + )}
ЭТАЖ {floor}/{totalFloors} {yearBuilt ? ` · ${yearBuilt}` : ""} @@ -314,6 +345,51 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
+ {/* ── #2002: баннер «рекомендуется ручная оценка». Рядом с headline-числом, + amber/non-alarming (токены --accent-2). Не презентуем авто-оценку как + твёрдую оферту, когда бэкенд считает её рискованной. */} + {showManualReview && ( +
+
+ + + Рекомендуется ручная оценка + +
+

+ Автоматический расчёт по этому объекту менее надёжен — используйте его + как ориентир, а не как окончательную цену. Для точной стоимости уточните + оценку у специалиста. +

+ {manualReviewReasons.length > 0 && ( +
    + {manualReviewReasons.map((reason, i) => ( +
  • {reason}
  • + ))} +
+ )} +
+ )} + {/* ── Две равноправные hero-цены: «Выставить» (asking) + «Получить» (sold). ── При hasSold=false (старые оценки / пустая ratio) — одиночный layout только с «Выставить». Empty-state при insufficientData. */} 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 1cdc3fc9..ccc124a3 100644 --- a/tradein-mvp/frontend/src/components/trade-in/trade-in.css +++ b/tradein-mvp/frontend/src/components/trade-in/trade-in.css @@ -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) и диффит diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 1093b32f..ca36d025 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -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 ── -- 2.45.3