feat(tradein/ui): две равноправные hero-цены (выставить/получить) + компактный ряд бенчмарков, убрать дубли
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped

This commit is contained in:
bot-backend 2026-06-20 10:14:27 +03:00
parent 7639dfe5fd
commit 44fdab2d5e
4 changed files with 189 additions and 135 deletions

View file

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

View file

@ -295,62 +295,107 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
</div> </div>
</div> </div>
{/* ЕДИНЫЙ итог: «Ожидаемая цена сделки» (S2). Доминирующий headline. {/* Две равноправные hero-цены: «Выставить» (asking) + «Получить» (sold).
При наличии expected_sold показываем его как главный outcome (ниже При hasSold=false (старые оценки / пустая ratio) одиночный layout
запроса, с X%). Без него (старые оценки / пустая ratio) headline = только с «Выставить». Empty-state при insufficientData. */}
asking-медиана, чтобы блок не оставался без главного числа. */} <div className={`hero-duo${hasSold && !insufficientData ? "" : " hero-duo--single"}`}>
<div className="hero-headline">
{insufficientData ? ( {insufficientData ? (
<> <div className="hero-duo__cell">
<div className="headline-label">Оценка недоступна</div> <div className="hero-duo__label">Оценка недоступна</div>
<div className="headline-value-empty">Недостаточно данных для оценки</div> <div className="headline-value-empty">Недостаточно данных для оценки</div>
<p className="headline-note"> <p className="headline-note">
Рядом не нашлось достаточно сопоставимых объявлений и сделок, чтобы Рядом не нашлось достаточно сопоставимых объявлений и сделок, чтобы
рассчитать цену. Уточните адрес или параметры объекта и попробуйте рассчитать цену. Уточните адрес или параметры объекта и попробуйте
снова. снова.
</p> </p>
</> </div>
) : ( ) : (
<> <>
<div className="headline-label"> {/* Левая ячейка: «Выставить в объявлении» */}
{hasSold ? "Ожидаемая цена сделки" : "Рекомендованная цена"} <div className="hero-duo__cell" data-role="asking">
{showDiscount && ( <div className="hero-duo__label">Выставить в объявлении</div>
<span <div className="hero-duo__value mono">{formatMln(m)} </div>
className="headline-delta" <div className="hero-duo__range mono">
aria-label={`на ${discountPct}% ниже рекомендованной цены в объявлении`} {formatMln(lo)} {formatMln(hi)}
>
{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> </div>
)} <div className="hero-duo__perm2 mono">
{estimate.median_price_per_m2.toLocaleString("ru-RU")} /м²
</div>
</div>
{/* Правая ячейка: «Получить (ожидаемая сделка)» — только при hasSold */}
{hasSold && ( {hasSold && (
<p className="headline-note"> <div className="hero-duo__cell" data-role="sold">
Реальные сделки проходят на <b>512% ниже</b> цен в объявлениях это <div className="hero-duo__label">
ожидаемая цена сделки по данным ДКП Росреестра, а не цена запроса. Получить (ожидаемая сделка)
</p> {showDiscount && (
)} <span
{hasSold && estimate.ratio_basis === "global_fallback" && ( className="hero-duo__delta"
<p className="headline-note" style={{ marginTop: 6 }}> aria-label={`на ${discountPct}% ниже цены в объявлении`}
Вероятная цена продажи рассчитана по средне-городскому коэффициенту >
мало зарегистрированных сделок в данном сегменте комнатности. Оценка {discountPct}%
ориентировочная. </span>
</p> )}
</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> </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): свежесть данных, {/* Доверие + действия (web-преимущества над PDF): свежесть данных,
collapsible «Как рассчитано», PDF-отчёт, CTA трейд-ин. Headline выше collapsible «Как рассчитано», PDF-отчёт, CTA трейд-ин. Headline выше
остаётся чистым прозрачность вторична/collapsible. */} остаётся чистым прозрачность вторична/collapsible. */}

View file

@ -114,14 +114,6 @@ export function SourcesProgress({ estimate, isPending }: Props) {
<span data-tnum>{doneCount}</span> <span data-tnum>{doneCount}</span>
<span className="total"> / {totalCount}</span> источников <span className="total"> / {totalCount}</span> источников
</div> </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>
</div> </div>

View file

@ -689,54 +689,117 @@
border-color: var(--border-strong, var(--border)); border-color: var(--border-strong, var(--border));
} }
/* Единый headline-итог: «Ожидаемая цена сделки» (#648 IA redesign) /* Две равноправные hero-цены: «Выставить» + «Получить»
Доминирует над двумя reference-figure ниже: больший размер value, Равный визуальный вес: одинаковый размер value у обоих. */
акцентный фон. Снимает «два одинаковых числа» это ОДНО главное число. */ .hero-duo {
.hero-headline { display: grid;
padding: 20px 22px 16px; 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; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 5px;
background: var(--accent-soft); padding: 18px 20px;
border-bottom: 1px solid color-mix(in oklch, var(--accent) 18%, var(--border)); 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; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
flex-wrap: wrap; 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-size: 12px;
font-weight: 600; 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); border-radius: var(--radius-sm);
background: color-mix(in oklch, var(--accent) 16%, transparent); background: color-mix(in oklch, var(--accent) 16%, transparent);
color: var(--accent-ink); color: var(--accent-ink);
} }
.headline-value { .hero-duo__value {
font-size: 38px; font-size: 28px;
font-weight: 700; font-weight: 700;
line-height: 1.05; line-height: 1.1;
letter-spacing: -0.025em; letter-spacing: -0.02em;
color: var(--fg); 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 { .headline-value-empty {
font-size: 22px; font-size: 22px;
font-weight: 600; font-weight: 600;
line-height: 1.27; line-height: 1.27;
color: var(--fg); color: var(--fg);
} }
.headline-range {
font-size: 12px;
color: var(--muted);
letter-spacing: 0.01em;
}
.headline-note { .headline-note {
margin: 4px 0 0; margin: 4px 0 0;
font-size: 12px; font-size: 12px;
@ -745,43 +808,7 @@
max-width: 60ch; max-width: 60ch;
} }
.headline-note b { font-weight: 700; } .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 { .hero-prices-note {
margin: 0; margin: 0;
padding: 12px 22px 0; padding: 12px 22px 0;
@ -1377,6 +1404,7 @@
.layout { grid-template-columns: 1fr; } .layout { grid-template-columns: 1fr; }
.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-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; }