feat(trade-in/ui): transparency «Мера» — analog_tier, address_precision, expected_sold бар, ratio_basis #1837
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")} ₽/м²
|
{soldPerM2.toLocaleString("ru-RU")} ₽/м²
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{estimate.ratio_basis === "global_fallback" && (
|
{estimate.ratio_basis === "global_fallback" ? (
|
||||||
<div className="hero-duo__note">
|
<div className="hero-duo__note">
|
||||||
Ср.-городской коэффициент — мало сделок в сегменте
|
Ср.-городской коэффициент (мало сделок в сегменте)
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : estimate.ratio_basis === "per_rooms" ? (
|
||||||
|
<div className="hero-duo__note">
|
||||||
|
Коэффициент по сделкам той же планировки
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
@ -523,6 +527,69 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
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";
|
import { API_BASE_URL } from "@/lib/api";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -35,6 +35,26 @@ const CONF_BAND: Record<string, string> = {
|
||||||
low: "низкая достоверность",
|
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.
|
* Лид-инбокс. Задаётся ТОЛЬКО через env NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL.
|
||||||
* БЕЗ fallback: если не задан → CTA «оставить заявку» скрывается (leadEnabled),
|
* БЕЗ fallback: если не задан → CTA «оставить заявку» скрывается (leadEnabled),
|
||||||
|
|
@ -43,9 +63,12 @@ const CONF_BAND: Record<string, string> = {
|
||||||
const CONTACT_EMAIL = (process.env.NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL ?? "").trim();
|
const CONTACT_EMAIL = (process.env.NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL ?? "").trim();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Эвристика уровня сопоставимых объектов из confidence_explanation.
|
* LEGACY fallback: эвристика уровня сопоставимых объектов из confidence_explanation.
|
||||||
* Бэкенд кладёт туда формулировку якорного tier'а; вытаскиваем человекочитаемую.
|
* Бэкенд кладёт туда формулировку якорного tier'а; вытаскиваем человекочитаемую.
|
||||||
* Null → ничего не показываем (graceful).
|
* Null → ничего не показываем (graceful).
|
||||||
|
*
|
||||||
|
* Используется только если бэкенд ещё не отдаёт структурного analog_tier —
|
||||||
|
* предпочитай resolveCompsTier() которая сначала смотрит на analog_tier.
|
||||||
*/
|
*/
|
||||||
function compsTier(explanation: string | null): string | null {
|
function compsTier(explanation: string | null): string | null {
|
||||||
// FIXME: backend should expose structured comps_tier (#695 follow-up).
|
// FIXME: backend should expose structured comps_tier (#695 follow-up).
|
||||||
|
|
@ -64,6 +87,17 @@ function compsTier(explanation: string | null): string | null {
|
||||||
return 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)
|
* «27 мая 2026, 14:30» — timeZone: "UTC" фиксирует вывод одинаково на SSR (Node UTC)
|
||||||
* и клиенте (любой TZ). Иначе «12:00» на сервере → «15:00» у клиента в Москве → React #418.
|
* и клиенте (любой TZ). Иначе «12:00» на сервере → «15:00» у клиента в Москве → React #418.
|
||||||
|
|
@ -93,7 +127,16 @@ function freshnessFromScraped(estimate: AggregatedEstimate): Date | null {
|
||||||
|
|
||||||
export function HeroTransparency({ estimate, brandSlug, brandName }: Props) {
|
export function HeroTransparency({ estimate, brandSlug, brandName }: Props) {
|
||||||
const band = CONF_BAND[estimate.confidence] ?? CONF_BAND.low;
|
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).
|
// SSR-safe: берём last_scraped_at (статичная ISO строка — нет мismatch).
|
||||||
// Если его нет, fallback на data_freshness_minutes через Date.now() —
|
// Если его нет, fallback на data_freshness_minutes через Date.now() —
|
||||||
|
|
@ -190,6 +233,29 @@ export function HeroTransparency({ estimate, brandSlug, brandName }: Props) {
|
||||||
<span className="k">Достоверность оценки</span>
|
<span className="k">Достоверность оценки</span>
|
||||||
<span className="v">{band}</span>
|
<span className="v">{band}</span>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
{estimate.confidence_explanation && !estimate.confidence_explanation.startsWith("address_not_geocoded") && (
|
{estimate.confidence_explanation && !estimate.confidence_explanation.startsWith("address_not_geocoded") && (
|
||||||
<p className="how-explanation">{estimate.confidence_explanation}</p>
|
<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.
|
// Точность гео-привязки адреса (из DaData qc_geo): house=0, street=1, approximate≥2.
|
||||||
export type AddressPrecision = "house" | "street" | "approximate";
|
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 {
|
export interface TradeInEstimateInput {
|
||||||
address: string; // min 3, max 500
|
address: string; // min 3, max 500
|
||||||
area_m2: number; // 10 < x < 500
|
area_m2: number; // 10 < x < 500
|
||||||
|
|
@ -167,6 +171,9 @@ export interface AggregatedEstimate {
|
||||||
// «approximate» (qc_geo≥2: населённый пункт/город/регион/не распознан).
|
// «approximate» (qc_geo≥2: населённый пункт/город/регион/не распознан).
|
||||||
// null если DaData не отрабатывала (адрес не геокодирован) / credentials не заданы.
|
// null если DaData не отрабатывала (адрес не геокодирован) / credentials не заданы.
|
||||||
address_precision?: AddressPrecision | null;
|
address_precision?: AddressPrecision | null;
|
||||||
|
// ── Структурный уровень аналогов (feat/estimator-ui-transparency) ──
|
||||||
|
// Появляется после деплоя backend'а; до этого absent/null → UI graceful fallback.
|
||||||
|
analog_tier?: AnalogTier | null;
|
||||||
// ── Параметры оценённой квартиры (для восстановления карточки по ?id=) ──
|
// ── Параметры оценённой квартиры (для восстановления карточки по ?id=) ──
|
||||||
area_m2: number | null;
|
area_m2: number | null;
|
||||||
rooms: number | null;
|
rooms: number | null;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue