feat(tradein/v2): #2899 — сами правки типа/маппера/ResultPanel (в первый коммит попал только тест: git checkout HEAD откатил рабочее дерево до коммита)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
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 / frontend-checks (pull_request) Successful in 56s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
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 / frontend-checks (pull_request) Successful in 56s
This commit is contained in:
parent
07655a2642
commit
03af7386e9
3 changed files with 418 additions and 346 deletions
|
|
@ -37,6 +37,41 @@ const {
|
||||||
font,
|
font,
|
||||||
} = tokens;
|
} = tokens;
|
||||||
|
|
||||||
|
// #2899 — одна и та же спокойная плашка для «−18% · к цене объявления» (карточка
|
||||||
|
// 2) и «Верх рынка · 69-й перцентиль среди 16 аналогов» (карточка 1). Раньше JSX
|
||||||
|
// плашки жил только в ветке без гистограммы, и delta карточки с барами терялся бы
|
||||||
|
// молча.
|
||||||
|
function DeltaPill({
|
||||||
|
delta,
|
||||||
|
deltaLabel,
|
||||||
|
}: {
|
||||||
|
delta: string;
|
||||||
|
deltaLabel?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<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 }}>
|
||||||
|
{delta}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 9.5, letterSpacing: 0.2 }}>{deltaLabel}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface ResultPanelProps {
|
interface ResultPanelProps {
|
||||||
// Required on the app path (v2/page.tsx always supplies mapResultPanel
|
// Required on the app path (v2/page.tsx always supplies mapResultPanel
|
||||||
// output) — an omitted prop must be a TS error, not a silent fallback to
|
// output) — an omitted prop must be a TS error, not a silent fallback to
|
||||||
|
|
@ -205,319 +240,198 @@ export default function ResultPanel({
|
||||||
const isHeadline = !isEmptyCard && ci === headlineIndex;
|
const isHeadline = !isEmptyCard && ci === headlineIndex;
|
||||||
const isAsking = ci === 0;
|
const isAsking = ci === 0;
|
||||||
return (
|
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
|
<div
|
||||||
|
key={ci}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "relative",
|
||||||
left: -1,
|
background: isHeadline
|
||||||
top: -1,
|
? infoSoftBg
|
||||||
width: 12,
|
: isRegistry
|
||||||
height: 12,
|
? surface.w40
|
||||||
borderLeft: `1.5px solid ${isHeadline ? accent : bracket}`,
|
: surface.w55,
|
||||||
borderTop: `1.5px solid ${isHeadline ? accent : bracket}`,
|
backdropFilter: "blur(6px)",
|
||||||
}}
|
border: isHeadline
|
||||||
/>
|
? `1.5px solid ${accent}`
|
||||||
<div
|
: isRegistry
|
||||||
style={{
|
? `1px solid ${lineSoft}`
|
||||||
display: "flex",
|
: `1px solid ${line2}`,
|
||||||
alignItems: "flex-start",
|
borderRadius: 8,
|
||||||
justifyContent: "space-between",
|
padding: "15px 16px 13px",
|
||||||
gap: 8,
|
boxShadow: isHeadline
|
||||||
|
? "0 6px 22px rgba(46,139,255,.16)"
|
||||||
|
: undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 9.5,
|
position: "absolute",
|
||||||
letterSpacing: 1.5,
|
left: -1,
|
||||||
// Contrast follow-up (audit #2266): accent (#2e8bff) on the
|
top: -1,
|
||||||
// headline card's pale infoSoftBg tint measured ≈2.87:1 — below
|
width: 12,
|
||||||
// even the AA "large text" floor (3:1). accentDeep is the same
|
height: 12,
|
||||||
// brand hue, darkened (existing token, not a one-off hex) — it
|
borderLeft: `1.5px solid ${isHeadline ? accent : bracket}`,
|
||||||
// clears ~4.2:1 here, so the label reads reliably too.
|
borderTop: `1.5px solid ${isHeadline ? accent : bracket}`,
|
||||||
color: isHeadline ? accentDeep : isRegistry ? muted2 : body2,
|
}}
|
||||||
fontWeight: isHeadline ? 600 : undefined,
|
/>
|
||||||
lineHeight: 1.6,
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{lines(card.title)}
|
<div
|
||||||
</div>
|
style={{
|
||||||
{/* #1991 — explicit "this is the figure that goes into the offer"
|
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
|
mark on the headline card, so the two dominant prices are not
|
||||||
just visually bigger but literally labelled which one to quote. */}
|
just visually bigger but literally labelled which one to quote. */}
|
||||||
{isHeadline && !isEmptyCard && (
|
{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
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontFamily: font.mono,
|
flex: "0 0 auto",
|
||||||
// M1 — asking (30) sits above the ДКП «справочно» tier (28)
|
fontSize: 8,
|
||||||
// yet clearly below the accent headline (40).
|
fontWeight: 700,
|
||||||
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,
|
letterSpacing: 1,
|
||||||
|
color: onAccent,
|
||||||
|
background: accentDeep,
|
||||||
|
borderRadius: 3,
|
||||||
|
padding: "3px 6px",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{card.unit}
|
В ОФФЕР
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
{card.range && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: font.mono,
|
|
||||||
fontSize: 11,
|
|
||||||
color: muted,
|
|
||||||
marginTop: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{card.range}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{card.ppm && (
|
</div>
|
||||||
<div
|
{isRegistry && (
|
||||||
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
fontSize: 8,
|
||||||
alignItems: "flex-end",
|
letterSpacing: 1.2,
|
||||||
gap: 2,
|
color: muted3,
|
||||||
height: 20,
|
marginTop: 3,
|
||||||
flex: 1,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{card.bars.map((h, bi) => (
|
{/* §M8 — расшифровка «ДКП» один раз на секцию. */}
|
||||||
<span
|
СПРАВОЧНО · ДОГОВОРЫ КУПЛИ-ПРОДАЖИ
|
||||||
key={bi}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
height: `${h}%`,
|
|
||||||
background: barColor(bi, card.bars as number[]),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
)}
|
||||||
type="button"
|
{/* H1 empty-state: no bare «—» / gauge / «· с учётом торга» — just an
|
||||||
className="rp-more"
|
honest note pointing at the recommended asking price. */}
|
||||||
onClick={() => onNavigate(card.nav)}
|
{isEmptyCard ? (
|
||||||
aria-label={`Подробнее — ${card.title.join(" ")}`}
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
marginTop: 12,
|
||||||
alignItems: "center",
|
fontSize: 11,
|
||||||
gap: 4,
|
lineHeight: 1.5,
|
||||||
cursor: "pointer",
|
color: body,
|
||||||
fontSize: 9,
|
maxWidth: "94%",
|
||||||
letterSpacing: 0.3,
|
|
||||||
color: accent,
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
background: "none",
|
|
||||||
border: "none",
|
|
||||||
padding: 0,
|
|
||||||
margin: 0,
|
|
||||||
fontFamily: "inherit",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Подробнее <span>→</span>
|
{card.emptyNote}
|
||||||
</button>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<>
|
||||||
// M2 — calm delta pill (was a 51px circular gauge that read like a
|
<div
|
||||||
// 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={{
|
style={{
|
||||||
display: "inline-flex",
|
marginTop: 14,
|
||||||
|
display: "flex",
|
||||||
alignItems: "baseline",
|
alignItems: "baseline",
|
||||||
gap: 5,
|
gap: 6,
|
||||||
background: infoSoftBg,
|
|
||||||
border: `1px solid ${lineSoft}`,
|
|
||||||
borderRadius: 20,
|
|
||||||
padding: "3px 10px",
|
|
||||||
color: accentDeep,
|
|
||||||
fontFamily: font.sans,
|
|
||||||
fontVariantNumeric: "tabular-nums",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontFamily: font.mono,
|
fontFamily: font.mono,
|
||||||
fontSize: 12,
|
// M1 — asking (30) sits above the ДКП «справочно» tier (28)
|
||||||
fontWeight: 600,
|
// 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.delta}
|
{card.value}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 9.5, letterSpacing: 0.2 }}>
|
<span
|
||||||
{card.deltaLabel}
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: muted,
|
||||||
|
letterSpacing: 1,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{card.unit}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</div>
|
||||||
) : (
|
{card.range && (
|
||||||
<span />
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rp-more"
|
className="rp-more"
|
||||||
|
|
@ -526,38 +440,151 @@ export default function ResultPanel({
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
gap: 4,
|
gap: 4,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
letterSpacing: 0.3,
|
letterSpacing: 0.3,
|
||||||
color: accent,
|
color: accent,
|
||||||
whiteSpace: "nowrap",
|
width: "100%",
|
||||||
background: "none",
|
background: "none",
|
||||||
border: "none",
|
border: "none",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
margin: 0,
|
margin: 0,
|
||||||
|
marginTop: 16,
|
||||||
fontFamily: "inherit",
|
fontFamily: "inherit",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Подробнее <span>→</span>
|
Подробнее <span>→</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
) : card.bars ? (
|
||||||
{card.exposureNote && (
|
<>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 9,
|
display: "flex",
|
||||||
color: muted2,
|
alignItems: "flex-end",
|
||||||
marginTop: 4,
|
justifyContent: "space-between",
|
||||||
lineHeight: 1.4,
|
gap: 12,
|
||||||
maxWidth: "82%",
|
marginTop: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{card.exposureNote}
|
<div
|
||||||
</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>
|
||||||
|
{/* #2899: позиция на рынке под гистограммой («Верх рынка · 69-й
|
||||||
|
перцентиль среди 16 аналогов»); при когорте < 15 delta нет. */}
|
||||||
|
{card.delta ? (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<DeltaPill
|
||||||
|
delta={card.delta}
|
||||||
|
deltaLabel={card.deltaLabel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// 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 ? (
|
||||||
|
<DeltaPill
|
||||||
|
delta={card.delta}
|
||||||
|
deltaLabel={card.deltaLabel}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
{card.exposureNote && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 9,
|
||||||
|
color: muted2,
|
||||||
|
marginTop: 4,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
maxWidth: "82%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{card.exposureNote}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -583,8 +610,8 @@ export default function ResultPanel({
|
||||||
marginTop: -6,
|
marginTop: -6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{SHORT_ESTIMATE_DISCLAIMER} Диапазоны показывают разброс цен на рынке,
|
{SHORT_ESTIMATE_DISCLAIMER} Диапазоны показывают разброс цен на рынке, а
|
||||||
а не погрешность оценки.
|
не погрешность оценки.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ranges + radar */}
|
{/* ranges + radar */}
|
||||||
|
|
|
||||||
|
|
@ -605,10 +605,16 @@ interface DealTier {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Свежайшая дата среди лотов + её точность (лоты не всегда отсортированы). */
|
/** Свежайшая дата среди лотов + её точность (лоты не всегда отсортированы). */
|
||||||
function newestLot(lots: AnalogLot[]): [string | null, "day" | "quarter" | null] {
|
function newestLot(
|
||||||
|
lots: AnalogLot[],
|
||||||
|
): [string | null, "day" | "quarter" | null] {
|
||||||
let best: AnalogLot | null = null;
|
let best: AnalogLot | null = null;
|
||||||
for (const l of lots) {
|
for (const l of lots) {
|
||||||
if (l.listing_date && (!best?.listing_date || l.listing_date > best.listing_date)) best = l;
|
if (
|
||||||
|
l.listing_date &&
|
||||||
|
(!best?.listing_date || l.listing_date > best.listing_date)
|
||||||
|
)
|
||||||
|
best = l;
|
||||||
}
|
}
|
||||||
return [best?.listing_date ?? null, best?.date_precision ?? null];
|
return [best?.listing_date ?? null, best?.date_precision ?? null];
|
||||||
}
|
}
|
||||||
|
|
@ -636,7 +642,9 @@ function resolveDealTier(
|
||||||
// Fix #1/#8: guard the deal ₽/м² histogram against a high outlier the
|
// Fix #1/#8: guard the deal ₽/м² histogram against a high outlier the
|
||||||
// same way the ads card does — otherwise a single mis-scraped lot bins
|
// same way the ads card does — otherwise a single mis-scraped lot bins
|
||||||
// over [min,max] and crushes the real deals into the left buckets.
|
// over [min,max] and crushes the real deals into the left buckets.
|
||||||
bars: bins8(guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean),
|
bars: bins8(
|
||||||
|
guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean,
|
||||||
|
),
|
||||||
// sd.deals — top-10 из ORDER BY deal_date DESC по всем sd.count сделкам,
|
// sd.deals — top-10 из ORDER BY deal_date DESC по всем sd.count сделкам,
|
||||||
// так что максимум по ним = максимум по всей выборке, не по показанным.
|
// так что максимум по ним = максимум по всей выборке, не по показанным.
|
||||||
asOf: dealsAsOfLabel(...newestLot(sd.deals)),
|
asOf: dealsAsOfLabel(...newestLot(sd.deals)),
|
||||||
|
|
@ -825,11 +833,13 @@ export function mapReport(e: AggregatedEstimate): Report {
|
||||||
* - "out_of_coverage" → "вне ЕКБ" (the index only covers Yekaterinburg)
|
* - "out_of_coverage" → "вне ЕКБ" (the index only covers Yekaterinburg)
|
||||||
* - "insufficient_data" → "мало данных" (too few comparable listings)
|
* - "insufficient_data" → "мало данных" (too few comparable listings)
|
||||||
*/
|
*/
|
||||||
function locationIndexBadge(
|
function locationIndexBadge(li: LocationIndexResponse | null | undefined): {
|
||||||
li: LocationIndexResponse | null | undefined,
|
label: string;
|
||||||
): { label: string; ok: boolean } {
|
ok: boolean;
|
||||||
|
} {
|
||||||
if (li == null) return { label: "нет данных", ok: false };
|
if (li == null) return { label: "нет данных", ok: false };
|
||||||
if (li.status === "ok") return { label: fmtPct(li.location_index_pct), ok: true };
|
if (li.status === "ok")
|
||||||
|
return { label: fmtPct(li.location_index_pct), ok: true };
|
||||||
if (li.status === "out_of_coverage") return { label: "вне ЕКБ", ok: false };
|
if (li.status === "out_of_coverage") return { label: "вне ЕКБ", ok: false };
|
||||||
return { label: "мало данных", ok: false }; // status === "insufficient_data"
|
return { label: "мало данных", ok: false }; // status === "insufficient_data"
|
||||||
}
|
}
|
||||||
|
|
@ -1063,6 +1073,23 @@ export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full 02 РЕЗУЛЬТАТ block: 3 cards + meta + ranges + scatter + sources. */
|
/** Full 02 РЕЗУЛЬТАТ block: 3 cards + meta + ranges + scatter + sources. */
|
||||||
|
// #2899 — позиция квартиры внутри когорты аналогов: плашка на карточке
|
||||||
|
// «РЕКОМЕНДОВАННАЯ ЦЕНА». Перцентиль считает бэкенд (1..99, «какая доля аналогов
|
||||||
|
// дешевле»; null при когорте < 15 — тогда плашки нет, а не «В рынке» по умолчанию).
|
||||||
|
// Лейбл — по терцилям: ≤33 «Низ рынка», ≥67 «Верх рынка», между — «В рынке»;
|
||||||
|
// число рядом, чтобы лейбл не читался точнее, чем он есть. Это НЕ
|
||||||
|
// location_index_pct (район против города) — тот живёт в HeroBar.
|
||||||
|
export function marketPositionPill(
|
||||||
|
e: Pick<AggregatedEstimate, "market_percentile" | "n_analogs">,
|
||||||
|
): { delta: string; deltaLabel: string } | undefined {
|
||||||
|
const pct = e.market_percentile;
|
||||||
|
if (pct == null || !Number.isFinite(pct)) return undefined;
|
||||||
|
const delta = pct <= 33 ? "Низ рынка" : pct >= 67 ? "Верх рынка" : "В рынке";
|
||||||
|
const n = e.n_analogs;
|
||||||
|
const deltaLabel = `${pct}-й перцентиль среди ${n} ${pluralRu(n, ["аналога", "аналогов", "аналогов"])}`;
|
||||||
|
return { delta, deltaLabel };
|
||||||
|
}
|
||||||
|
|
||||||
export function mapResultPanel(
|
export function mapResultPanel(
|
||||||
e: AggregatedEstimate,
|
e: AggregatedEstimate,
|
||||||
streetDeals?: StreetDealsResponse | null,
|
streetDeals?: StreetDealsResponse | null,
|
||||||
|
|
@ -1108,7 +1135,12 @@ export function mapResultPanel(
|
||||||
// 872k выброс this card already reports as "исключён", squashing the real
|
// 872k выброс this card already reports as "исключён", squashing the real
|
||||||
// analogs into the left bins while the number says the outlier is dropped.
|
// analogs into the left bins while the number says the outlier is dropped.
|
||||||
// dealsOnlyPrice → e.analogs is empty → bins8([]) → [] (no bars drawn).
|
// dealsOnlyPrice → e.analogs is empty → bins8([]) → [] (no bars drawn).
|
||||||
bars: bins8(guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean),
|
bars: bins8(
|
||||||
|
guardPriceOutliers(e.analogs.map((a) => a.price_per_m2)).clean,
|
||||||
|
),
|
||||||
|
// #2899: «Верх рынка · 69-й перцентиль среди 16 аналогов» — та же спокойная
|
||||||
|
// плашка, что у карточки 2; undefined при когорте < 15 (бэкенд отдаёт null).
|
||||||
|
...(marketPositionPill(e) ?? {}),
|
||||||
nav: 2,
|
nav: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -1121,7 +1153,9 @@ export function mapResultPanel(
|
||||||
e.expected_sold_range_high_rub,
|
e.expected_sold_range_high_rub,
|
||||||
)
|
)
|
||||||
: "",
|
: "",
|
||||||
ppm: hasExpected ? `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга` : "",
|
ppm: hasExpected
|
||||||
|
? `${fmtPpm(e.expected_sold_per_m2)} · с учётом торга`
|
||||||
|
: "",
|
||||||
delta:
|
delta:
|
||||||
hasExpected && e.asking_to_sold_ratio != null
|
hasExpected && e.asking_to_sold_ratio != null
|
||||||
? fmtPct((e.asking_to_sold_ratio - 1) * 100)
|
? fmtPct((e.asking_to_sold_ratio - 1) * 100)
|
||||||
|
|
@ -2148,7 +2182,10 @@ export function mapSources(
|
||||||
// sample still shown in the table right below this KPI tile) — a bare
|
// sample still shown in the table right below this KPI tile) — a bare
|
||||||
// "0" here would directly contradict visible rows. Fall back to the
|
// "0" here would directly contradict visible rows. Fall back to the
|
||||||
// actual displayed population (same fix as ListingsCard's count-strip).
|
// actual displayed population (same fix as ListingsCard's count-strip).
|
||||||
count: e != null ? String(e.n_analogs > 0 ? e.n_analogs : e.analogs.length) : "—",
|
count:
|
||||||
|
e != null
|
||||||
|
? String(e.n_analogs > 0 ? e.n_analogs : e.analogs.length)
|
||||||
|
: "—",
|
||||||
median: e != null ? fmtMln(e.median_price_rub) : "—",
|
median: e != null ? fmtMln(e.median_price_rub) : "—",
|
||||||
ppm:
|
ppm:
|
||||||
e != null && Number.isFinite(e.median_price_per_m2)
|
e != null && Number.isFinite(e.median_price_per_m2)
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,7 @@ export interface QuotaStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HouseType =
|
export type HouseType =
|
||||||
| "panel"
|
"panel" | "brick" | "monolith" | "monolith_brick" | "other";
|
||||||
| "brick"
|
|
||||||
| "monolith"
|
|
||||||
| "monolith_brick"
|
|
||||||
| "other";
|
|
||||||
|
|
||||||
export type RepairState = "needs_repair" | "standard" | "good" | "excellent";
|
export type RepairState = "needs_repair" | "standard" | "good" | "excellent";
|
||||||
|
|
||||||
|
|
@ -33,11 +29,19 @@ export const REPAIR_STATES: readonly RepairState[] = [
|
||||||
"good",
|
"good",
|
||||||
"excellent",
|
"excellent",
|
||||||
];
|
];
|
||||||
export function asHouseType(v: string | null | undefined): HouseType | undefined {
|
export function asHouseType(
|
||||||
return v && (HOUSE_TYPES as readonly string[]).includes(v) ? (v as HouseType) : undefined;
|
v: string | null | undefined,
|
||||||
|
): HouseType | undefined {
|
||||||
|
return v && (HOUSE_TYPES as readonly string[]).includes(v)
|
||||||
|
? (v as HouseType)
|
||||||
|
: undefined;
|
||||||
}
|
}
|
||||||
export function asRepairState(v: string | null | undefined): RepairState | undefined {
|
export function asRepairState(
|
||||||
return v && (REPAIR_STATES as readonly string[]).includes(v) ? (v as RepairState) : undefined;
|
v: string | null | undefined,
|
||||||
|
): RepairState | undefined {
|
||||||
|
return v && (REPAIR_STATES as readonly string[]).includes(v)
|
||||||
|
? (v as RepairState)
|
||||||
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConfidenceLevel = "low" | "medium" | "high";
|
export type ConfidenceLevel = "low" | "medium" | "high";
|
||||||
|
|
@ -103,9 +107,9 @@ export interface AnalogLot {
|
||||||
days_on_market: number | null;
|
days_on_market: number | null;
|
||||||
photo_url: string | null;
|
photo_url: string | null;
|
||||||
// ── Слой 5.2 — clickable links + distance ──
|
// ── Слой 5.2 — clickable links + distance ──
|
||||||
source: string | null; // 'avito' / 'cian' / 'domklik' / 'rosreestr'
|
source: string | null; // 'avito' / 'cian' / 'domklik' / 'rosreestr'
|
||||||
source_url: string | null; // ссылка на оригинал
|
source_url: string | null; // ссылка на оригинал
|
||||||
distance_m: number | null; // расстояние до целевой квартиры в метрах
|
distance_m: number | null; // расстояние до целевой квартиры в метрах
|
||||||
// ── PR M (#564 Phase 3) — confidence tier для rosreestr deals ──
|
// ── PR M (#564 Phase 3) — confidence tier для rosreestr deals ──
|
||||||
// 'T0_per_house' — kadastr_num exact match (currently not available in open dataset)
|
// 'T0_per_house' — kadastr_num exact match (currently not available in open dataset)
|
||||||
// 'T1_per_street' — street-level only (default for all rosreestr deals)
|
// 'T1_per_street' — street-level only (default for all rosreestr deals)
|
||||||
|
|
@ -125,7 +129,7 @@ export interface AnalogLot {
|
||||||
// month — "YYYY-MM"; ppm2 — медиана ₽/м² за месяц по зданию/району.
|
// month — "YYYY-MM"; ppm2 — медиана ₽/м² за месяц по зданию/району.
|
||||||
export interface PriceTrendPoint {
|
export interface PriceTrendPoint {
|
||||||
month: string; // "YYYY-MM"
|
month: string; // "YYYY-MM"
|
||||||
ppm2: number; // ₽/м²
|
ppm2: number; // ₽/м²
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CianValuationSummary {
|
export interface CianValuationSummary {
|
||||||
|
|
@ -133,7 +137,7 @@ export interface CianValuationSummary {
|
||||||
rent_price_rub: number | null;
|
rent_price_rub: number | null;
|
||||||
chart: Array<{ date: string; price: number }>;
|
chart: Array<{ date: string; price: number }>;
|
||||||
chart_change_pct: number | null;
|
chart_change_pct: number | null;
|
||||||
chart_change_direction: 'increase' | 'decrease' | 'neutral' | null;
|
chart_change_direction: "increase" | "decrease" | "neutral" | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Базис коэффициента asking→sold (S3): по группе комнат либо городской fallback.
|
// Базис коэффициента asking→sold (S3): по группе комнат либо городской fallback.
|
||||||
|
|
@ -144,20 +148,20 @@ export type RatioBasis = "per_rooms" | "global_fallback";
|
||||||
// blend'а медианы. Сурфейсится как референсный маркер. null при отсутствии записи.
|
// blend'а медианы. Сурфейсится как референсный маркер. null при отсутствии записи.
|
||||||
export interface AvitoImvSummary {
|
export interface AvitoImvSummary {
|
||||||
recommended_price: number | null; // рекомендованная цена Avito, ₽
|
recommended_price: number | null; // рекомендованная цена Avito, ₽
|
||||||
lower_price: number | null; // нижняя граница IMV-коридора, ₽
|
lower_price: number | null; // нижняя граница IMV-коридора, ₽
|
||||||
higher_price: number | null; // верхняя граница IMV-коридора, ₽
|
higher_price: number | null; // верхняя граница IMV-коридора, ₽
|
||||||
market_count: number | null; // объём рынка, на котором построена оценка
|
market_count: number | null; // объём рынка, на котором построена оценка
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── #652: коридор реальных ДКП-сделок Росреестра (advisory, не клампит) ──
|
// ── #652: коридор реальных ДКП-сделок Росреестра (advisory, не клампит) ──
|
||||||
// Агрегаты ₽/м² по сопоставимым сделкам за период. null / count=0 если по улице
|
// Агрегаты ₽/м² по сопоставимым сделкам за период. null / count=0 если по улице
|
||||||
// нет сделок. count..period_months — required (бэкенд не отдаёт частичный объект).
|
// нет сделок. count..period_months — required (бэкенд не отдаёт частичный объект).
|
||||||
export interface DkpCorridor {
|
export interface DkpCorridor {
|
||||||
count: number; // число ДКП-сделок в выборке
|
count: number; // число ДКП-сделок в выборке
|
||||||
low_ppm2: number; // P10 ₽/м² по сделкам (робастный коридор)
|
low_ppm2: number; // P10 ₽/м² по сделкам (робастный коридор)
|
||||||
median_ppm2: number; // медиана ₽/м²
|
median_ppm2: number; // медиана ₽/м²
|
||||||
high_ppm2: number; // P90 ₽/м² по сделкам (робастный коридор)
|
high_ppm2: number; // P90 ₽/м² по сделкам (робастный коридор)
|
||||||
period_months: number; // окно ПОИСКА сделок — НЕ возраст данных
|
period_months: number; // окно ПОИСКА сделок — НЕ возраст данных
|
||||||
// #2846: ISO-дата свежайшей из ОТОБРАННЫХ сделок (не из всей таблицы). Точность
|
// #2846: ISO-дата свежайшей из ОТОБРАННЫХ сделок (не из всей таблицы). Точность
|
||||||
// — квартал: Росреестр публикует deal_date = первым днём квартала, поэтому
|
// — квартал: Росреестр публикует deal_date = первым днём квартала, поэтому
|
||||||
// «2026-01-01» читается как «I кв. 2026», а не как 1 января. optional: оценки,
|
// «2026-01-01» читается как «I кв. 2026», а не как 1 января. optional: оценки,
|
||||||
|
|
@ -179,10 +183,14 @@ export interface AggregatedEstimate {
|
||||||
expected_sold_range_high_rub?: number | null;
|
expected_sold_range_high_rub?: number | null;
|
||||||
expected_sold_per_m2?: number | null;
|
expected_sold_per_m2?: number | null;
|
||||||
asking_to_sold_ratio?: number | null; // sold / asking (≈0.82)
|
asking_to_sold_ratio?: number | null; // sold / asking (≈0.82)
|
||||||
ratio_basis?: RatioBasis | null; // 'per_rooms' | 'global_fallback'
|
ratio_basis?: RatioBasis | null; // 'per_rooms' | 'global_fallback'
|
||||||
confidence: ConfidenceLevel;
|
confidence: ConfidenceLevel;
|
||||||
confidence_explanation: string | null;
|
confidence_explanation: string | null;
|
||||||
n_analogs: number;
|
n_analogs: number;
|
||||||
|
// #2899: позиция ЭТОЙ квартиры внутри когорты аналогов, 1..99 — «какая доля
|
||||||
|
// аналогов дешевле». null/отсутствует = когорта < 15 либо старая оценка;
|
||||||
|
// показывать только вместе с n_analogs. НЕ location_index_pct (тот про район).
|
||||||
|
market_percentile?: number | null;
|
||||||
insufficient_data: boolean; // backend #697: true когда median_price_rub <= 0 (нет данных)
|
insufficient_data: boolean; // backend #697: true когда median_price_rub <= 0 (нет данных)
|
||||||
// fix (never-block estimate) — оценка теперь показывается всегда, пока цена
|
// fix (never-block estimate) — оценка теперь показывается всегда, пока цена
|
||||||
// посчитана (insufficient_data=false), даже при n_analogs=0 (фолбэк по
|
// посчитана (insufficient_data=false), даже при n_analogs=0 (фолбэк по
|
||||||
|
|
@ -210,16 +218,16 @@ export interface AggregatedEstimate {
|
||||||
// Ephemeral (не персистится в БД, только для текущего ответа) — optional,
|
// Ephemeral (не персистится в БД, только для текущего ответа) — optional,
|
||||||
// т.к. оценки, посчитанные до деплоя бэкенда, поле не содержат.
|
// т.к. оценки, посчитанные до деплоя бэкенда, поле не содержат.
|
||||||
target_city_ambiguous?: boolean;
|
target_city_ambiguous?: boolean;
|
||||||
sources_used: string[]; // ['avito', 'cian', 'rosreestr']
|
sources_used: string[]; // ['avito', 'cian', 'rosreestr']
|
||||||
// #2043 (BE-1): достоверность выборки — реальный коэффициент вариации ₽/м² (std/mean),
|
// #2043 (BE-1): достоверность выборки — реальный коэффициент вариации ₽/м² (std/mean),
|
||||||
// счётчики аналогов по источнику, момент создания оценки. Все optional: старые
|
// счётчики аналогов по источнику, момент создания оценки. Все optional: старые
|
||||||
// кешированные оценки их не содержат → UI graceful fallback.
|
// кешированные оценки их не содержат → UI graceful fallback.
|
||||||
cv?: number | null; // коэффициент вариации ₽/м² (0..1), null если <2 цен
|
cv?: number | null; // коэффициент вариации ₽/м² (0..1), null если <2 цен
|
||||||
source_counts?: Record<string, number>; // {'avito': 12, 'cian': 5}
|
source_counts?: Record<string, number>; // {'avito': 12, 'cian': 5}
|
||||||
created_at?: string | null; // ISO datetime — «отчёт от DD.MM»
|
created_at?: string | null; // ISO datetime — «отчёт от DD.MM»
|
||||||
data_freshness_minutes: number | null; // «обновлено N минут назад»
|
data_freshness_minutes: number | null; // «обновлено N минут назад»
|
||||||
last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional)
|
last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional)
|
||||||
est_days_on_market: number | null; // прогноз срока продажи
|
est_days_on_market: number | null; // прогноз срока продажи
|
||||||
// address_precision — точность гео-привязки адреса (из DaData qc_geo):
|
// address_precision — точность гео-привязки адреса (из DaData qc_geo):
|
||||||
// «house» (qc_geo=0, дом точно), «street» (qc_geo=1, до улицы),
|
// «house» (qc_geo=0, дом точно), «street» (qc_geo=1, до улицы),
|
||||||
// «approximate» (qc_geo≥2: населённый пункт/город/регион/не распознан).
|
// «approximate» (qc_geo≥2: населённый пункт/город/регион/не распознан).
|
||||||
|
|
@ -383,15 +391,15 @@ export interface HouseAnalyticsResponse {
|
||||||
// ── Street deals (endpoint: GET /trade-in/street-deals) ──
|
// ── Street deals (endpoint: GET /trade-in/street-deals) ──
|
||||||
|
|
||||||
export interface StreetDealsResponse {
|
export interface StreetDealsResponse {
|
||||||
street: string | null; // "Космонавтов" / null если не извлёкся
|
street: string | null; // "Космонавтов" / null если не извлёкся
|
||||||
period_from: string; // ISO date "2025-05-29"
|
period_from: string; // ISO date "2025-05-29"
|
||||||
period_to: string;
|
period_to: string;
|
||||||
count: number; // all matching, не топ-10
|
count: number; // all matching, не топ-10
|
||||||
median_price_rub: number;
|
median_price_rub: number;
|
||||||
median_price_per_m2: number;
|
median_price_per_m2: number;
|
||||||
range_low_rub: number;
|
range_low_rub: number;
|
||||||
range_high_rub: number;
|
range_high_rub: number;
|
||||||
deals: AnalogLot[]; // top-10 по deal_date DESC
|
deals: AnalogLot[]; // top-10 по deal_date DESC
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sales vs Listings (endpoint: GET /trade-in/sales-vs-listings) ──
|
// ── Sales vs Listings (endpoint: GET /trade-in/sales-vs-listings) ──
|
||||||
|
|
@ -400,7 +408,7 @@ export interface StreetDealsResponse {
|
||||||
|
|
||||||
export interface SalesListingPair {
|
export interface SalesListingPair {
|
||||||
deal_id: number;
|
deal_id: number;
|
||||||
deal_date: string; // ISO date
|
deal_date: string; // ISO date
|
||||||
deal_price_rub: number;
|
deal_price_rub: number;
|
||||||
deal_price_per_m2: number;
|
deal_price_per_m2: number;
|
||||||
deal_area_m2: number;
|
deal_area_m2: number;
|
||||||
|
|
@ -409,9 +417,9 @@ export interface SalesListingPair {
|
||||||
deal_address: string;
|
deal_address: string;
|
||||||
|
|
||||||
listing_id: number | null;
|
listing_id: number | null;
|
||||||
listing_source: string | null; // 'avito' / 'cian' / 'yandex' / 'domklik' (истор. могут встречаться выключенные источники)
|
listing_source: string | null; // 'avito' / 'cian' / 'yandex' / 'domklik' (истор. могут встречаться выключенные источники)
|
||||||
listing_source_url: string | null;
|
listing_source_url: string | null;
|
||||||
listing_date: string | null; // ISO date
|
listing_date: string | null; // ISO date
|
||||||
listing_price_rub: number | null;
|
listing_price_rub: number | null;
|
||||||
listing_price_per_m2: number | null;
|
listing_price_per_m2: number | null;
|
||||||
listing_area_m2: number | null;
|
listing_area_m2: number | null;
|
||||||
|
|
@ -426,9 +434,9 @@ export interface SalesListingPair {
|
||||||
|
|
||||||
export interface SalesVsListingsResponse {
|
export interface SalesVsListingsResponse {
|
||||||
street: string | null;
|
street: string | null;
|
||||||
period_months: number; // 24 default
|
period_months: number; // 24 default
|
||||||
window_days: number; // 180 default
|
window_days: number; // 180 default
|
||||||
area_tolerance: number; // 0.15 default
|
area_tolerance: number; // 0.15 default
|
||||||
total_deals: number;
|
total_deals: number;
|
||||||
deals_with_listings: number;
|
deals_with_listings: number;
|
||||||
linkage_rate_pct: number;
|
linkage_rate_pct: number;
|
||||||
|
|
@ -440,14 +448,14 @@ export interface SalesVsListingsResponse {
|
||||||
// Качество данных: house_linked = есть пары ДКП↔listing; street_only = есть
|
// Качество данных: house_linked = есть пары ДКП↔listing; street_only = есть
|
||||||
// сделки, но привязка к конкретному дому/объявлению невозможна; no_data = нет сделок.
|
// сделки, но привязка к конкретному дому/объявлению невозможна; no_data = нет сделок.
|
||||||
data_quality: "house_linked" | "street_only" | "no_data";
|
data_quality: "house_linked" | "street_only" | "no_data";
|
||||||
pairs: SalesListingPair[]; // все sorted by deal_date DESC
|
pairs: SalesListingPair[]; // все sorted by deal_date DESC
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sell-time sensitivity (endpoint: GET /estimate/{id}/sell-time-sensitivity) ──
|
// ── Sell-time sensitivity (endpoint: GET /estimate/{id}/sell-time-sensitivity) ──
|
||||||
|
|
||||||
export interface SellTimeBucket {
|
export interface SellTimeBucket {
|
||||||
price_premium_label: string; // 'cheap' | 'median' | 'plus5' | 'plus10'
|
price_premium_label: string; // 'cheap' | 'median' | 'plus5' | 'plus10'
|
||||||
price_premium_pct: number; // -5, 0, 5, 10
|
price_premium_pct: number; // -5, 0, 5, 10
|
||||||
median_exposure_days: number | null;
|
median_exposure_days: number | null;
|
||||||
p25_days: number | null;
|
p25_days: number | null;
|
||||||
p75_days: number | null;
|
p75_days: number | null;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue