diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
index ece2a03c..cfe1d3ea 100644
--- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
@@ -348,11 +348,15 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
{soldPerM2.toLocaleString("ru-RU")} ₽/м²
)}
- {estimate.ratio_basis === "global_fallback" && (
+ {estimate.ratio_basis === "global_fallback" ? (
- Ср.-городской коэффициент — мало сделок в сегменте
+ Ср.-городской коэффициент (мало сделок в сегменте)
- )}
+ ) : estimate.ratio_basis === "per_rooms" ? (
+
+ Коэффициент по сделкам той же планировки
+
+ ) : null}
)}
>
@@ -523,6 +527,69 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
);
})()}
+
+ {/* ── Задача 3: Бар «Ожидаемая цена сделки» (expected_sold) ──
+ Показывается только если поле присутствует и > 0. Graceful при null.
+ Визуально отличается: акцентный градиент (--viz-5/--viz-6) и пометка. */}
+ {hasSold && (() => {
+ // Строим бар либо из диапазона (low/high), либо из точечного значения ±0 span.
+ const esLo =
+ typeof soldLo === "number" && soldLo > 0 ? soldLo : (sold as number);
+ const esHi =
+ typeof soldHi === "number" && soldHi > 0 ? soldHi : (sold as number);
+ const esM = sold as number;
+ const esSpan = esHi - esLo;
+ const esMedPct =
+ esSpan > 0
+ ? Math.max(5, Math.min(95, ((esM - esLo) / esSpan) * 100))
+ : 50;
+ return (
+
+
+
+ Ожидаемая цена сделки{" "}
+ (прогноз)
+
+ медиана · {formatMln(esM)} ₽
+
+
+
+
+
+
+ {formatMln(esM)} ₽
+
+ {typeof soldPerM2 === "number" && (
+
+ {soldPerM2.toLocaleString("ru-RU")} ₽/м²
+
+ )}
+ {showDiscount && (
+
+ −{discountPct}% к объявлению
+
+ )}
+
+
+ );
+ })()}
>
)}
diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx
index fe54a325..62f4f8a9 100644
--- a/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx
@@ -18,7 +18,7 @@
*/
import { useState, useEffect } from "react";
-import type { AggregatedEstimate } from "@/types/trade-in";
+import type { AggregatedEstimate, AnalogTier } from "@/types/trade-in";
import { API_BASE_URL } from "@/lib/api";
interface Props {
@@ -35,6 +35,26 @@ const CONF_BAND: Record = {
low: "низкая достоверность",
};
+/**
+ * Структурный маппинг analog_tier → читаемый лейбл.
+ * Используется ПРЕДПОЧТИТЕЛЬНО если бэкенд уже отдаёт поле.
+ */
+const ANALOG_TIER_LABELS: Record = {
+ same_building: "по аналогам того же дома",
+ micro_radius: "по ближайшему окружению ≤500 м",
+ district: "по аналогам района",
+ city: "по аналогам города (широкий радиус)",
+};
+
+/**
+ * Маппинг address_precision → читаемый лейбл для коллапсибл-деталей.
+ */
+const PRECISION_LABELS: Record = {
+ house: "до дома",
+ street: "до улицы",
+ approximate: "приблизительно",
+};
+
/**
* Лид-инбокс. Задаётся ТОЛЬКО через env NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL.
* БЕЗ fallback: если не задан → CTA «оставить заявку» скрывается (leadEnabled),
@@ -43,9 +63,12 @@ const CONF_BAND: Record = {
const CONTACT_EMAIL = (process.env.NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL ?? "").trim();
/**
- * Эвристика уровня сопоставимых объектов из confidence_explanation.
+ * LEGACY fallback: эвристика уровня сопоставимых объектов из confidence_explanation.
* Бэкенд кладёт туда формулировку якорного tier'а; вытаскиваем человекочитаемую.
* Null → ничего не показываем (graceful).
+ *
+ * Используется только если бэкенд ещё не отдаёт структурного analog_tier —
+ * предпочитай resolveCompsTier() которая сначала смотрит на analog_tier.
*/
function compsTier(explanation: string | null): string | null {
// FIXME: backend should expose structured comps_tier (#695 follow-up).
@@ -64,6 +87,17 @@ function compsTier(explanation: string | null): string | null {
return null;
}
+/**
+ * Предпочитает структурное поле analog_tier (если задано бэкендом).
+ * Если absent/null — graceful fallback на compsTier() эвристику.
+ */
+function resolveCompsTier(estimate: AggregatedEstimate): string | null {
+ if (estimate.analog_tier) {
+ return ANALOG_TIER_LABELS[estimate.analog_tier] ?? null;
+ }
+ return compsTier(estimate.confidence_explanation);
+}
+
/**
* «27 мая 2026, 14:30» — timeZone: "UTC" фиксирует вывод одинаково на SSR (Node UTC)
* и клиенте (любой TZ). Иначе «12:00» на сервере → «15:00» у клиента в Москве → React #418.
@@ -93,7 +127,16 @@ function freshnessFromScraped(estimate: AggregatedEstimate): Date | null {
export function HeroTransparency({ estimate, brandSlug, brandName }: Props) {
const band = CONF_BAND[estimate.confidence] ?? CONF_BAND.low;
- const tier = compsTier(estimate.confidence_explanation);
+ // Задача 1: предпочитаем структурный analog_tier, fallback на эвристику строки.
+ const tier = resolveCompsTier(estimate);
+ // Задача 2: точность адреса в деталях (если задана).
+ const precisionLabel =
+ estimate.address_precision != null
+ ? (PRECISION_LABELS[estimate.address_precision] ?? null)
+ : null;
+ // Предупреждение при low-confidence + приблизительном адресе.
+ const showPrecisionWarning =
+ estimate.confidence === "low" && estimate.address_precision === "approximate";
// SSR-safe: берём last_scraped_at (статичная ISO строка — нет мismatch).
// Если его нет, fallback на data_freshness_minutes через Date.now() —
@@ -190,6 +233,29 @@ export function HeroTransparency({ estimate, brandSlug, brandName }: Props) {
Достоверность оценки
{band}
+ {precisionLabel && (
+
+ Точность адреса
+
+ {precisionLabel}
+ {showPrecisionWarning && (
+
+ ⚠️
+
+ )}
+
+
+ )}
{estimate.confidence_explanation && !estimate.confidence_explanation.startsWith("address_not_geocoded") && (
{estimate.confidence_explanation}
diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts
index b239c780..1093b32f 100644
--- a/tradein-mvp/frontend/src/types/trade-in.ts
+++ b/tradein-mvp/frontend/src/types/trade-in.ts
@@ -45,6 +45,10 @@ export type ConfidenceLevel = "low" | "medium" | "high";
// Точность гео-привязки адреса (из DaData qc_geo): house=0, street=1, approximate≥2.
export type AddressPrecision = "house" | "street" | "approximate";
+// Структурный уровень аналогов (#695 follow-up) — бэкенд отдаёт начиная с deploy ветки
+// feat/estimator-ui-transparency. До деплоя поле absent/null → graceful fallback на compsTier().
+export type AnalogTier = "same_building" | "micro_radius" | "district" | "city";
+
export interface TradeInEstimateInput {
address: string; // min 3, max 500
area_m2: number; // 10 < x < 500
@@ -167,6 +171,9 @@ export interface AggregatedEstimate {
// «approximate» (qc_geo≥2: населённый пункт/город/регион/не распознан).
// null если DaData не отрабатывала (адрес не геокодирован) / credentials не заданы.
address_precision?: AddressPrecision | null;
+ // ── Структурный уровень аналогов (feat/estimator-ui-transparency) ──
+ // Появляется после деплоя backend'а; до этого absent/null → UI graceful fallback.
+ analog_tier?: AnalogTier | null;
// ── Параметры оценённой квартиры (для восстановления карточки по ?id=) ──
area_m2: number | null;
rooms: number | null;