feat(tradein/ui): две равноправные hero-цены (выставить/получить) + компактный ряд бенчмарков #1823

Merged
lekss361 merged 1 commit from fix/tradein-estimate-hero-restructure into main 2026-06-20 07:18:13 +00:00
4 changed files with 189 additions and 135 deletions

View file

@ -20,20 +20,18 @@
* 1. SourcesProgress estimate prop
* 2. WhatIfPanel baseEstimate + baseInput props
* 3. HeroSummary estimate + input props
* 4. IMVBenchmark benchmark prop (FIXTURE_IMV, available:true)
* 5. CianValuationCard data prop
* 6. HouseInfoCard houses prop (FIXTURE_HOUSES, length>0)
* 7. PlacementHistoryCard estimateId; seed queryKey "placement-history"
* 8. HouseAnalyticsSection estimateId; seed "house-analytics" + "sell-time-sensitivity"
* 9. PhotoUpload estimateId (upload-UI; fetch photo может тихо упасть ок)
* 10. ListingsCard estimate + estimateId; seed "cian-price-changes" пустым
* 11. DealsCard estimate prop
* 12. StreetDealsCard estimate prop
* 13. MapCard estimate prop (analogs имеют lat/lon пины есть)
* 14. DistributionCard estimate prop
* 15. PriceTrendCard estimate prop
* 16. ExposureCard estimate prop
* 17. OfferCard estimate + brandSlug=null props
* 4. HouseInfoCard houses prop (FIXTURE_HOUSES, length>0)
* 5. PlacementHistoryCard estimateId; seed queryKey "placement-history"
* 6. HouseAnalyticsSection estimateId; seed "house-analytics" + "sell-time-sensitivity"
* 7. PhotoUpload estimateId (upload-UI; fetch photo может тихо упасть ок)
* 8. ListingsCard estimate + estimateId; seed "cian-price-changes" пустым
* 9. DealsCard estimate prop
* 10. StreetDealsCard estimate prop
* 11. MapCard estimate prop (analogs имеют lat/lon пины есть)
* 12. DistributionCard estimate prop
* 13. PriceTrendCard estimate prop
* 14. ExposureCard estimate prop
* 15. OfferCard estimate + brandSlug=null props
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@ -41,14 +39,12 @@ import { notFound } from "next/navigation";
import { useState } from "react";
import "@/components/trade-in/trade-in.css";
import { CianValuationCard } from "@/components/trade-in/CianValuationCard";
import { DealsCard } from "@/components/trade-in/DealsCard";
import { DistributionCard } from "@/components/trade-in/DistributionCard";
import { ExposureCard } from "@/components/trade-in/ExposureCard";
import { HeroSummary } from "@/components/trade-in/HeroSummary";
import { HouseAnalyticsSection } from "@/components/trade-in/HouseAnalyticsSection";
import { HouseInfoCard } from "@/components/trade-in/HouseInfoCard";
import { IMVBenchmark } from "@/components/trade-in/IMVBenchmark";
import { ListingsCard } from "@/components/trade-in/ListingsCard";
import { OfferCard } from "@/components/trade-in/OfferCard";
import { PhotoUpload } from "@/components/trade-in/PhotoUpload";
@ -63,7 +59,6 @@ import {
FIXTURE_ANALYTICS,
FIXTURE_ESTIMATE,
FIXTURE_HOUSES,
FIXTURE_IMV,
FIXTURE_INPUT,
FIXTURE_PLACEMENT,
FIXTURE_SALES,
@ -152,12 +147,6 @@ function PreviewContent() {
isResubmitting={false}
/>
{/* 4. IMVBenchmark — передаём пропом напрямую (available:true → не null) */}
<IMVBenchmark benchmark={FIXTURE_IMV} isLoading={false} />
{/* 5. CianValuationCard */}
<CianValuationCard data={FIXTURE_ESTIMATE.cian_valuation} />
{/* 6. HouseInfoCard — передаём пропом напрямую (length>0 → не null) */}
<HouseInfoCard houses={FIXTURE_HOUSES} isLoading={false} />

View file

@ -295,62 +295,107 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
</div>
</div>
{/* ЕДИНЫЙ итог: «Ожидаемая цена сделки» (S2). Доминирующий headline.
При наличии expected_sold показываем его как главный outcome (ниже
запроса, с X%). Без него (старые оценки / пустая ratio) headline =
asking-медиана, чтобы блок не оставался без главного числа. */}
<div className="hero-headline">
{/* Две равноправные hero-цены: «Выставить» (asking) + «Получить» (sold).
При hasSold=false (старые оценки / пустая ratio) одиночный layout
только с «Выставить». Empty-state при insufficientData. */}
<div className={`hero-duo${hasSold && !insufficientData ? "" : " hero-duo--single"}`}>
{insufficientData ? (
<>
<div className="headline-label">Оценка недоступна</div>
<div className="hero-duo__cell">
<div className="hero-duo__label">Оценка недоступна</div>
<div className="headline-value-empty">Недостаточно данных для оценки</div>
<p className="headline-note">
Рядом не нашлось достаточно сопоставимых объявлений и сделок, чтобы
рассчитать цену. Уточните адрес или параметры объекта и попробуйте
снова.
</p>
</>
</div>
) : (
<>
<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 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>
{/* Правая ячейка: «Получить (ожидаемая сделка)» — только при hasSold */}
{hasSold && (
<p className="headline-note">
Реальные сделки проходят на <b>512% ниже</b> цен в объявлениях это
ожидаемая цена сделки по данным ДКП Росреестра, а не цена запроса.
</p>
)}
{hasSold && estimate.ratio_basis === "global_fallback" && (
<p className="headline-note" style={{ marginTop: 6 }}>
Вероятная цена продажи рассчитана по средне-городскому коэффициенту
мало зарегистрированных сделок в данном сегменте комнатности. Оценка
ориентировочная.
</p>
<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>
)}
</div>
)}
</>
)}
</div>
{/* Компактный ряд бенчмарков: Avito IMV · Cian · ДКП
Вторичный контекст-якорь под hero, не конкурирует по размеру. */}
{!insufficientData && (imvPrice !== null || estimate.cian_valuation?.sale_price_rub != null || showDkp) && (
<div className="hero-benchmarks">
{imvPrice !== null && (
<div className="bench-chip">
<span className="bench-chip__src">Avito IMV</span>
<span className="bench-chip__val mono">{formatMln(imvPrice)} </span>
</div>
)}
{estimate.cian_valuation?.sale_price_rub != null && (
<div className="bench-chip">
<span className="bench-chip__src">Cian</span>
<span className="bench-chip__val mono">
{formatMln(estimate.cian_valuation.sale_price_rub)}
</span>
{estimate.cian_valuation.rent_price_rub != null && (
<span className="bench-chip__sub mono">
аренда {(estimate.cian_valuation.rent_price_rub / 1000).toFixed(0)} т.р./мес
</span>
)}
</div>
)}
{showDkp && dkp && (
<div className="bench-chip">
<span className="bench-chip__src">ДКП · Росреестр</span>
<span className="bench-chip__val mono">
{formatMln(dkp.median_ppm2 * dkpArea)}
</span>
<span className="bench-chip__sub mono">
{dkp.median_ppm2.toLocaleString("ru-RU")} /м² · {dkp.count} сд.
</span>
</div>
)}
</div>
)}
{/* Доверие + действия (web-преимущества над PDF): свежесть данных,
collapsible «Как рассчитано», PDF-отчёт, CTA трейд-ин. Headline выше
остаётся чистым прозрачность вторична/collapsible. */}

