feat(tradein/ui): две равноправные hero-цены (выставить/получить) + компактный ряд бенчмарков #1823
4 changed files with 189 additions and 135 deletions
|
|
@ -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} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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>5–12% ниже</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. ── */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue