gendesign/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx
bot-backend 890daa4a31
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / openapi-codegen-check (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
feat(tradein/v2): wire main dashboard to real API (mappers + page shell) (#2038)
FE-1 foundation for #2036. app/v2/page.tsx now owns data: ParamsPanel form ->
useEstimateMutation, restore-by-id via window.location.search (mirrors v1, avoids
useSearchParams Suspense build break), sub-hooks useStreetDeals/useEstimateHouseAnalytics;
loading/empty/insufficient/error states (honest placeholders, no fixtures-as-real).

New components/trade-in/v2/mappers.ts: pure AggregatedEstimate(+street-deals/analytics)
-> fixture-shape transforms — 3 price tiers (asking/expected_sold/DKP), ranges, scatter,
7 source slots, summary. CV + per-source counts are FE-approx pending BE-1 (#2043).

ParamsPanel -> controlled form (onSubmit/isPending/error/initialValues). ResultPanel/
ObjectSummary/HeroBar/Footer accept a composite data prop defaulting to the existing
fixture (pixel-perfect markup unchanged). next build green (/v2 34 kB).
2026-06-28 14:48:57 +03:00

894 lines
24 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,
onAccent,
accentGlow,
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: 300,
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.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: 13,
height: 13,
borderRadius: "50%",
background: onAccent,
border: `3px solid ${accent}`,
boxShadow: `0 0 8px ${accentGlow}`,
}}
/>
</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: 13,
height: 13,
borderRadius: "50%",
background: onAccent,
border: `3px solid ${accent}`,
boxShadow: `0 0 8px ${accentGlow}`,
}}
/>
</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 }}>
ПОСТРОЕНО ПО 6 АНАЛОГАМ И 10 СДЕЛКАМ ЗА 6 МЕСЯЦЕВ
</span>
</div>
</div>
</div>
);
}