gendesign/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx
bot-backend 338ccbb78f
All checks were successful
Deploy Trade-In / build-frontend (push) Successful in 1m55s
Deploy Trade-In / deploy (push) Successful in 44s
Deploy Trade-In / changes (push) Successful in 11s
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
fix(tradein/v2): честность+консистентность — реконсиляция счётчика аналогов, убрать фейк-donut/линейку, outlier-guard, created_at, дисклеймеры (#2481)
2026-07-12 15:09:10 +00:00

926 lines
29 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 type { Ref } from "react";
import { tokens } from "./tokens";
import {
ranges,
resultCards,
resultMeta,
scatterMini,
sources,
} from "./fixtures";
import type { ResultPanelData } from "./mappers";
const {
accent,
accentDeep,
onAccent,
ink,
body,
body2,
muted,
muted2,
muted3,
line,
line2,
lineSoft,
bracket,
barLo,
barMid,
track,
success,
hint,
disabledBorder,
disabledValue,
disabledLabel,
infoSoftBg,
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;
// #2264 C7: after a successful estimate the page moves focus here (a labelled,
// programmatically-focusable region) so keyboard/SR users land on the result
// instead of the <body>. tabIndex={-1} makes it focus()-able without adding a
// Tab stop.
regionRef?: Ref<HTMLDivElement>;
}
// 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,
regionRef,
}: ResultPanelProps) {
return (
<div
ref={regionRef}
role="region"
aria-labelledby="v2-result-heading"
tabIndex={-1}
style={{
display: "flex",
flexDirection: "column",
gap: 14,
minWidth: 0,
outline: "none",
}}
>
<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>
{/* Panel heading — real <h2> + the region's accessible name
(aria-labelledby). Preflight not loaded → reset UA margin inline. */}
<h2
id="v2-result-heading"
style={{
margin: 0,
fontSize: 13,
fontWeight: 600,
letterSpacing: 2,
}}
>
РЕЗУЛЬТАТ ОЦЕНКИ
</h2>
</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>
РАЗБРОС ЦЕН{" "}
<b style={{ color: accent, fontFamily: font.mono }}>
{data.meta.cv}
</b>
</span>
</div>
</div>
{/* result cards */}
<div
style={{
display: "grid",
// #1991 — the registry (ДКП) tile is an external anchor, not a peer
// of the two prices that actually matter (asking / expected). Equal
// 1fr columns still read as "three equivalent options" even with
// the demoted colors/type-scale below, so the two primary cards get
// ~50% more width and the registry tile narrows into a visibly
// secondary, reference-only column (closer to a convergence strip
// than a full-size card, per audit acceptance criteria).
gridTemplateColumns: "1.3fr 1.3fr 0.85fr",
gap: 14,
flex: "0 0 auto",
}}
>
{data.cards.map((card, ci) => {
// Price hierarchy (audit H2 + M1). The three cards are NOT equal weight:
// ci 0 — РЕКОМЕНДОВАННАЯ ЦЕНА В ОБЪЯВЛЕНИИ (market asking, baseline)
// ci 1 — ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ (the real answer → headline)
// ci 2 — ДКП · РОСРЕЕСТР (registry reference, ~43% → demoted)
// The headline gets an accent border + tint + glow + a larger accent
// value so the user can't confuse it with the registry figure; the
// registry card is demoted to a softer, smaller, muted "СПРАВОЧНО"
// tile. Value scale reads expected(40) ≫ asking(30) > ДКП(28).
// #1991 — column width above is the third, independent axis: asking
// and expected visually dominate the row, ДКП is narrow/secondary.
//
// H1 — when the expected card has no prediction (emptyNote), it drops
// to an honest empty-state and the accent frame moves onto the asking
// card (ci 0) so the panel always has ONE clear headline, never a hole.
const expectedEmpty = Boolean(data.cards[1]?.emptyNote);
const isEmptyCard = Boolean(card.emptyNote);
const isRegistry = ci === 2;
const headlineIndex = expectedEmpty ? 0 : 1;
const isHeadline = !isEmptyCard && ci === headlineIndex;
const isAsking = ci === 0;
return (
<div
key={ci}
style={{
position: "relative",
background: isHeadline
? infoSoftBg
: isRegistry
? surface.w40
: surface.w55,
backdropFilter: "blur(6px)",
border: isHeadline
? `1.5px solid ${accent}`
: isRegistry
? `1px solid ${lineSoft}`
: `1px solid ${line2}`,
borderRadius: 8,
padding: "15px 16px 13px",
boxShadow: isHeadline
? "0 6px 22px rgba(46,139,255,.16)"
: undefined,
}}
>
<div
style={{
position: "absolute",
left: -1,
top: -1,
width: 12,
height: 12,
borderLeft: `1.5px solid ${isHeadline ? accent : bracket}`,
borderTop: `1.5px solid ${isHeadline ? accent : bracket}`,
}}
/>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 8,
}}
>
<div
style={{
fontSize: 9.5,
letterSpacing: 1.5,
// Contrast follow-up (audit #2266): accent (#2e8bff) on the
// headline card's pale infoSoftBg tint measured ≈2.87:1 — below
// even the AA "large text" floor (3:1). accentDeep is the same
// brand hue, darkened (existing token, not a one-off hex) — it
// clears ~4.2:1 here, so the label reads reliably too.
color: isHeadline ? accentDeep : isRegistry ? muted2 : body2,
fontWeight: isHeadline ? 600 : undefined,
lineHeight: 1.6,
}}
>
{lines(card.title)}
</div>
{/* #1991 — explicit "this is the figure that goes into the offer"
mark on the headline card, so the two dominant prices are not
just visually bigger but literally labelled which one to quote. */}
{isHeadline && !isEmptyCard && (
<span
style={{
flex: "0 0 auto",
fontSize: 8,
fontWeight: 700,
letterSpacing: 1,
color: onAccent,
background: accentDeep,
borderRadius: 3,
padding: "3px 6px",
whiteSpace: "nowrap",
}}
>
В ОФФЕР
</span>
)}
</div>
{isRegistry && (
<div
style={{
fontSize: 8,
letterSpacing: 1.2,
color: muted3,
marginTop: 3,
}}
>
{/* §M8 — расшифровка «ДКП» один раз на секцию. */}
СПРАВОЧНО · ДОГОВОРЫ КУПЛИ-ПРОДАЖИ
</div>
)}
{/* H1 empty-state: no bare «—» / gauge / «· с учётом торга» — just an
honest note pointing at the recommended asking price. */}
{isEmptyCard ? (
<div
style={{
marginTop: 12,
fontSize: 11,
lineHeight: 1.5,
color: body,
maxWidth: "94%",
}}
>
{card.emptyNote}
</div>
) : (
<>
<div
style={{
marginTop: 14,
display: "flex",
alignItems: "baseline",
gap: 6,
}}
>
<span
style={{
fontFamily: font.mono,
// M1 — asking (30) sits above the ДКП «справочно» tier (28)
// yet clearly below the accent headline (40).
fontSize: isHeadline ? 40 : isRegistry ? 28 : 30,
fontWeight: isHeadline ? 600 : 400,
letterSpacing: -1,
// Contrast follow-up (audit #2266) — accentDeep, not
// accent, for the same reason as the title label above.
color: isHeadline
? accentDeep
: isRegistry
? muted
: isAsking
? ink
: undefined,
}}
>
{card.value}
</span>
<span
style={{
fontSize: 13,
color: muted,
letterSpacing: 1,
whiteSpace: "nowrap",
}}
>
{card.unit}
</span>
</div>
{card.range && (
<div
style={{
fontFamily: font.mono,
fontSize: 11,
color: muted,
marginTop: 10,
}}
>
{card.range}
</div>
)}
{card.ppm && (
<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>
)}
</>
)}
{isEmptyCard ? (
<button
type="button"
className="rp-more"
onClick={() => onNavigate(card.nav)}
aria-label={`Подробнее — ${card.title.join(" ")}`}
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 4,
cursor: "pointer",
fontSize: 9,
letterSpacing: 0.3,
color: accent,
width: "100%",
background: "none",
border: "none",
padding: 0,
margin: 0,
marginTop: 16,
fontFamily: "inherit",
}}
>
Подробнее <span></span>
</button>
) : 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>
<button
type="button"
className="rp-more"
onClick={() => onNavigate(card.nav)}
aria-label={`Подробнее — ${card.title.join(" ")}`}
style={{
display: "flex",
alignItems: "center",
gap: 4,
cursor: "pointer",
fontSize: 9,
letterSpacing: 0.3,
color: accent,
whiteSpace: "nowrap",
background: "none",
border: "none",
padding: 0,
margin: 0,
fontFamily: "inherit",
}}
>
Подробнее <span></span>
</button>
</div>
) : (
// M2 — calm delta pill (was a 51px circular gauge that read like a
// tech "занижение" indicator). Full text «18% к цене объявления»,
// neutral accentDeep on a soft-blue chip, tabular-nums.
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
marginTop: 14,
}}
>
{card.delta ? (
<span
style={{
display: "inline-flex",
alignItems: "baseline",
gap: 5,
background: infoSoftBg,
border: `1px solid ${lineSoft}`,
borderRadius: 20,
padding: "3px 10px",
color: accentDeep,
fontFamily: font.sans,
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
}}
>
<span
style={{
fontFamily: font.mono,
fontSize: 12,
fontWeight: 600,
}}
>
{card.delta}
</span>
<span style={{ fontSize: 9.5, letterSpacing: 0.2 }}>
{card.deltaLabel}
</span>
</span>
) : (
<span />
)}
<button
type="button"
className="rp-more"
onClick={() => onNavigate(card.nav)}
aria-label={`Подробнее — ${card.title.join(" ")}`}
style={{
display: "flex",
alignItems: "center",
gap: 4,
cursor: "pointer",
fontSize: 9,
letterSpacing: 0.3,
color: accent,
whiteSpace: "nowrap",
background: "none",
border: "none",
padding: 0,
margin: 0,
fontFamily: "inherit",
}}
>
Подробнее <span></span>
</button>
</div>
)}
</div>
);
})}
</div>
{/* §1.2 честный разброс: диапазоны цен = рыночный спред, НЕ погрешность
оценки. Ставим сразу под ценовыми карточками, чтобы широкий диапазон
не читался как «неуверенность расчёта». */}
<div
style={{
// M3 — было 10px muted-микропринт; повышено до 12px body, чтобы
// ключевая honest-оговорка о разбросе читалась, а не терялась.
fontSize: 12,
lineHeight: 1.5,
color: body,
marginTop: -4,
}}
>
Диапазоны показывают разброс цен на рынке, а не погрешность оценки.
</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 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>
{/* 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>
<button
type="button"
className="rp-more"
onClick={() => onNavigate(2)}
aria-label="Подробнее — источники данных"
style={{
display: "flex",
alignItems: "center",
gap: 4,
cursor: "pointer",
fontSize: 9.5,
letterSpacing: 0.3,
color: accent,
background: "none",
border: "none",
padding: 0,
margin: 0,
fontFamily: "inherit",
}}
>
Подробнее <span></span>
</button>
</div>
{/* Fix #1 (audit) — the tile counts below are summed from the top-10
persisted sample, which can be less than builtOn's full n_analogs
total (footer, below) for large result sets. */}
{data.meta.sourcesNote && (
<div style={{ fontSize: 9.5, color: hint, marginBottom: 8 }}>
{data.meta.sourcesNote}
</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>
);
}