feat(trade-in/ui): transparency «Мера» — analog_tier, address_precision, expected_sold бар, ratio_basis #1837

Merged
lekss361 merged 1 commit from feat/estimator-ui-transparency into main 2026-06-20 12:31:20 +00:00
3 changed files with 146 additions and 6 deletions

View file

@ -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>
</>
)}

View file

@ -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>

View file

@ -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;