Merge pull request 'feat(trade-in/ui): transparency «Мера» — analog_tier, address_precision, expected_sold бар, ratio_basis' (#1837) from feat/estimator-ui-transparency into main
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 6s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been cancelled
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 6s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been cancelled
Reviewed-on: #1837
This commit is contained in:
commit
56e1c6e62d
3 changed files with 146 additions and 6 deletions
|
|
@ -348,11 +348,15 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
|||
{soldPerM2.toLocaleString("ru-RU")} ₽/м²
|
||||
</div>
|
||||
)}
|
||||
{estimate.ratio_basis === "global_fallback" && (
|
||||
{estimate.ratio_basis === "global_fallback" ? (
|
||||
<div className="hero-duo__note">
|
||||
Ср.-городской коэффициент — мало сделок в сегменте
|
||||
Ср.-городской коэффициент (мало сделок в сегменте)
|
||||
</div>
|
||||
)}
|
||||
) : estimate.ratio_basis === "per_rooms" ? (
|
||||
<div className="hero-duo__note">
|
||||
Коэффициент по сделкам той же планировки
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -523,6 +527,69 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
|||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* ── Задача 3: Бар «Ожидаемая цена сделки» (expected_sold) ──
|
||||
Показывается только если поле присутствует и > 0. Graceful при null.
|
||||
Визуально отличается: акцентный градиент (--viz-5/--viz-6) и пометка. */}
|
||||
{hasSold && (() => {
|
||||
// Строим бар либо из диапазона (low/high), либо из точечного значения ±0 span.
|
||||
const esLo =
|
||||
typeof soldLo === "number" && soldLo > 0 ? soldLo : (sold as number);
|
||||
const esHi =
|
||||
typeof soldHi === "number" && soldHi > 0 ? soldHi : (sold as number);
|
||||
const esM = sold as number;
|
||||
const esSpan = esHi - esLo;
|
||||
const esMedPct =
|
||||
esSpan > 0
|
||||
? Math.max(5, Math.min(95, ((esM - esLo) / esSpan) * 100))
|
||||
: 50;
|
||||
return (
|
||||
<div className="bar-block">
|
||||
<div className="bar-head">
|
||||
<span className="bar-title">
|
||||
Ожидаемая цена сделки{" "}
|
||||
<span style={{ color: "var(--muted-2)" }}>(прогноз)</span>
|
||||
</span>
|
||||
<span className="bar-cv">медиана · {formatMln(esM)} ₽</span>
|
||||
</div>
|
||||
<div className="pricebar">
|
||||
<div className="axis">
|
||||
<div
|
||||
className="range"
|
||||
style={{
|
||||
left: "5%",
|
||||
right: "5%",
|
||||
background:
|
||||
"linear-gradient(90deg,var(--viz-5,#a78bfa),var(--viz-6,#7c3aed))",
|
||||
}}
|
||||
/>
|
||||
<div className="median" style={{ left: `${esMedPct}%` }} />
|
||||
</div>
|
||||
<div className="endpoint" style={{ left: "5%" }}>
|
||||
<div className="endpoint-val">{formatMln(esLo)}</div>
|
||||
<div className="endpoint-tick" />
|
||||
</div>
|
||||
<div className="endpoint" style={{ left: "95%" }}>
|
||||
<div className="endpoint-val">{formatMln(esHi)}</div>
|
||||
<div className="endpoint-tick" />
|
||||
</div>
|
||||
<div className="median-label" style={{ left: `${esMedPct}%`, top: -2 }}>
|
||||
{formatMln(esM)} ₽
|
||||
</div>
|
||||
{typeof soldPerM2 === "number" && (
|
||||
<div className="exp left">
|
||||
{soldPerM2.toLocaleString("ru-RU")} ₽/м²
|
||||
</div>
|
||||
)}
|
||||
{showDiscount && (
|
||||
<div className="exp right" style={{ color: "var(--danger)" }}>
|
||||
−{discountPct}% к объявлению
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { AggregatedEstimate } from "@/types/trade-in";
|
||||
import type { AggregatedEstimate, AnalogTier } from "@/types/trade-in";
|
||||
import { API_BASE_URL } from "@/lib/api";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -35,6 +35,26 @@ const CONF_BAND: Record<string, string> = {
|
|||
low: "низкая достоверность",
|
||||
};
|
||||
|
||||
/**
|
||||
* Структурный маппинг analog_tier → читаемый лейбл.
|
||||
* Используется ПРЕДПОЧТИТЕЛЬНО если бэкенд уже отдаёт поле.
|
||||
*/
|
||||
const ANALOG_TIER_LABELS: Record<AnalogTier, string> = {
|
||||
same_building: "по аналогам того же дома",
|
||||
micro_radius: "по ближайшему окружению ≤500 м",
|
||||
district: "по аналогам района",
|
||||
city: "по аналогам города (широкий радиус)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Маппинг address_precision → читаемый лейбл для коллапсибл-деталей.
|
||||
*/
|
||||
const PRECISION_LABELS: Record<string, string> = {
|
||||
house: "до дома",
|
||||
street: "до улицы",
|
||||
approximate: "приблизительно",
|
||||
};
|
||||
|
||||
/**
|
||||
* Лид-инбокс. Задаётся ТОЛЬКО через env NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL.
|
||||
* БЕЗ fallback: если не задан → CTA «оставить заявку» скрывается (leadEnabled),
|
||||
|
|
@ -43,9 +63,12 @@ const CONF_BAND: Record<string, string> = {
|
|||
const CONTACT_EMAIL = (process.env.NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL ?? "").trim();
|
||||
|
||||
/**
|
||||
* Эвристика уровня сопоставимых объектов из confidence_explanation.
|
||||
* LEGACY fallback: эвристика уровня сопоставимых объектов из confidence_explanation.
|
||||
* Бэкенд кладёт туда формулировку якорного tier'а; вытаскиваем человекочитаемую.
|
||||
* Null → ничего не показываем (graceful).
|
||||
*
|
||||
* Используется только если бэкенд ещё не отдаёт структурного analog_tier —
|
||||
* предпочитай resolveCompsTier() которая сначала смотрит на analog_tier.
|
||||
*/
|
||||
function compsTier(explanation: string | null): string | null {
|
||||
// FIXME: backend should expose structured comps_tier (#695 follow-up).
|
||||
|
|
@ -64,6 +87,17 @@ function compsTier(explanation: string | null): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Предпочитает структурное поле analog_tier (если задано бэкендом).
|
||||
* Если absent/null — graceful fallback на compsTier() эвристику.
|
||||
*/
|
||||
function resolveCompsTier(estimate: AggregatedEstimate): string | null {
|
||||
if (estimate.analog_tier) {
|
||||
return ANALOG_TIER_LABELS[estimate.analog_tier] ?? null;
|
||||
}
|
||||
return compsTier(estimate.confidence_explanation);
|
||||
}
|
||||
|
||||
/**
|
||||
* «27 мая 2026, 14:30» — timeZone: "UTC" фиксирует вывод одинаково на SSR (Node UTC)
|
||||
* и клиенте (любой TZ). Иначе «12:00» на сервере → «15:00» у клиента в Москве → React #418.
|
||||
|
|
@ -93,7 +127,16 @@ function freshnessFromScraped(estimate: AggregatedEstimate): Date | null {
|
|||
|
||||
export function HeroTransparency({ estimate, brandSlug, brandName }: Props) {
|
||||
const band = CONF_BAND[estimate.confidence] ?? CONF_BAND.low;
|
||||
const tier = compsTier(estimate.confidence_explanation);
|
||||
// Задача 1: предпочитаем структурный analog_tier, fallback на эвристику строки.
|
||||
const tier = resolveCompsTier(estimate);
|
||||
// Задача 2: точность адреса в деталях (если задана).
|
||||
const precisionLabel =
|
||||
estimate.address_precision != null
|
||||
? (PRECISION_LABELS[estimate.address_precision] ?? null)
|
||||
: null;
|
||||
// Предупреждение при low-confidence + приблизительном адресе.
|
||||
const showPrecisionWarning =
|
||||
estimate.confidence === "low" && estimate.address_precision === "approximate";
|
||||
|
||||
// SSR-safe: берём last_scraped_at (статичная ISO строка — нет мismatch).
|
||||
// Если его нет, fallback на data_freshness_minutes через Date.now() —
|
||||
|
|
@ -190,6 +233,29 @@ export function HeroTransparency({ estimate, brandSlug, brandName }: Props) {
|
|||
<span className="k">Достоверность оценки</span>
|
||||
<span className="v">{band}</span>
|
||||
</li>
|
||||
{precisionLabel && (
|
||||
<li>
|
||||
<span className="k">Точность адреса</span>
|
||||
<span
|
||||
className="v"
|
||||
style={
|
||||
showPrecisionWarning
|
||||
? { color: "var(--danger)", fontWeight: 600 }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{precisionLabel}
|
||||
{showPrecisionWarning && (
|
||||
<span
|
||||
aria-label="Предупреждение: адрес определён приблизительно при низкой достоверности"
|
||||
style={{ marginLeft: 6 }}
|
||||
>
|
||||
⚠️
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
{estimate.confidence_explanation && !estimate.confidence_explanation.startsWith("address_not_geocoded") && (
|
||||
<p className="how-explanation">{estimate.confidence_explanation}</p>
|
||||
|
|
|
|||
|
|
@ -45,6 +45,10 @@ export type ConfidenceLevel = "low" | "medium" | "high";
|
|||
// Точность гео-привязки адреса (из DaData qc_geo): house=0, street=1, approximate≥2.
|
||||
export type AddressPrecision = "house" | "street" | "approximate";
|
||||
|
||||
// Структурный уровень аналогов (#695 follow-up) — бэкенд отдаёт начиная с deploy ветки
|
||||
// feat/estimator-ui-transparency. До деплоя поле absent/null → graceful fallback на compsTier().
|
||||
export type AnalogTier = "same_building" | "micro_radius" | "district" | "city";
|
||||
|
||||
export interface TradeInEstimateInput {
|
||||
address: string; // min 3, max 500
|
||||
area_m2: number; // 10 < x < 500
|
||||
|
|
@ -167,6 +171,9 @@ export interface AggregatedEstimate {
|
|||
// «approximate» (qc_geo≥2: населённый пункт/город/регион/не распознан).
|
||||
// null если DaData не отрабатывала (адрес не геокодирован) / credentials не заданы.
|
||||
address_precision?: AddressPrecision | null;
|
||||
// ── Структурный уровень аналогов (feat/estimator-ui-transparency) ──
|
||||
// Появляется после деплоя backend'а; до этого absent/null → UI graceful fallback.
|
||||
analog_tier?: AnalogTier | null;
|
||||
// ── Параметры оценённой квартиры (для восстановления карточки по ?id=) ──
|
||||
area_m2: number | null;
|
||||
rooms: number | null;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue