gendesign/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx
bot-backend 1722309372
All checks were successful
CI / changes (pull_request) Successful in 7s
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
fix(tradein/v2): UI/UX-аудит P2 — контент/честность/иерархия (#2062)
- Опц. поля больше не шлют непроверенные дефолты в ценовую модель:
  ТИП/РЕМОНТ → «Не указано»→undefined, БАЛКОН tri-state (null пока не
  трогали) → undefined.
- Валидация у поля: красная рамка + сообщение под адресом/площадью,
  показываются ВСЕ блокеры (не только первый); низ — только серверная ошибка.
- Копирайт «вы»: «если знаете»; кэш-строка → «ДАННЫЕ АКТУАЛЬНЫ ·
  ДЕЙСТВИТЕЛЕН 24 Ч»; drawer без жаргона (убраны (asking)/POI).
- Даты сделок Росреестра → «янв 2026» (месяц, не фейк-день 01.01);
  бренды источников без method-суффиксов (РОСРЕЕСТР/АВИТО/ДОМКЛИК);
  «эталон»→«опорная цена».
- График истории цен: зум оси Y к реальному диапазону данных + динамические
  подписи оси; легенда серии показывается только при наличии линии.
- 3 цены весомее (font-weight 400); медиана-маркеры range-баров → тонкий тик
  (не выглядит как draggable-ручка); фото здания — синий duotone +
  десатурация (не крадёт первый взгляд у цен).
- Таблицы аналогов/сделок: площадь/комнаты/этаж вынесены из адресной ячейки
  в колонку ПАРАМЕТРЫ; ↗→› в СВОДКЕ.

Сознательно НЕ тронуто: перераспределение accent-синего (item 9) — axe уже 0,
а широкое снятие акцента конфликтует с «не ломать HUD-палитру»; монотонность
тиров срока продажи не форсируется (это были бы выдуманные данные).
2026-06-28 20:51:49 +03:00

901 lines
25 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.

"use client";
import { Fragment } from "react";
import { tokens } from "./tokens";
import {
ranges,
resultCards,
resultMeta,
scatterMini,
sources,
} from "./fixtures";
import type { ResultPanelData } from "./mappers";
const {
accent,
ink,
muted,
muted2,
muted3,
line,
line2,
line3,
bracket,
barLo,
barMid,
track,
success,
hint,
scatterDeal,
disabledBorder,
disabledValue,
disabledLabel,
surface,
font,
} = tokens;
// Default presentation data (unwired usage): the existing design fixtures.
const RESULT_FIXTURE: ResultPanelData = {
cards: resultCards,
meta: resultMeta,
ranges,
scatterMini,
sources,
};
interface ResultPanelProps {
data?: ResultPanelData;
onNavigate: (i: number) => void;
}
// Mini-histogram column tint: peak column = accent, its neighbours = mid, rest = lo.
function barColor(i: number, bars: number[]): string {
const peak = bars.indexOf(Math.max(...bars));
if (i === peak) return accent;
if (i === peak - 1 || i === peak + 1) return barMid;
return barLo;
}
// Render a string[] with <br/> between lines (faithful to the design line breaks).
function lines(parts: string[]) {
return parts.map((part, i) => (
<Fragment key={i}>
{i > 0 && <br />}
{part}
</Fragment>
));
}
// Split a source name on its parenthetical so the design's forced
// "РОСРЕЕСТР / (ВНУТР.)" two-line break (line-height 1.3) is reproduced; plain
// names without " (" stay on a single line.
function sourceName(name: string) {
const idx = name.indexOf(" (");
if (idx === -1) return name;
return (
<>
{name.slice(0, idx)}
<br />
{name.slice(idx + 1)}
</>
);
}
export default function ResultPanel({
data = RESULT_FIXTURE,
onNavigate,
}: ResultPanelProps) {
return (
<div
style={{ display: "flex", flexDirection: "column", gap: 14, minWidth: 0 }}
>
<style>{`
.rp-more { transition: opacity 120ms ease; }
.rp-more:hover { opacity: .6; }
`}</style>
{/* header */}
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
}}
>
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
<span style={{ fontFamily: font.mono, fontSize: 15, color: accent }}>
02
</span>
<span style={{ fontSize: 13, fontWeight: 600, letterSpacing: 2 }}>
РЕЗУЛЬТАТ ОЦЕНКИ
</span>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 22,
fontSize: 10,
letterSpacing: 1,
color: muted,
}}
>
<span>
ИСТОЧНИКОВ:{" "}
<b style={{ color: ink, fontFamily: font.mono }}>
{data.meta.sources}
</b>
</span>
<span>
ДОСТОВЕРНОСТЬ: <b style={{ color: ink }}>{data.meta.confidence}</b>
</span>
<span>
CV{" "}
<b style={{ color: accent, fontFamily: font.mono }}>
{data.meta.cv}
</b>
</span>
</div>
</div>
{/* result cards */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr",
gap: 14,
flex: "0 0 auto",
}}
>
{data.cards.map((card, ci) => (
<div
key={ci}
style={{
position: "relative",
background: surface.w55,
backdropFilter: "blur(6px)",
border: `1px solid ${line2}`,
borderRadius: 8,
padding: "15px 16px 13px",
}}
>
<div
style={{
position: "absolute",
left: -1,
top: -1,
width: 12,
height: 12,
borderLeft: `1.5px solid ${bracket}`,
borderTop: `1.5px solid ${bracket}`,
}}
/>
<div
style={{
fontSize: 9.5,
letterSpacing: 1.5,
color: muted2,
lineHeight: 1.6,
}}
>
{lines(card.title)}
</div>
<div
style={{
marginTop: 14,
display: "flex",
alignItems: "baseline",
gap: 6,
}}
>
<span
style={{
fontFamily: font.mono,
fontSize: 38,
fontWeight: 400,
letterSpacing: -1,
}}
>
{card.value}
</span>
<span
style={{
fontSize: 13,
color: muted,
letterSpacing: 1,
whiteSpace: "nowrap",
}}
>
{card.unit}
</span>
</div>
<div
style={{
fontFamily: font.mono,
fontSize: 11,
color: muted,
marginTop: 10,
}}
>
{card.range}
</div>
<div
style={{
fontFamily: font.mono,
fontSize: 10,
color: muted2,
marginTop: 4,
}}
>
{card.ppm}
</div>
{card.note && (
<div
style={{
fontSize: 9,
color: muted2,
marginTop: 4,
lineHeight: 1.4,
maxWidth: "82%",
}}
>
{card.note}
</div>
)}
{card.bars ? (
<div
style={{
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
gap: 12,
marginTop: 12,
}}
>
<div
style={{
display: "flex",
alignItems: "flex-end",
gap: 2,
height: 20,
flex: 1,
}}
>
{card.bars.map((h, bi) => (
<span
key={bi}
style={{
flex: 1,
height: `${h}%`,
background: barColor(bi, card.bars as number[]),
}}
/>
))}
</div>
<div
className="rp-more"
onClick={() => onNavigate(card.nav)}
style={{
display: "flex",
alignItems: "center",
gap: 4,
cursor: "pointer",
fontSize: 9,
letterSpacing: 0.3,
color: accent,
whiteSpace: "nowrap",
}}
>
Подробнее <span></span>
</div>
</div>
) : (
<>
<div
className="rp-more"
onClick={() => onNavigate(card.nav)}
style={{
marginTop: 16,
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 4,
cursor: "pointer",
fontSize: 9,
letterSpacing: 0.3,
color: accent,
}}
>
Подробнее <span></span>
</div>
<div
style={{
position: "absolute",
right: 14,
top: 13,
width: 58,
height: 58,
}}
>
<svg width="58" height="58" viewBox="0 0 58 58">
<circle
cx="29"
cy="29"
r="25"
fill="none"
stroke={track}
strokeWidth="3"
/>
<circle
cx="29"
cy="29"
r="25"
fill="none"
stroke={accent}
strokeWidth="3"
strokeLinecap="round"
strokeDasharray="157"
strokeDashoffset="140"
transform="rotate(-90 29 29)"
/>
</svg>
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<span
style={{
fontFamily: font.mono,
fontSize: 13,
fontWeight: 500,
color: accent,
}}
>
{card.delta}
</span>
<span
style={{
fontSize: 6.5,
letterSpacing: 1,
color: muted2,
marginTop: 1,
}}
>
{card.deltaLabel}
</span>
</div>
</div>
</>
)}
</div>
))}
</div>
{/* ranges + radar */}
<div
style={{
position: "relative",
background: surface.w50,
backdropFilter: "blur(6px)",
border: `1px solid ${line2}`,
borderRadius: 8,
padding: "18px 20px",
display: "grid",
gridTemplateColumns: "1fr 168px 1fr",
gap: 24,
alignItems: "center",
flex: "0 0 auto",
}}
>
<div
style={{
position: "absolute",
right: -1,
bottom: -1,
width: 13,
height: 13,
borderRight: `1.5px solid ${bracket}`,
borderBottom: `1.5px solid ${bracket}`,
}}
/>
{/* left range — ads */}
<div>
<div
style={{
fontSize: 9,
letterSpacing: 1.3,
color: muted2,
lineHeight: 1.5,
}}
>
{lines(data.ranges.ads.label)}
</div>
<div
style={{
fontFamily: font.mono,
fontSize: 11,
color: muted,
margin: "12px 0 16px",
}}
>
медиана · <b style={{ color: ink }}>{data.ranges.ads.median}</b>
</div>
<div
style={{
position: "relative",
height: 3,
background: track,
borderRadius: 2,
}}
>
<div
style={{
position: "absolute",
left: data.ranges.ads.marker.fillLeft,
right: data.ranges.ads.marker.fillRight,
top: 0,
bottom: 0,
background: `linear-gradient(90deg,${barMid},${accent})`,
borderRadius: 2,
}}
/>
<div
style={{
position: "absolute",
left: data.ranges.ads.marker.dotLeft,
top: "50%",
transform: "translate(-50%,-50%)",
width: 3,
height: 16,
borderRadius: 1,
background: accent,
}}
/>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontFamily: font.mono,
fontSize: 10,
color: muted2,
marginTop: 10,
}}
>
<span>{data.ranges.ads.lo}</span>
<span style={{ color: accent }}>{data.ranges.ads.median}</span>
<span>{data.ranges.ads.hi}</span>
</div>
</div>
{/* radar — price × sell-time scatter */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 6,
}}
>
<div style={{ fontSize: 8, letterSpacing: 1.5, color: muted2 }}>
ЦЕНА × СРОК ПРОДАЖИ
</div>
<svg width="168" height="140" viewBox="0 0 168 140" fill="none">
<g stroke="#e3ecf4" strokeWidth="1">
<line x1="34" y1="38" x2="158" y2="38" />
<line x1="34" y1="74" x2="158" y2="74" />
<line x1="96" y1="10" x2="96" y2="110" />
</g>
<line
x1="34"
y1="10"
x2="34"
y2="110"
stroke={line3}
strokeWidth="1"
/>
<line
x1="34"
y1="110"
x2="158"
y2="110"
stroke={line3}
strokeWidth="1"
/>
<line
x1="40"
y1="100"
x2="150"
y2="30"
stroke={accent}
strokeWidth="1"
strokeDasharray="3 4"
opacity="0.45"
/>
<g fill={scatterDeal}>
{data.scatterMini.deals.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
))}
</g>
<g fill={accent}>
{data.scatterMini.analogs.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
))}
</g>
<circle
cx={data.scatterMini.subject.x}
cy={data.scatterMini.subject.y}
r={data.scatterMini.subject.r}
fill={ink}
/>
<circle
cx={data.scatterMini.subject.x}
cy={data.scatterMini.subject.y}
r="6.5"
fill="none"
stroke={ink}
strokeWidth="1"
opacity="0.4"
/>
<g
fontFamily={font.mono}
fontSize="6.5"
fill={muted2}
textAnchor="end"
>
{data.scatterMini.yTicks.map((t, i) => (
<text key={i} x={30} y={t.y}>
{t.label}
</text>
))}
</g>
<g
fontFamily={font.mono}
fontSize="6.5"
fill={muted2}
textAnchor="middle"
>
{data.scatterMini.xTicks.map((t, i) => (
<text key={i} x={t.x} y={120}>
{t.label}
</text>
))}
</g>
<text
x="96"
y="134"
fontSize="6.5"
letterSpacing="1"
fill={muted2}
textAnchor="middle"
>
СРОК ПРОДАЖИ, ДН
</text>
<text
x="11"
y="60"
fontSize="6.5"
letterSpacing="1"
fill={muted2}
textAnchor="middle"
transform="rotate(-90 11 60)"
>
ЦЕНА ПРОДАЖИ
</text>
</svg>
<div
style={{
display: "flex",
gap: 12,
fontSize: 7.5,
letterSpacing: 0.5,
color: muted2,
}}
>
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
<span
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: accent,
}}
/>
аналоги
</span>
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
<span
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: scatterDeal,
}}
/>
сделки
</span>
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
<span
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: ink,
}}
/>
объект
</span>
</div>
<div
className="rp-more"
onClick={() => onNavigate(3)}
style={{
display: "flex",
alignItems: "center",
gap: 4,
cursor: "pointer",
fontSize: 9,
letterSpacing: 0.3,
color: accent,
marginTop: 2,
}}
>
Подробнее <span></span>
</div>
</div>
{/* right range — deals */}
<div>
<div
style={{
fontSize: 9,
letterSpacing: 1.3,
color: muted2,
lineHeight: 1.5,
}}
>
{lines(data.ranges.deals.label)}
</div>
<div
style={{
fontFamily: font.mono,
fontSize: 11,
color: muted,
margin: "12px 0 16px",
}}
>
медиана · <b style={{ color: ink }}>{data.ranges.deals.median}</b>
</div>
<div
style={{
position: "relative",
height: 3,
background: track,
borderRadius: 2,
}}
>
<div
style={{
position: "absolute",
left: data.ranges.deals.marker.fillLeft,
right: data.ranges.deals.marker.fillRight,
top: 0,
bottom: 0,
background: `linear-gradient(90deg,${barMid},${accent})`,
borderRadius: 2,
}}
/>
<div
style={{
position: "absolute",
left: data.ranges.deals.marker.dotLeft,
top: "50%",
transform: "translate(-50%,-50%)",
width: 3,
height: 16,
borderRadius: 1,
background: accent,
}}
/>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontFamily: font.mono,
fontSize: 10,
color: muted2,
marginTop: 10,
}}
>
<span>{data.ranges.deals.lo}</span>
<span style={{ color: accent }}>{data.ranges.deals.median}</span>
<span>{data.ranges.deals.hi}</span>
</div>
</div>
</div>
{/* sources */}
<div
style={{
background: surface.w50,
backdropFilter: "blur(6px)",
border: `1px solid ${line2}`,
borderRadius: 8,
padding: "14px 18px 13px",
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 11,
}}
>
<span
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: 2,
color: ink,
}}
>
ИСТОЧНИКИ ДАННЫХ
</span>
<span
className="rp-more"
onClick={() => onNavigate(2)}
style={{
display: "flex",
alignItems: "center",
gap: 4,
cursor: "pointer",
fontSize: 9.5,
letterSpacing: 0.3,
color: accent,
}}
>
Подробнее <span></span>
</span>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(7,1fr)",
gap: 9,
flex: 1,
minHeight: 0,
alignItems: "stretch",
alignContent: "stretch",
}}
>
{data.sources.map((s, si) =>
s.active ? (
<div
key={si}
style={{
border: `1px solid ${line}`,
borderRadius: 6,
padding: "10px 9px",
display: "flex",
flexDirection: "column",
background: surface.w50,
}}
>
<div
style={{
fontSize: 9,
letterSpacing: 0.5,
color: ink,
fontWeight: 600,
...(s.name.includes(" (") ? { lineHeight: 1.3 } : {}),
}}
>
{sourceName(s.name)}
</div>
<div
style={{
fontFamily: font.mono,
fontSize: 26,
fontWeight: 300,
marginTop: "auto",
}}
>
{s.count}
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 5,
fontSize: 9,
color: muted,
marginTop: 3,
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: success,
}}
/>
{s.label}
</div>
</div>
) : (
<div
key={si}
style={{
border: `1px solid ${disabledBorder}`,
borderRadius: 6,
padding: "10px 9px",
display: "flex",
flexDirection: "column",
background: surface.w25,
}}
>
<div
style={{
fontSize: 9,
letterSpacing: 0.5,
color: muted3,
fontWeight: 600,
}}
>
{s.name}
</div>
<div
style={{
fontFamily: font.mono,
fontSize: 22,
fontWeight: 300,
marginTop: "auto",
color: disabledValue,
}}
>
{s.count}
</div>
<div
style={{ fontSize: 9, color: disabledLabel, marginTop: 3 }}
>
{s.label}
</div>
</div>
),
)}
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: 11,
}}
>
<span style={{ fontSize: 10, color: hint }}>
При недоступности источника частичный результат не блокируется
</span>
<span style={{ fontSize: 9.5, letterSpacing: 1.5, color: muted2 }}>
{data.meta.builtOn ?? "—"}
</span>
</div>
</div>
</div>
);
}