feat(tradein): HeroSummary empty-state при insufficient_data (#740) #743

Merged
bot-reviewer merged 1 commit from feat/740-hero-insufficient-data into main 2026-05-30 15:37:14 +00:00
3 changed files with 51 additions and 27 deletions

View file

@ -95,6 +95,9 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
const m = estimate.median_price_rub;
const lo = estimate.range_low_rub;
const hi = estimate.range_high_rub;
// Пустая оценка (backend #697): нет аналогов/сделок → median<=0. Вместо
// «0,00 млн ₽» в headline показываем явный empty-state.
const insufficientData = estimate.insufficient_data || m <= 0;
// ── Ожидаемая цена продажи (S3). Рисуем второй блок только при валидном числе:
// null/undefined/0 (старые оценки или пустая ratio-таблица) → одиночный layout. ──
@ -294,33 +297,47 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
запроса, с X%). Без него (старые оценки / пустая ratio) headline =
asking-медиана, чтобы блок не оставался без главного числа. */}
<div className="hero-headline">
<div className="headline-label">
{hasSold ? "Ожидаемая цена сделки" : "Рекомендованная цена"}
{showDiscount && (
<span
className="headline-delta"
aria-label={`на ${discountPct}% ниже рекомендованной цены в объявлении`}
>
{discountPct}% к объявлению
</span>
)}
</div>
<div className="headline-value mono">
{formatMln(hasSold ? (sold as number) : m)}
</div>
{hasSold && typeof soldLo === "number" && typeof soldHi === "number" && (
<div className="headline-range mono">
ожидаемый диапазон {formatMln(soldLo)} {formatMln(soldHi)}
{typeof soldPerM2 === "number"
? ` · ${soldPerM2.toLocaleString("ru-RU")} ₽/м²`
: ""}
</div>
)}
{hasSold && (
<p className="headline-note">
Реальные сделки проходят на <b>512% ниже</b> цен в объявлениях это
ожидаемая цена сделки по данным ДКП Росреестра, а не цена запроса.
</p>
{insufficientData ? (
<>
<div className="headline-label">Оценка недоступна</div>
<div className="headline-value-empty">Недостаточно данных для оценки</div>
<p className="headline-note">
Рядом не нашлось достаточно сопоставимых объявлений и сделок, чтобы
рассчитать цену. Уточните адрес или параметры объекта и попробуйте
снова.
</p>
</>
) : (
<>
<div className="headline-label">
{hasSold ? "Ожидаемая цена сделки" : "Рекомендованная цена"}
{showDiscount && (
<span
className="headline-delta"
aria-label={`на ${discountPct}% ниже рекомендованной цены в объявлении`}
>
{discountPct}% к объявлению
</span>
)}
</div>
<div className="headline-value mono">
{formatMln(hasSold ? (sold as number) : m)}
</div>
{hasSold && typeof soldLo === "number" && typeof soldHi === "number" && (
<div className="headline-range mono">
ожидаемый диапазон {formatMln(soldLo)} {formatMln(soldHi)}
{typeof soldPerM2 === "number"
? ` · ${soldPerM2.toLocaleString("ru-RU")} ₽/м²`
: ""}
</div>
)}
{hasSold && (
<p className="headline-note">
Реальные сделки проходят на <b>512% ниже</b> цен в объявлениях это
ожидаемая цена сделки по данным ДКП Росреестра, а не цена запроса.
</p>
)}
</>
)}
</div>

View file

@ -705,6 +705,12 @@
letter-spacing: -0.025em;
color: var(--fg);
}
.headline-value-empty {
font-size: 22px;
font-weight: 600;
line-height: 1.27;
color: var(--fg);
}
.headline-range {
font-size: 12px;
color: var(--muted);

View file

@ -123,6 +123,7 @@ export interface AggregatedEstimate {
confidence: ConfidenceLevel;
confidence_explanation: string | null;
n_analogs: number;
insufficient_data: boolean; // backend #697: true когда median_price_rub <= 0 (нет данных)
period_months: number; // 24
analogs: AnalogLot[]; // top 5-10
actual_deals: AnalogLot[]; // last 12 mo