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> </div>
)} )}
{/* Две равноправные hero-цены: «Выставить» (asking) + «Получить» (sold). {/* §1.1 «Одна цифра + честный разброс»: expected_sold цифра-герой
При hasSold=false (старые оценки / пустая ratio) одиночный layout («оценка» / ожидаемая цена продажи), median вторичный ориентир
только с «Выставить». Empty-state при insufficientData. */} «рекомендованная цена в объявлении», НЕ конкурирует за внимание.
<div className={`hero-duo${hasSold && !insufficientData ? "" : " hero-duo--single"}`}> При hasSold=false (старые оценки / пустая ratio) median остаётся
единственной честной цифрой. Empty-state при insufficientData. */}
<div
className={
!insufficientData && hasSold
? "hero-duo hero-duo--sold-primary"
: "hero-duo hero-duo--single"
}
>
{insufficientData ? ( {insufficientData ? (
<div className="hero-duo__cell"> <div className="hero-duo__cell">
<div className="hero-duo__label">Оценка недоступна</div> <div className="hero-duo__label">Оценка недоступна</div>
@ -404,11 +412,50 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
снова. снова.
</p> </p>
</div> </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__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__value mono">{formatMln(m)} </div>
<div className="hero-duo__range mono"> <div className="hero-duo__range mono">
{formatMln(lo)} {formatMln(hi)} {formatMln(lo)} {formatMln(hi)}
@ -417,44 +464,18 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
{estimate.median_price_per_m2.toLocaleString("ru-RU")} /м² {estimate.median_price_per_m2.toLocaleString("ru-RU")} /м²
</div> </div>
</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> </div>

View file

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

View file

@ -756,6 +756,35 @@
font-style: italic; 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-duo ── */
.hero-benchmarks { .hero-benchmarks {
display: flex; display: flex;
@ -1405,6 +1434,7 @@
.form-card { position: static; max-height: none; } .form-card { position: static; max-height: none; }
.hero-top { grid-template-columns: 1fr; } .hero-top { grid-template-columns: 1fr; }
.hero-duo { 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-prices { grid-template-columns: 1fr; }
.hero-photo { aspect-ratio: 16/9; } .hero-photo { aspect-ratio: 16/9; }
.progress-list { grid-template-columns: 1fr; } .progress-list { grid-template-columns: 1fr; }

View file

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

View file

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

View file

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