feat(tradein): dual-display asking vs expected-sold in HeroSummary (#648 S5a)
Show «Ориентир запроса» (asking median) and «Ожидаемая цена продажи» (expected sold, S3 rosreestr ДКП) as two EQUAL-weight figures (same value typography; sold card gets a subtle accent tint only), each with range + ₽/м², plus a tasteful −N% discount chip (aria-labelled, Unicode minus) and an explainer. Graceful: expected_sold null/0 (old estimates / empty ratio table) → single asking figure, no empty card/rows. Tokens-only CSS, responsive. tsc --noEmit clean (TS strict); npm run build passes.
This commit is contained in:
parent
35bd0238ef
commit
00484fc02f
3 changed files with 145 additions and 1 deletions
|
|
@ -73,6 +73,23 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
|||
const lo = estimate.range_low_rub;
|
||||
const hi = estimate.range_high_rub;
|
||||
|
||||
// ── Ожидаемая цена продажи (S3). Рисуем второй блок только при валидном числе:
|
||||
// null/undefined/0 (старые оценки или пустая ratio-таблица) → одиночный layout. ──
|
||||
const sold = estimate.expected_sold_price_rub;
|
||||
const hasSold = typeof sold === "number" && sold > 0;
|
||||
const soldLo = estimate.expected_sold_range_low_rub;
|
||||
const soldHi = estimate.expected_sold_range_high_rub;
|
||||
const soldPerM2 = estimate.expected_sold_per_m2;
|
||||
// Скидка запрос→продажа: из ratio при наличии, иначе из самих чисел. Только если ≥1%.
|
||||
const discountPct = hasSold
|
||||
? Math.round(
|
||||
(typeof estimate.asking_to_sold_ratio === "number" && estimate.asking_to_sold_ratio > 0
|
||||
? 1 - estimate.asking_to_sold_ratio
|
||||
: 1 - (sold as number) / m) * 100,
|
||||
)
|
||||
: 0;
|
||||
const showDiscount = hasSold && discountPct >= 1;
|
||||
|
||||
const showMockDisclaimer = isMockAddress(estimate);
|
||||
|
||||
// Progressive enrichment state
|
||||
|
|
@ -237,6 +254,51 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`hero-prices${hasSold ? "" : " single"}`}>
|
||||
<div className="price-figure" data-kind="asking">
|
||||
<div className="price-figure-label">Ориентир запроса</div>
|
||||
<div className="price-figure-value mono">{formatMln(m)} ₽</div>
|
||||
<div className="price-figure-range mono">
|
||||
{formatMln(lo)} – {formatMln(hi)} ₽
|
||||
</div>
|
||||
<div className="price-figure-perm2 mono">
|
||||
{estimate.median_price_per_m2.toLocaleString("ru-RU")} ₽/м²
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasSold && (
|
||||
<div className="price-figure" data-kind="sold">
|
||||
<div className="price-figure-label">
|
||||
Ожидаемая цена продажи
|
||||
{showDiscount && (
|
||||
<span className="price-figure-delta" aria-label={`на ${discountPct}% ниже запроса`}>
|
||||
−{discountPct}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="price-figure-value mono">{formatMln(sold as number)} ₽</div>
|
||||
{typeof soldLo === "number" && typeof soldHi === "number" && (
|
||||
<div className="price-figure-range mono">
|
||||
{formatMln(soldLo)} – {formatMln(soldHi)} ₽
|
||||
</div>
|
||||
)}
|
||||
{typeof soldPerM2 === "number" && (
|
||||
<div className="price-figure-perm2 mono">
|
||||
{soldPerM2.toLocaleString("ru-RU")} ₽/м²
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasSold && (
|
||||
<p className="hero-prices-note">
|
||||
<b>Запрос</b> — по чему выставлены сопоставимые квартиры в объявлениях.{" "}
|
||||
<b>Ожидаемая цена продажи</b> — реалистичная цена сделки по данным ДКП Росреестра, обычно
|
||||
ниже запроса.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="hero-bars">
|
||||
<div className="bar-block">
|
||||
<div className="bar-head">
|
||||
|
|
|
|||
|
|
@ -624,6 +624,75 @@
|
|||
.meta-grid .meta-row .v { color: var(--fg); font-weight: 500; }
|
||||
.meta-grid .meta-row .v.mono { font-family: var(--font-mono); }
|
||||
|
||||
/* Два равнозначных ценовых ориентира: запрос vs ожидаемая продажа (#648 S5a).
|
||||
Одинаковый размер value у обоих — ни один не доминирует. */
|
||||
.hero-prices {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
padding: 20px 22px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.hero-prices.single { grid-template-columns: 1fr; }
|
||||
.price-figure {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 16px 18px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.price-figure[data-kind="sold"] {
|
||||
background: var(--accent-soft);
|
||||
border-color: color-mix(in oklch, var(--accent) 22%, var(--border));
|
||||
}
|
||||
.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;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
}
|
||||
.hero-prices-note b { color: var(--fg-2); font-weight: 600; }
|
||||
|
||||
.hero-bars {
|
||||
padding: 18px 22px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
|
@ -1070,6 +1139,7 @@
|
|||
.layout { grid-template-columns: 1fr; }
|
||||
.form-card { position: static; max-height: none; }
|
||||
.hero-top { grid-template-columns: 1fr; }
|
||||
.hero-prices { grid-template-columns: 1fr; }
|
||||
.hero-photo { aspect-ratio: 16/9; }
|
||||
.progress-list { grid-template-columns: 1fr; }
|
||||
.advantages { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
|
|
|
|||
|
|
@ -69,12 +69,24 @@ export interface CianValuationSummary {
|
|||
chart_change_direction: 'increase' | 'decrease' | 'neutral' | null;
|
||||
}
|
||||
|
||||
// Базис коэффициента asking→sold (S3): по группе комнат либо городской fallback.
|
||||
export type RatioBasis = "per_rooms" | "global_fallback";
|
||||
|
||||
export interface AggregatedEstimate {
|
||||
estimate_id: string; // UUID
|
||||
median_price_rub: number;
|
||||
median_price_rub: number; // ASKING-ориентир: по чему выставлены аналоги
|
||||
range_low_rub: number;
|
||||
range_high_rub: number;
|
||||
median_price_per_m2: number;
|
||||
// ── Ожидаемая цена продажи (S3) — реалистичная цена сделки по ДКП Росреестра,
|
||||
// обычно ~на 15–20% ниже asking. Все поля optional + nullable: старые оценки
|
||||
// их не содержат, а graceful-degradation отдаёт null при пустой ratio-таблице.
|
||||
expected_sold_price_rub?: number | null;
|
||||
expected_sold_range_low_rub?: number | null;
|
||||
expected_sold_range_high_rub?: number | null;
|
||||
expected_sold_per_m2?: number | null;
|
||||
asking_to_sold_ratio?: number | null; // sold / asking (≈0.82)
|
||||
ratio_basis?: RatioBasis | null; // 'per_rooms' | 'global_fallback'
|
||||
confidence: ConfidenceLevel;
|
||||
confidence_explanation: string | null;
|
||||
n_analogs: number;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue