fix(tradein/v2): честная подача интервала оценки — одна цифра-герой + честный разброс #2159

Merged
lekss361 merged 1 commit from fix/tradein-v2-hero-honest-interval into main 2026-07-02 16:20:07 +00:00
6 changed files with 114 additions and 47 deletions

View file

@ -390,10 +390,18 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
</div>
)}
{/* Две равноправные hero-цены: «Выставить» (asking) + «Получить» (sold).
При hasSold=false (старые оценки / пустая ratio) одиночный layout
только с «Выставить». Empty-state при insufficientData. */}
<div className={`hero-duo${hasSold && !insufficientData ? "" : " hero-duo--single"}`}>
{/* §1.1 «Одна цифра + честный разброс»: expected_sold цифра-герой
(«оценка» / ожидаемая цена продажи), median вторичный ориентир
«рекомендованная цена в объявлении», НЕ конкурирует за внимание.
При hasSold=false (старые оценки / пустая ratio) median остаётся
единственной честной цифрой. Empty-state при insufficientData. */}
<div
className={
!insufficientData && hasSold
? "hero-duo hero-duo--sold-primary"
: "hero-duo hero-duo--single"
}
>
{insufficientData ? (
<div className="hero-duo__cell">
<div className="hero-duo__label">Оценка недоступна</div>
@ -404,11 +412,50 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
снова.
</p>
</div>
) : (
) : hasSold ? (
<>
{/* Левая ячейка: «Выставить в объявлении» */}
{/* Цифра-герой: «оценка» = ожидаемая цена продажи (expected_sold) */}
<div className="hero-duo__cell" data-role="sold">
<div className="hero-duo__label">
Оценка · ожидаемая цена продажи
{showDiscount && (
<span
className="hero-duo__delta"
title="Насколько ожидаемая цена сделки ниже цены в объявлении. Это разрыв «объявление → реальная сделка», а не скидка сервиса."
aria-label={`ожидаемая цена продажи на ${discountPct}% ниже цены в объявлении`}
>
{discountPct}% к объявлению
</span>
)}
</div>
<div className="hero-duo__value mono">{formatMln(sold as number)} </div>
{typeof soldLo === "number" && typeof soldHi === "number" && (
<div className="hero-duo__range mono">
рыночный разброс: {formatMln(soldLo)} {formatMln(soldHi)}
</div>
)}
{typeof soldPerM2 === "number" && (
<div className="hero-duo__perm2 mono">
{soldPerM2.toLocaleString("ru-RU")} /м²
</div>
)}
<p className="hero-duo__hint">
Диапазон отражает разброс цен по рынку, а не погрешность оценки.
</p>
{estimate.ratio_basis === "global_fallback" ? (
<div className="hero-duo__note">
Ср.-городской коэффициент (мало сделок в сегменте)
</div>
) : estimate.ratio_basis === "per_rooms" ? (
<div className="hero-duo__note">
Коэффициент по сделкам той же планировки
</div>
) : null}
</div>
{/* Вторичный ориентир: рекомендованная цена в объявлении (median) */}
<div className="hero-duo__cell" data-role="asking">
<div className="hero-duo__label">Выставить в объявлении</div>
<div className="hero-duo__label">Рекомендованная цена в объявлении</div>
<div className="hero-duo__value mono">{formatMln(m)} </div>
<div className="hero-duo__range mono">
{formatMln(lo)} {formatMln(hi)}
@ -417,44 +464,18 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
{estimate.median_price_per_m2.toLocaleString("ru-RU")} /м²
</div>
</div>
{/* Правая ячейка: «Получить (ожидаемая сделка)» — только при hasSold */}
{hasSold && (
<div className="hero-duo__cell" data-role="sold">
<div className="hero-duo__label">
Получить (ожидаемая сделка)
{showDiscount && (
<span
className="hero-duo__delta"
aria-label={`на ${discountPct}% ниже цены в объявлении`}
>
{discountPct}%
</span>
)}
</div>
<div className="hero-duo__value mono">{formatMln(sold as number)} </div>
{typeof soldLo === "number" && typeof soldHi === "number" && (
<div className="hero-duo__range mono">
{formatMln(soldLo)} {formatMln(soldHi)}
</div>
)}
{typeof soldPerM2 === "number" && (
<div className="hero-duo__perm2 mono">
{soldPerM2.toLocaleString("ru-RU")} /м²
</div>
)}
{estimate.ratio_basis === "global_fallback" ? (
<div className="hero-duo__note">
Ср.-городской коэффициент (мало сделок в сегменте)
</div>
) : estimate.ratio_basis === "per_rooms" ? (
<div className="hero-duo__note">
Коэффициент по сделкам той же планировки
</div>
) : null}
</div>
)}
</>
) : (
<div className="hero-duo__cell" data-role="asking">
<div className="hero-duo__label">Рекомендованная цена в объявлении</div>
<div className="hero-duo__value mono">{formatMln(m)} </div>
<div className="hero-duo__range mono">
{formatMln(lo)} {formatMln(hi)}
</div>
<div className="hero-duo__perm2 mono">
{estimate.median_price_per_m2.toLocaleString("ru-RU")} /м²
</div>
</div>
)}
</div>

View file

@ -58,7 +58,7 @@ export function OfferCard({ estimate, brandSlug }: Props) {
</div>
<div className="card-meta">
<div>
Оценка по рынку:{" "}
Цена в объявлении:{" "}
<b className="mono">
{estimate.insufficient_data ? "—" : `${formatMln(estimate.median_price_rub)}`}
</b>

View file

@ -756,6 +756,35 @@
font-style: italic;
}
/* §1.1 «Одна цифра-герой»: expected_sold доминирует, median вторичный
ориентир (не конкурирует за внимание). Применяется только когда есть
expected_sold (класс .hero-duo--sold-primary). */
.hero-duo--sold-primary {
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
align-items: stretch;
}
/* Цифра-герой (expected_sold) — крупнее, чем вторичный median. */
.hero-duo--sold-primary .hero-duo__cell[data-role=sold] .hero-duo__value {
font-size: 34px;
}
/* Вторичный median-ориентир — приглушённый фон + меньший кегль числа. */
.hero-duo--sold-primary .hero-duo__cell[data-role=asking] {
background: var(--surface-1, var(--bg));
}
.hero-duo--sold-primary .hero-duo__cell[data-role=asking] .hero-duo__value {
font-size: 20px;
font-weight: 600;
color: var(--fg-2);
}
/* Честное пояснение к разбросу — «не погрешность оценки». */
.hero-duo__hint {
margin: 6px 0 0;
font-size: 11px;
line-height: 1.45;
color: var(--muted);
max-width: 44ch;
}
/* ── Компактный ряд бенчмарков под hero-duo ── */
.hero-benchmarks {
display: flex;
@ -1405,6 +1434,7 @@
.form-card { position: static; max-height: none; }
.hero-top { grid-template-columns: 1fr; }
.hero-duo { grid-template-columns: 1fr; }
.hero-duo--sold-primary { grid-template-columns: 1fr; }
.hero-prices { grid-template-columns: 1fr; }
.hero-photo { aspect-ratio: 16/9; }
.progress-list { grid-template-columns: 1fr; }

View file

@ -432,6 +432,20 @@ export default function ResultPanel({
})}
</div>
{/* §1.2 честный разброс: диапазоны цен = рыночный спред, НЕ погрешность
оценки. Ставим сразу под ценовыми карточками, чтобы широкий диапазон
не читался как «неуверенность расчёта». */}
<div
style={{
fontSize: 10,
lineHeight: 1.5,
color: hint,
marginTop: -4,
}}
>
Диапазоны показывают разброс цен на рынке, а не погрешность оценки.
</div>
{/* ranges + radar */}
<div
style={{

View file

@ -68,7 +68,7 @@ export const resultCards: ResultCard[] = [
range: "6,83 — 12,81 млн ₽",
ppm: "199 973 ₽/м²",
delta: "11%",
deltaLabel: "К РЫНКУ",
deltaLabel: "К ОБЪЯВЛ.",
nav: 2,
},
{

View file

@ -855,7 +855,9 @@ export function mapResultPanel(
e.asking_to_sold_ratio != null
? fmtPct((e.asking_to_sold_ratio - 1) * 100)
: "—",
deltaLabel: "К РЫНКУ",
// §1.3: разрыв «цена в объявлении → ожидаемая сделка», НЕ скидка сервиса.
// «К ОБЪЯВЛ.» однозначнее прежнего «К РЫНКУ» (не читается как «скидка от нас»).
deltaLabel: "К ОБЪЯВЛ.",
note:
dealTier != null &&
e.expected_sold_price_rub != null &&