View file

@ -114,14 +114,6 @@ export function SourcesProgress({ estimate, isPending }: Props) {
<span data-tnum>{doneCount}</span>
<span className="total"> / {totalCount}</span> источников
</div>
{estimate && (
<div style={{ marginTop: 4 }}>
медиана:{" "}
<b className="mono" style={{ color: "var(--fg)" }}>
{estimate.insufficient_data ? "—" : `${formatMln(estimate.median_price_rub)}`}
</b>
</div>
)}
</div>
</div>

View file

@ -689,54 +689,117 @@
border-color: var(--border-strong, var(--border));
}
/* Единый headline-итог: «Ожидаемая цена сделки» (#648 IA redesign)
Доминирует над двумя reference-figure ниже: больший размер value,
акцентный фон. Снимает «два одинаковых числа» это ОДНО главное число. */
.hero-headline {
padding: 20px 22px 16px;
/* Две равноправные hero-цены: «Выставить» + «Получить»
Равный визуальный вес: одинаковый размер value у обоих. */
.hero-duo {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
padding: 20px 22px;
border-bottom: 1px solid var(--border);
}
.hero-duo--single { grid-template-columns: 1fr; }
.hero-duo__cell {
display: flex;
flex-direction: column;
gap: 6px;
background: var(--accent-soft);
border-bottom: 1px solid color-mix(in oklch, var(--accent) 18%, var(--border));
gap: 5px;
padding: 18px 20px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.headline-label {
.hero-duo__cell[data-role=sold] {
background: var(--accent-soft);
border-color: color-mix(in oklch, var(--accent) 22%, var(--border));
}
.hero-duo__label {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
flex-wrap: wrap;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.01em;
color: var(--accent-ink);
}
.headline-delta {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
padding: 2px 8px;
letter-spacing: 0.02em;
color: var(--muted);
}
.hero-duo__cell[data-role=sold] .hero-duo__label { color: var(--accent-ink); }
.hero-duo__delta {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
padding: 1px 6px;
border-radius: var(--radius-sm);
background: color-mix(in oklch, var(--accent) 16%, transparent);
color: var(--accent-ink);
}
.headline-value {
font-size: 38px;
.hero-duo__value {
font-size: 28px;
font-weight: 700;
line-height: 1.05;
letter-spacing: -0.025em;
line-height: 1.1;
letter-spacing: -0.02em;
color: var(--fg);
}
.hero-duo__range {
font-size: 12px;
color: var(--muted);
letter-spacing: 0.01em;
}
.hero-duo__perm2 {
font-size: 11px;
color: var(--muted-2);
letter-spacing: 0.04em;
}
.hero-duo__note {
margin-top: 4px;
font-size: 11px;
color: var(--muted);
font-style: italic;
}
/* ── Компактный ряд бенчмарков под hero-duo ── */
.hero-benchmarks {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 14px 22px;
border-bottom: 1px solid var(--border);
background: var(--surface-1, var(--bg));
}
.bench-chip {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 12px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
min-width: 120px;
}
.bench-chip__src {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted-2);
}
.bench-chip__val {
font-size: 14px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--fg);
}
.bench-chip__sub {
font-size: 10px;
color: var(--muted);
}
/* ── empty-state headline (без данных) ── */
.headline-value-empty {
font-size: 22px;
font-weight: 600;
line-height: 1.27;
color: var(--fg);
}
.headline-range {
font-size: 12px;
color: var(--muted);
letter-spacing: 0.01em;
}
.headline-note {
margin: 4px 0 0;
font-size: 12px;
@ -745,43 +808,7 @@
max-width: 60ch;
}
.headline-note b { font-weight: 700; }
.price-figure-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.02em;
color: var(--muted);
}
.price-figure[data-kind="sold"] .price-figure-label { color: var(--accent-ink); }
.price-figure-delta {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
padding: 1px 6px;
border-radius: var(--radius-sm);
background: color-mix(in oklch, var(--accent) 14%, transparent);
color: var(--accent-ink);
}
.price-figure-value {
font-size: 28px;
font-weight: 600;
line-height: 1.1;
letter-spacing: -0.02em;
color: var(--fg);
}
.price-figure-range {
font-size: 12px;
color: var(--muted);
letter-spacing: 0.01em;
}
.price-figure-perm2 {
font-size: 11px;
color: var(--muted-2);
letter-spacing: 0.04em;
}
.hero-prices-note {
margin: 0;
padding: 12px 22px 0;
@ -1377,6 +1404,7 @@
.layout { grid-template-columns: 1fr; }
.form-card { position: static; max-height: none; }
.hero-top { grid-template-columns: 1fr; }
.hero-duo { grid-template-columns: 1fr; }
.hero-prices { grid-template-columns: 1fr; }
.hero-photo { aspect-ratio: 16/9; }
.progress-list { grid-template-columns: 1fr; }