diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index ee72f119..969aa3a9 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -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 +
+
+
Ориентир запроса
+
{formatMln(m)} ₽
+
+ {formatMln(lo)} – {formatMln(hi)} ₽ +
+
+ {estimate.median_price_per_m2.toLocaleString("ru-RU")} ₽/м² +
+
+ + {hasSold && ( +
+
+ Ожидаемая цена продажи + {showDiscount && ( + + −{discountPct}% + + )} +
+
{formatMln(sold as number)} ₽
+ {typeof soldLo === "number" && typeof soldHi === "number" && ( +
+ {formatMln(soldLo)} – {formatMln(soldHi)} ₽ +
+ )} + {typeof soldPerM2 === "number" && ( +
+ {soldPerM2.toLocaleString("ru-RU")} ₽/м² +
+ )} +
+ )} +
+ + {hasSold && ( +

+ Запрос — по чему выставлены сопоставимые квартиры в объявлениях.{" "} + Ожидаемая цена продажи — реалистичная цена сделки по данным ДКП Росреестра, обычно + ниже запроса. +

+ )} +
diff --git a/tradein-mvp/frontend/src/components/trade-in/trade-in.css b/tradein-mvp/frontend/src/components/trade-in/trade-in.css index 28bb70e0..6bbe0280 100644 --- a/tradein-mvp/frontend/src/components/trade-in/trade-in.css +++ b/tradein-mvp/frontend/src/components/trade-in/trade-in.css @@ -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)); } diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 4ade4328..f6aa9ad6 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -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;