"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 . tabIndex={-1} makes it focus()-able without adding a // Tab stop. regionRef?: Ref; } // 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
between lines (faithful to the design line breaks). function lines(parts: string[]) { return parts.map((part, i) => ( {i > 0 &&
} {part}
)); } // 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)}
{name.slice(idx + 1)} ); } export default function ResultPanel({ data = RESULT_FIXTURE, onNavigate, regionRef, }: ResultPanelProps) { return (
{/* header */}
02 {/* Panel heading — real

+ the region's accessible name (aria-labelledby). Preflight not loaded → reset UA margin inline. */}

РЕЗУЛЬТАТ ОЦЕНКИ

ИСТОЧНИКОВ:{" "} {data.meta.sources} ДОСТОВЕРНОСТЬ: {data.meta.confidence} РАЗБРОС ЦЕН{" "} {data.meta.cv}
{/* result cards */}
{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 (
{lines(card.title)}
{/* #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 && ( В ОФФЕР )}
{isRegistry && (
{/* §M8 — расшифровка «ДКП» один раз на секцию. */} СПРАВОЧНО · ДОГОВОРЫ КУПЛИ-ПРОДАЖИ
)} {/* H1 empty-state: no bare «—» / gauge / «· с учётом торга» — just an honest note pointing at the recommended asking price. */} {isEmptyCard ? (
{card.emptyNote}
) : ( <>
{card.value} {card.unit}
{card.range && (
{card.range}
)} {card.ppm && (
{card.ppm}
)} {card.note && (
{card.note}
)} )} {isEmptyCard ? ( ) : card.bars ? (
{card.bars.map((h, bi) => ( ))}
) : ( // 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.
{card.delta ? ( {card.delta} {card.deltaLabel} ) : ( )}
)}
); })}
{/* §1.2 честный разброс: диапазоны цен = рыночный спред, НЕ погрешность оценки. Ставим сразу под ценовыми карточками, чтобы широкий диапазон не читался как «неуверенность расчёта». */}
Диапазоны показывают разброс цен на рынке, а не погрешность оценки.
{/* ranges + radar */}
{/* left range — ads */}
{lines(data.ranges.ads.label)}
медиана · {data.ranges.ads.median}
{data.ranges.ads.lo} {data.ranges.ads.median} {data.ranges.ads.hi}
{/* right range — deals */}
{lines(data.ranges.deals.label)}
медиана · {data.ranges.deals.median}
{data.ranges.deals.lo} {data.ranges.deals.median} {data.ranges.deals.hi}
{/* sources */}
ИСТОЧНИКИ ДАННЫХ
{/* 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 && (
{data.meta.sourcesNote}
)}
{data.sources.map((s, si) => s.active ? (
{sourceName(s.name)}
{s.count}
{s.label}
) : (
{s.name}
{s.count}
{s.label}
), )}
При недоступности источника частичный результат не блокируется {data.meta.builtOn ?? "—"}
); }