gendesign/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx
bot-backend d73b9198d5
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m5s
fix(tradein/v2): визуальный аудит — иерархия/честность/микрокопи (#2266)
Живой визуальный аудит /trade-in/v2 нашёл HIGH/MEDIUM/LOW дефекты после
honest-hero раунда — фиксы:

- H1: честный fallback-state флагманской карточки при expected_sold_price=null
  (вместо четырёх прочерков)
- H3: две расходящиеся "медианы объявлений" на одном экране (рейка 14.23млн
  vs герой 8.99млн на тех же аналогах) — унифицированы на e.median_price_rub
- M1-M3: иерархия трёх цен (asking уменьшен, круговой гейдж → пилюля
  "-N% к цене объявления", дисклеймер разброса читаем)
- M4: PDF-отчёт primary после результата (без добавления lead-CTA —
  открытый продуктовый вопрос, не решался)
- M5: порядок меток оси X графика (2026 в начале, не терялась при years!=5) —
  geometry вынесена в общий export (phYearX) вместо дублирования в двух файлах
- M6: gate немонотонного "срок продажи" при n<5 аналогов
- M7: сброс scroll оверлея при смене секции
- M8 + LOW: унификация терминов (СТОИМОСТЬ→ЦЕНА, CV→"разброс цен",
  P25-P75 человекочитаемо, ДКП-расшифровка, скрыт литеральный
  "Обновлено: —", метро-разделители в адресе, убран внутренний
  "ПОВТОРНЫЕ АДРЕСА" label)
- Контраст accent-текста hero (40px headline) поднят с ~2.87:1 до ~4.2:1
  (accent → accentDeep, существующий токен той же палитры)

Мобильная раскладка (H2) вынесена в отдельный issue #2275, не в этом PR.
Lead-CTA "Оставить заявку" — открытый продуктовый вопрос, не решался.

Refs #2266, #2262
2026-07-03 23:31:50 +03:00

369 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 03 СВОДКА ОБЪЕКТА — right-rail summary card for the /trade-in/v2 "МЕРА Оценка"
// design port. Faithful markup port of МЕРА Оценка.dc.html lines 397-422:
// address + "В РАСЧЁТЕ" badge, four clickable per-section totals, and a
// "КАЧЕСТВО ДАННЫХ" block with a donut. Data from fixtures; clicks fire
// onNavigate(row.nav) only — no API, no fetching.
"use client";
import { tokens } from "./tokens";
import { object, summary } from "./fixtures";
import type { ObjectSummaryData } from "./mappers";
// Default presentation data (unwired usage): the existing design fixtures.
const OBJECT_SUMMARY_FIXTURE: ObjectSummaryData = { object, summary };
interface ObjectSummaryProps {
data?: ObjectSummaryData;
onNavigate: (i: number) => void;
}
// Audit M11 — name the unnamed «N · XX,XX млн» numbers. Each summary row value
// is built in mappers.mapSummary as "count · metric"; the metric is different
// per row. This caption labels each ·-separated token token-for-token. Keyed by
// label content so it matches both the mapper labels ("Продажи рядом") and the
// older fixture labels ("Продажи в доме"). Sources in mappers.mapSummary:
// Продажи рядом : houseSold · kpi.median_exposure_days
// Аналоги : e.n_analogs · e.median_price_rub (H3 — SAME field as the
// 02-РЕЗУЛЬТАТ hero «медиана объявлений», not the raw analog
// listing-price median which read far higher over the same set)
// Сделки : dealCount · streetDeals.median_price_rub ?? median(deals)
// Аналитика : kpi.median_exposure_days · -kpi.median_bargain_pct
function rowHint(label: string): string {
if (label.startsWith("Продажи")) return "кол-во · медиана экспозиции";
if (label === "Аналоги") return "кол-во · медиана объявлений";
if (label === "Сделки") return "кол-во · медиана сделок";
if (label === "Аналитика") return "экспозиция · торг";
return "";
}
export function ObjectSummary({
data = OBJECT_SUMMARY_FIXTURE,
onNavigate,
}: ObjectSummaryProps) {
return (
<div
style={{
position: "relative",
background: tokens.surface.w50,
backdropFilter: "blur(6px)",
border: `1px solid ${tokens.line2}`,
borderRadius: 8,
padding: "15px 16px",
flex: 1,
display: "flex",
flexDirection: "column",
color: tokens.ink,
fontFamily: tokens.font.sans,
}}
>
<style>{`
.os-row { background: none; border: none; transition: background .12s; }
.os-row:hover { background: rgba(46,139,255,.07); }
`}</style>
{/* HUD corner bracket */}
<div
style={{
position: "absolute",
right: -1,
top: -1,
width: 12,
height: 12,
borderRight: `1.5px solid ${tokens.bracket}`,
borderTop: `1.5px solid ${tokens.bracket}`,
}}
/>
{/* Header: 03 СВОДКА ОБЪЕКТА + В РАСЧЁТЕ badge */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 5,
}}
>
<div style={{ display: "flex", alignItems: "baseline", gap: 9 }}>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 14,
color: tokens.accent,
}}
>
03
</span>
{/* Panel heading — real <h2> (preflight not loaded → reset UA margin
inline; visuals unchanged). */}
<h2
style={{
margin: 0,
fontSize: 12,
fontWeight: 600,
letterSpacing: 1.5,
}}
>
СВОДКА ОБЪЕКТА
</h2>
</div>
<span
style={{
display: "flex",
alignItems: "center",
gap: 4,
fontSize: 8,
letterSpacing: 0.8,
color: tokens.success,
border: `1px solid ${tokens.successBorder}`,
borderRadius: 20,
padding: "2px 7px",
whiteSpace: "nowrap",
}}
>
<span
style={{
width: 5,
height: 5,
borderRadius: "50%",
background: tokens.success,
}}
/>
АКТУАЛЬНО
</span>
</div>
<div
style={{
fontSize: 9,
letterSpacing: 1.2,
color: tokens.body2,
marginBottom: 13,
}}
>
ИТОГИ ПО РАЗДЕЛАМ
</div>
{/* Address */}
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 9,
marginBottom: 14,
}}
>
<svg
width="15"
height="18"
viewBox="0 0 15 18"
fill="none"
style={{ marginTop: 1 }}
aria-hidden="true"
>
<path
d="M7.5 17C7.5 17 14 11 14 6.5A6.5 6.5 0 1 0 1 6.5C1 11 7.5 17 7.5 17Z"
stroke={tokens.accent}
strokeWidth="1.2"
/>
<circle cx="7.5" cy="6.5" r="2.3" fill={tokens.accent} />
</svg>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>
{data.object.address}
</div>
<div style={{ fontSize: 11, color: tokens.muted, marginTop: 2 }}>
{data.object.city}
</div>
</div>
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true">
<path
d="M5 1h7v7M12 1L6 7M9 8v4H1V4h4"
stroke={tokens.muted2}
strokeWidth="1"
/>
</svg>
</div>
{/* Per-section totals (clickable) */}
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
{data.summary.rows
.filter((row) => row.value !== "—")
.map((row, i, visible) => (
<button
key={row.label}
type="button"
className="os-row"
onClick={() => onNavigate(row.nav)}
aria-label={`${row.label} — подробнее`}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "9px 6px",
cursor: "pointer",
borderRadius: 5,
width: "100%",
fontFamily: "inherit",
textAlign: "left",
borderBottom:
i < visible.length - 1
? `1px dotted ${tokens.lineGradient}`
: undefined,
}}
>
<span
style={{
fontSize: 10.5,
color: tokens.body,
display: "flex",
alignItems: "center",
gap: 6,
whiteSpace: "nowrap",
paddingRight: 12,
}}
>
<span
style={{
width: 5,
height: 5,
borderRadius: "50%",
background:
row.dot === "accent" ? tokens.accent : tokens.scatterDeal,
}}
/>
{row.label}
</span>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
gap: 1,
}}
>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 11,
color: tokens.ink2,
whiteSpace: "nowrap",
}}
>
{row.value}
</span>
{rowHint(row.label) && (
<span
style={{
fontSize: 8,
letterSpacing: 0.2,
color: tokens.muted3,
whiteSpace: "nowrap",
}}
>
{rowHint(row.label)}
</span>
)}
</span>
<span style={{ color: tokens.accent, fontSize: 11 }}></span>
</span>
</button>
))}
</div>
{/* КАЧЕСТВО ДАННЫХ */}
<div
style={{
marginTop: 14,
background: tokens.infoSoftBg,
border: `1px solid ${tokens.infoSoftBorder}`,
borderRadius: 6,
padding: 12,
display: "flex",
gap: 12,
alignItems: "center",
}}
>
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" aria-hidden="true">
<circle
cx="25"
cy="25"
r="20"
stroke={tokens.track}
strokeWidth="3"
/>
<circle
cx="25"
cy="25"
r="20"
stroke={tokens.accent}
strokeWidth="3"
strokeLinecap="round"
strokeDasharray="126"
strokeDashoffset="72"
transform="rotate(-90 25 25)"
/>
<circle cx="25" cy="25" r="11" stroke="#cdddec" strokeWidth="1" />
<circle cx="25" cy="25" r="1.6" fill={tokens.accent} />
</svg>
<div style={{ flex: 1 }}>
<div
style={{
fontSize: 9.5,
letterSpacing: 1.5,
color: tokens.muted2,
marginBottom: 7,
}}
>
КАЧЕСТВО ДАННЫХ
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 10,
color: tokens.muted,
lineHeight: 1.7,
}}
>
<span>Источников</span>
<span style={{ fontFamily: tokens.font.mono, color: tokens.ink }}>
{data.summary.quality.sources}
</span>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 10,
color: tokens.muted,
lineHeight: 1.7,
}}
>
<span>Достоверность</span>
<span style={{ color: tokens.ink }}>
{data.summary.quality.confidence}
</span>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 10,
color: tokens.muted,
lineHeight: 1.7,
}}
>
<span>Разброс цен</span>
<span
style={{ fontFamily: tokens.font.mono, color: tokens.accent }}
>
{data.summary.quality.cv}
</span>
</div>
</div>
</div>
</div>
);
}