fix(tradein/v2): UI/UX аудит round 2 — AA-контраст, честность чисел, a11y (#2081) #2086
15 changed files with 1046 additions and 395 deletions
|
|
@ -403,7 +403,10 @@ export default function TradeInV2Page() {
|
|||
useEffect(() => {
|
||||
const compute = () =>
|
||||
setArtboardScale(
|
||||
Math.min(1, window.innerWidth / 1536, window.innerHeight / 1024),
|
||||
Math.max(
|
||||
0.88,
|
||||
Math.min(1, window.innerWidth / 1536, window.innerHeight / 1024),
|
||||
),
|
||||
);
|
||||
compute();
|
||||
window.addEventListener("resize", compute);
|
||||
|
|
@ -469,6 +472,11 @@ export default function TradeInV2Page() {
|
|||
estimate != null &&
|
||||
(estimate.insufficient_data || estimate.n_analogs === 0);
|
||||
const apiError = mutation.error?.message ?? null;
|
||||
// M4: estimate-dependent meta/controls (the «ДЕЙСТВИТЕЛЕН ДО» validity line,
|
||||
// «КАК РАССЧИТАНО», PDF) only render once there is a real, sufficient estimate.
|
||||
// Folds in `mounted` so SSR and the first client render agree (false → hidden)
|
||||
// — no hydration drift, same reason the PDF control is gated behind `mounted`.
|
||||
const hasEstimate = mounted && estimate != null && !insufficient;
|
||||
|
||||
// ── Mapped presentation data (memoised so nav/drawer toggles don't recompute
|
||||
// geometry). ──────────────────────────────────────────────────────────
|
||||
|
|
@ -716,6 +724,7 @@ export default function TradeInV2Page() {
|
|||
data={{ report, object: objectInfo }}
|
||||
estimateId={mounted ? currentEstimateId : null}
|
||||
onOpenInfo={() => setDrawerOpen(true)}
|
||||
hasEstimate={hasEstimate}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
|
@ -741,7 +750,7 @@ export default function TradeInV2Page() {
|
|||
</main>
|
||||
|
||||
<footer style={{ display: "contents" }}>
|
||||
<Footer data={report} />
|
||||
<Footer data={report} hasEstimate={hasEstimate} />
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,20 @@ const cardStyle = {
|
|||
padding: "16px 18px",
|
||||
} as const;
|
||||
|
||||
// L1 — a "обычно X–Y дн" band is only honest with ≥2 lots AND a non-degenerate
|
||||
// span. From a single analog (n<2) or when min===max ("237–237") the band is a
|
||||
// fake range, so we suppress it and keep just the point estimate (tier.days) +
|
||||
// the lot count. Gated on BOTH the parsed n (count line "1 аналог" → 1) and the
|
||||
// parsed range endpoints, so either signal alone drops the band.
|
||||
function sellTimeRangeMeaningful(tier: SellTimeTier): boolean {
|
||||
if (!tier.range || tier.range === "—") return false;
|
||||
const n = Number.parseInt(tier.count, 10); // "1 аналог" → 1
|
||||
if (Number.isFinite(n) && n < 2) return false;
|
||||
const nums = tier.range.match(/\d+/g)?.map(Number) ?? []; // "18–180" → [18,180]
|
||||
if (nums.length >= 2 && nums[0] === nums[nums.length - 1]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export default function AnalyticsView({
|
||||
data = ANALYTICS_FIXTURE,
|
||||
onNavigate,
|
||||
|
|
@ -107,8 +121,29 @@ export default function AnalyticsView({
|
|||
[avitoPts, yandexDots],
|
||||
);
|
||||
|
||||
// L2 — the subtitle is mapper-built and hardcodes "Avito + Яндекс"; drop any
|
||||
// series that drew no points so the subtitle matches the rendered legend (the
|
||||
// swatches below are already gated on *Pts.length). If the note format changes
|
||||
// the replace is a graceful no-op.
|
||||
const activeSeriesNames = [
|
||||
avitoPts.length > 0 ? "Avito" : null,
|
||||
yandexPts.length > 0 ? "Яндекс" : null,
|
||||
].filter((s): s is string => s != null);
|
||||
const historyNote =
|
||||
activeSeriesNames.length > 0
|
||||
? data.priceHistory.note.replace(
|
||||
/Avito \+ Яндекс/,
|
||||
activeSeriesNames.join(" + "),
|
||||
)
|
||||
: data.priceHistory.note;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
|
||||
<div
|
||||
role="region"
|
||||
tabIndex={0}
|
||||
aria-label="Аналитика дома — прокручиваемая область"
|
||||
style={{ display: "flex", flexDirection: "column", gap: 22 }}
|
||||
>
|
||||
{/* header */}
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -139,7 +174,9 @@ export default function AnalyticsView({
|
|||
style={{
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 1.5,
|
||||
color: tokens.muted2,
|
||||
// C1: primary KPI label → body2 (matches ParamsPanel field-label
|
||||
// pattern); the muted value/sub below keep the secondary tier.
|
||||
color: tokens.body2,
|
||||
}}
|
||||
>
|
||||
{kpi.label}
|
||||
|
|
@ -216,8 +253,12 @@ export default function AnalyticsView({
|
|||
<div
|
||||
style={{ fontSize: 9.5, color: tokens.hint, lineHeight: 1.5 }}
|
||||
>
|
||||
{tier.range}
|
||||
<br />
|
||||
{sellTimeRangeMeaningful(tier) ? (
|
||||
<>
|
||||
{tier.range}
|
||||
<br />
|
||||
</>
|
||||
) : null}
|
||||
{tier.count}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -240,7 +281,7 @@ export default function AnalyticsView({
|
|||
История цен в этом доме
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
|
||||
{data.priceHistory.note}
|
||||
{historyNote}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 16, fontSize: 10 }}>
|
||||
|
|
@ -280,7 +321,10 @@ export default function AnalyticsView({
|
|||
viewBox="0 0 900 220"
|
||||
style={{ width: "100%", height: 200, marginTop: 12 }}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label="График истории цен в этом доме: медиана ₽/м² по годам, серии Avito и Яндекс"
|
||||
>
|
||||
<title>История цен в этом доме — медиана ₽/м² по годам</title>
|
||||
<g stroke={tokens.lineSoft2} strokeWidth={1}>
|
||||
<line x1={40} y1={20} x2={900} y2={20} />
|
||||
<line x1={40} y1={65} x2={900} y2={65} />
|
||||
|
|
@ -454,7 +498,10 @@ export default function AnalyticsView({
|
|||
<svg
|
||||
viewBox="0 0 900 300"
|
||||
style={{ width: "100%", height: 280, marginTop: 12 }}
|
||||
role="img"
|
||||
aria-label="Точечный график зависимости цены продажи от срока экспозиции: аналоги, сделки и ваш объект"
|
||||
>
|
||||
<title>Цена × срок продажи</title>
|
||||
<g stroke={tokens.lineSoft2} strokeWidth={1}>
|
||||
<line x1={60} y1={20} x2={880} y2={20} />
|
||||
<line x1={60} y1={77} x2={880} y2={77} />
|
||||
|
|
|
|||
|
|
@ -8,9 +8,38 @@ import type { CacheData } from "./mappers";
|
|||
|
||||
const FIXTURE_CACHE: CacheData = { kpis: cacheKpi, rows: cacheRows };
|
||||
|
||||
/**
|
||||
* M8 — ВРЕМЯ column normalizer. `r.time` arrives PRE-FORMATTED as a string from
|
||||
* mapCache (CacheRow.time, produced by fmtCacheTime), one of:
|
||||
* • same-day → "HH:MM:SS" (e.g. "23:20:51")
|
||||
* • yesterday → "вчера HH:MM"
|
||||
* • older → "DD.MM.YYYY"
|
||||
* The raw column mixed second-precision clock times with bare dates, so a lone
|
||||
* "23:20:51" was visually indistinguishable from a date. We normalize every bare
|
||||
* clock time to minute precision (HH:MM) so same-day rows read uniformly with the
|
||||
* "вчера HH:MM" rows, while date rows pass through unchanged. The ISO timestamp is
|
||||
* already discarded upstream (mapper not editable here), so a same-day cell stays
|
||||
* a time by the column's convention: a clock value ⇒ today, a date ⇒ older.
|
||||
* Missing / unparseable → «—». Pure + idempotent on its own output.
|
||||
*/
|
||||
function normTime(raw: string | null | undefined): string {
|
||||
const s = (raw ?? "").trim();
|
||||
if (!s || s === "—") return "—";
|
||||
// Bare clock time "HH:MM:SS" | "HH:MM" → minute precision (drop seconds).
|
||||
const clock = s.match(/^(\d{1,2}):(\d{2})(?::\d{2})?$/);
|
||||
if (clock) return `${clock[1].padStart(2, "0")}:${clock[2]}`;
|
||||
// "вчера HH:MM:SS" → "вчера HH:MM"; dates ("DD.MM.YYYY") have no HH:MM:SS run
|
||||
// and pass through unchanged.
|
||||
return s.replace(/(\d{1,2}:\d{2}):\d{2}\b/, "$1");
|
||||
}
|
||||
|
||||
export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
|
||||
const showSrc = data.rows.some((r) => r.src && r.src !== "—");
|
||||
const gridCols = showSrc ? "2.4fr 1fr 1fr 1fr" : "2.4fr 1fr 1fr";
|
||||
// M7 — header cells, in column order, so every data column has a header.
|
||||
const headers = showSrc
|
||||
? ["АДРЕС", "ВРЕМЯ", "ИСТОЧНИКОВ", "СТАТУС"]
|
||||
: ["АДРЕС", "ВРЕМЯ", "СТАТУС"];
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
|
||||
{/* 3 KPI cards */}
|
||||
|
|
@ -35,7 +64,8 @@ export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
|
|||
style={{
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 1.5,
|
||||
color: tokens.muted2,
|
||||
// C1 — key caption (labels the headline KPI value) → body2.
|
||||
color: tokens.body2,
|
||||
}}
|
||||
>
|
||||
{kpi.label}
|
||||
|
|
@ -84,70 +114,82 @@ export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
|
|||
>
|
||||
Последние оценки
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: gridCols,
|
||||
gap: 10,
|
||||
padding: "9px 18px",
|
||||
fontSize: 9,
|
||||
letterSpacing: 1,
|
||||
color: tokens.muted2,
|
||||
borderBottom: `1px solid ${tokens.lineSoft2}`,
|
||||
}}
|
||||
>
|
||||
<span>АДРЕС</span>
|
||||
<span>ВРЕМЯ</span>
|
||||
{showSrc && <span>ИСТОЧНИКОВ</span>}
|
||||
<span>СТАТУС</span>
|
||||
</div>
|
||||
{data.rows.map((r, i) => (
|
||||
{/* M7 — real table semantics via ARIA roles over the existing grid. */}
|
||||
<div role="table" aria-label="Последние оценки">
|
||||
<div
|
||||
key={`${r.addr}-${i}`}
|
||||
role="row"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: gridCols,
|
||||
gap: 10,
|
||||
padding: "12px 18px",
|
||||
fontSize: 11,
|
||||
borderBottom: `1px solid ${tokens.lineSoft3}`,
|
||||
alignItems: "center",
|
||||
padding: "9px 18px",
|
||||
fontSize: 9,
|
||||
letterSpacing: 1,
|
||||
// C1 — column headers are primary labels → body2.
|
||||
color: tokens.body2,
|
||||
borderBottom: `1px solid ${tokens.lineSoft2}`,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: tokens.body }}>{r.addr}</span>
|
||||
<span
|
||||
style={{ fontFamily: tokens.font.mono, color: tokens.muted2 }}
|
||||
>
|
||||
{r.time}
|
||||
</span>
|
||||
{showSrc && (
|
||||
<span
|
||||
style={{ fontFamily: tokens.font.mono, color: tokens.muted }}
|
||||
>
|
||||
{r.src}
|
||||
{headers.map((h) => (
|
||||
<span key={h} role="columnheader">
|
||||
{h}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
))}
|
||||
</div>
|
||||
{data.rows.map((r, i) => (
|
||||
<div
|
||||
key={`${r.addr}-${i}`}
|
||||
role="row"
|
||||
style={{
|
||||
display: "flex",
|
||||
display: "grid",
|
||||
gridTemplateColumns: gridCols,
|
||||
gap: 10,
|
||||
padding: "12px 18px",
|
||||
fontSize: 11,
|
||||
borderBottom: `1px solid ${tokens.lineSoft3}`,
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 10,
|
||||
color: r.statusColor,
|
||||
}}
|
||||
>
|
||||
<span role="cell" style={{ color: tokens.body }}>
|
||||
{r.addr}
|
||||
</span>
|
||||
<span
|
||||
role="cell"
|
||||
style={{ fontFamily: tokens.font.mono, color: tokens.muted2 }}
|
||||
>
|
||||
{normTime(r.time)}
|
||||
</span>
|
||||
{showSrc && (
|
||||
<span
|
||||
role="cell"
|
||||
style={{ fontFamily: tokens.font.mono, color: tokens.muted }}
|
||||
>
|
||||
{r.src}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
role="cell"
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
background: r.statusColor,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 10,
|
||||
color: r.statusColor,
|
||||
}}
|
||||
/>
|
||||
{r.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
background: r.statusColor,
|
||||
}}
|
||||
/>
|
||||
{r.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,9 +9,12 @@ import type { Report } from "./types";
|
|||
|
||||
interface FooterProps {
|
||||
data?: Report;
|
||||
// M4: hide the «ДЕЙСТВИТЕЛЕН ДО» validity line when there is no real estimate
|
||||
// (mirrors HeroBar). Optional → unwired/fixture usage keeps showing it.
|
||||
hasEstimate?: boolean;
|
||||
}
|
||||
|
||||
export function Footer({ data = report }: FooterProps) {
|
||||
export function Footer({ data = report, hasEstimate }: FooterProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -35,10 +38,12 @@ export function Footer({ data = report }: FooterProps) {
|
|||
<span>
|
||||
ДАТА <span style={{ color: tokens.muted }}>{data.date}</span>
|
||||
</span>
|
||||
<span>
|
||||
ДЕЙСТВИТЕЛЕН ДО{" "}
|
||||
<span style={{ color: tokens.muted }}>{data.validUntil}</span>
|
||||
</span>
|
||||
{hasEstimate !== false && (
|
||||
<span>
|
||||
ДЕЙСТВИТЕЛЕН ДО{" "}
|
||||
<span style={{ color: tokens.muted }}>{data.validUntil}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
@ -107,7 +112,9 @@ export function Footer({ data = report }: FooterProps) {
|
|||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "4px",
|
||||
color: tokens.muted,
|
||||
// C1: primary brand label — body2 (#46586c) reads as the most
|
||||
// prominent footer text, above the secondary id/date metadata.
|
||||
color: tokens.body2,
|
||||
}}
|
||||
>
|
||||
МЕРА
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ function pdfDownloadHref(estimateId: string | null | undefined): string | null {
|
|||
interface HeroBarProps {
|
||||
data?: HeroBarData;
|
||||
estimateId?: string | null;
|
||||
// M4: when false (no real, sufficient estimate yet) the estimate-dependent
|
||||
// meta/controls — the «ДЕЙСТВИТЕЛЕН ДО» validity line and «КАК РАССЧИТАНО» —
|
||||
// are hidden, mirroring how the PDF control already gates on estimate presence.
|
||||
hasEstimate: boolean;
|
||||
onOpenInfo: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -63,6 +67,7 @@ const pdfBtnStyle: CSSProperties = {
|
|||
export default function HeroBar({
|
||||
data = HERO_FIXTURE,
|
||||
estimateId,
|
||||
hasEstimate,
|
||||
onOpenInfo,
|
||||
}: HeroBarProps) {
|
||||
const pdfHref = pdfDownloadHref(estimateId);
|
||||
|
|
@ -159,32 +164,34 @@ export default function HeroBar({
|
|||
{data.report.date}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
borderLeft: `1px solid ${tokens.lineSoft}`,
|
||||
paddingLeft: 26,
|
||||
}}
|
||||
>
|
||||
{hasEstimate && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9,
|
||||
letterSpacing: "2px",
|
||||
color: tokens.muted2,
|
||||
marginBottom: 6,
|
||||
borderLeft: `1px solid ${tokens.lineSoft}`,
|
||||
paddingLeft: 26,
|
||||
}}
|
||||
>
|
||||
ДЕЙСТВИТЕЛЕН ДО
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9,
|
||||
letterSpacing: "2px",
|
||||
color: tokens.muted2,
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
ДЕЙСТВИТЕЛЕН ДО
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: 14,
|
||||
color: tokens.ink,
|
||||
}}
|
||||
>
|
||||
{data.report.validUntil}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: 14,
|
||||
color: tokens.ink,
|
||||
}}
|
||||
>
|
||||
{data.report.validUntil}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 12, width: "100%", maxWidth: 440 }}>
|
||||
|
|
@ -208,30 +215,32 @@ export default function HeroBar({
|
|||
{pdfBtnInner}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="hero-calc-btn"
|
||||
onClick={onOpenInfo}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 50,
|
||||
background: tokens.surface.w40,
|
||||
border: `1px solid ${tokens.line}`,
|
||||
borderRadius: 7,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
fontFamily: tokens.font.sans,
|
||||
color: tokens.muted,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "1.5px",
|
||||
transition: "all .15s",
|
||||
}}
|
||||
>
|
||||
КАК РАССЧИТАНО
|
||||
</button>
|
||||
{hasEstimate && (
|
||||
<button
|
||||
type="button"
|
||||
className="hero-calc-btn"
|
||||
onClick={onOpenInfo}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 50,
|
||||
background: tokens.surface.w40,
|
||||
border: `1px solid ${tokens.line}`,
|
||||
borderRadius: 7,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
fontFamily: tokens.font.sans,
|
||||
color: tokens.muted,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "1.5px",
|
||||
transition: "all .15s",
|
||||
}}
|
||||
>
|
||||
КАК РАССЧИТАНО
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -69,52 +69,66 @@ export default function HistoryView({
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: houseGrid,
|
||||
gap: "12px",
|
||||
padding: "9px 18px",
|
||||
fontSize: "9px",
|
||||
letterSpacing: "1.2px",
|
||||
color: tokens.muted2,
|
||||
borderBottom: `1px solid ${tokens.lineSoft2}`,
|
||||
}}
|
||||
>
|
||||
<span>ЛОТ</span>
|
||||
<span>ЦЕНА НАЧ. → ПОСЛ.</span>
|
||||
<span>ДАТА</span>
|
||||
<span>ЭКСПОЗИЦИЯ</span>
|
||||
</div>
|
||||
|
||||
{data.houseSales.map((row, i) => (
|
||||
<div role="table" aria-label="История продаж в этом доме">
|
||||
<div
|
||||
key={i}
|
||||
role="row"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: houseGrid,
|
||||
gap: "12px",
|
||||
padding: "13px 18px",
|
||||
fontSize: "12px",
|
||||
borderBottom:
|
||||
i < data.houseSales.length - 1
|
||||
? `1px solid ${tokens.lineSoft4}`
|
||||
: undefined,
|
||||
padding: "9px 18px",
|
||||
fontSize: "9px",
|
||||
letterSpacing: "1.2px",
|
||||
// C1: primary column-header labels → body2 (above the muted rows).
|
||||
color: tokens.body2,
|
||||
borderBottom: `1px solid ${tokens.lineSoft2}`,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: tokens.body }}>{row.lot}</span>
|
||||
<span style={{ fontFamily: tokens.font.mono, color: tokens.muted }}>
|
||||
{row.priceFrom} →{" "}
|
||||
<b style={{ color: tokens.ink2 }}>{row.priceTo}</b>
|
||||
</span>
|
||||
<span style={{ fontFamily: tokens.font.mono, color: tokens.muted }}>
|
||||
{row.date}
|
||||
</span>
|
||||
<span style={{ fontFamily: tokens.font.mono, color: tokens.muted }}>
|
||||
{row.exposure}
|
||||
</span>
|
||||
<span role="columnheader">ЛОТ</span>
|
||||
<span role="columnheader">ЦЕНА НАЧ. → ПОСЛ.</span>
|
||||
<span role="columnheader">ДАТА</span>
|
||||
<span role="columnheader">ЭКСПОЗИЦИЯ</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{data.houseSales.map((row, i) => (
|
||||
<div
|
||||
key={i}
|
||||
role="row"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: houseGrid,
|
||||
gap: "12px",
|
||||
padding: "13px 18px",
|
||||
fontSize: "12px",
|
||||
borderBottom:
|
||||
i < data.houseSales.length - 1
|
||||
? `1px solid ${tokens.lineSoft4}`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<span role="cell" style={{ color: tokens.body }}>{row.lot}</span>
|
||||
<span
|
||||
role="cell"
|
||||
style={{ fontFamily: tokens.font.mono, color: tokens.muted }}
|
||||
>
|
||||
{row.priceFrom} →{" "}
|
||||
<b style={{ color: tokens.ink2 }}>{row.priceTo}</b>
|
||||
</span>
|
||||
<span
|
||||
role="cell"
|
||||
style={{ fontFamily: tokens.font.mono, color: tokens.muted }}
|
||||
>
|
||||
{row.date}
|
||||
</span>
|
||||
<span
|
||||
role="cell"
|
||||
style={{ fontFamily: tokens.font.mono, color: tokens.muted }}
|
||||
>
|
||||
{row.exposure}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── ДКП-сделки на улице ──────────────────────────────────── */}
|
||||
|
|
@ -144,7 +158,8 @@ export default function HistoryView({
|
|||
style={{
|
||||
fontSize: "8.5px",
|
||||
letterSpacing: "2px",
|
||||
color: tokens.muted2,
|
||||
// C1: primary section eyebrow → body2 (ObjectSummary pattern).
|
||||
color: tokens.body2,
|
||||
marginBottom: "5px",
|
||||
}}
|
||||
>
|
||||
|
|
@ -205,7 +220,8 @@ export default function HistoryView({
|
|||
style={{
|
||||
fontSize: "9px",
|
||||
letterSpacing: "1.5px",
|
||||
color: tokens.muted2,
|
||||
// C1: primary KPI label → body2 (above the muted value/unit).
|
||||
color: tokens.body2,
|
||||
}}
|
||||
>
|
||||
СДЕЛОК ЗА 24 МЕС
|
||||
|
|
@ -232,7 +248,8 @@ export default function HistoryView({
|
|||
style={{
|
||||
fontSize: "9px",
|
||||
letterSpacing: "1.5px",
|
||||
color: tokens.muted2,
|
||||
// C1: primary KPI label → body2 (above the muted value/unit).
|
||||
color: tokens.body2,
|
||||
}}
|
||||
>
|
||||
МЕДИАНА
|
||||
|
|
@ -257,7 +274,8 @@ export default function HistoryView({
|
|||
style={{
|
||||
fontSize: "9px",
|
||||
letterSpacing: "1.5px",
|
||||
color: tokens.muted2,
|
||||
// C1: primary KPI label → body2 (above the muted value/unit).
|
||||
color: tokens.body2,
|
||||
}}
|
||||
>
|
||||
ДИАПАЗОН
|
||||
|
|
@ -280,86 +298,104 @@ export default function HistoryView({
|
|||
</div>
|
||||
|
||||
{/* Table header */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: dkpGridCols,
|
||||
gap: "10px",
|
||||
padding: "9px 18px",
|
||||
fontSize: "9px",
|
||||
letterSpacing: "1px",
|
||||
color: tokens.muted2,
|
||||
borderBottom: `1px solid ${tokens.lineSoft2}`,
|
||||
}}
|
||||
>
|
||||
<span>АДРЕС</span>
|
||||
<span>ПЛОЩАДЬ</span>
|
||||
<span>ЦЕНА СДЕЛКИ</span>
|
||||
<span>₽/М²</span>
|
||||
<span>ДАТА</span>
|
||||
{showAsk && <span>ОБЪЯВЛЕНИЕ ДО СДЕЛКИ</span>}
|
||||
</div>
|
||||
|
||||
{data.dkpRows.map((r, i) => (
|
||||
<div role="table" aria-label="ДКП-сделки по вашей улице">
|
||||
<div
|
||||
key={i}
|
||||
role="row"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: dkpGridCols,
|
||||
gap: "10px",
|
||||
padding: "11px 18px",
|
||||
fontSize: "11px",
|
||||
borderBottom: `1px solid ${tokens.lineSoft3}`,
|
||||
alignItems: "center",
|
||||
padding: "9px 18px",
|
||||
fontSize: "9px",
|
||||
letterSpacing: "1px",
|
||||
// C1: primary column-header labels → body2 (above the muted rows).
|
||||
color: tokens.body2,
|
||||
borderBottom: `1px solid ${tokens.lineSoft2}`,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: tokens.body }}>{r.addr ?? "—"}</span>
|
||||
<span style={{ fontFamily: tokens.font.mono, color: tokens.muted }}>
|
||||
{r.area}
|
||||
</span>
|
||||
<span style={{ fontFamily: tokens.font.mono, color: tokens.ink2 }}>
|
||||
{r.price}
|
||||
</span>
|
||||
<span style={{ fontFamily: tokens.font.mono, color: tokens.muted }}>
|
||||
{r.ppm}
|
||||
</span>
|
||||
<span
|
||||
style={{ fontFamily: tokens.font.mono, color: tokens.muted2 }}
|
||||
<span role="columnheader">АДРЕС</span>
|
||||
<span role="columnheader">ПЛОЩАДЬ</span>
|
||||
<span role="columnheader">ЦЕНА СДЕЛКИ</span>
|
||||
<span role="columnheader">₽/М²</span>
|
||||
<span role="columnheader">ДАТА</span>
|
||||
{showAsk && <span role="columnheader">ОБЪЯВЛЕНИЕ ДО СДЕЛКИ</span>}
|
||||
</div>
|
||||
|
||||
{data.dkpRows.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
role="row"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: dkpGridCols,
|
||||
gap: "10px",
|
||||
padding: "11px 18px",
|
||||
fontSize: "11px",
|
||||
borderBottom: `1px solid ${tokens.lineSoft3}`,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{r.date ?? "—"}
|
||||
</span>
|
||||
{showAsk && (
|
||||
<span role="cell" style={{ color: tokens.body }}>
|
||||
{r.addr ?? "—"}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "7px",
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: "10px",
|
||||
color: tokens.muted,
|
||||
}}
|
||||
role="cell"
|
||||
style={{ fontFamily: tokens.font.mono, color: tokens.muted }}
|
||||
>
|
||||
{r.area}
|
||||
</span>
|
||||
<span
|
||||
role="cell"
|
||||
style={{ fontFamily: tokens.font.mono, color: tokens.ink2 }}
|
||||
>
|
||||
{r.price}
|
||||
</span>
|
||||
<span
|
||||
role="cell"
|
||||
style={{ fontFamily: tokens.font.mono, color: tokens.muted }}
|
||||
>
|
||||
{r.ppm}
|
||||
</span>
|
||||
<span
|
||||
role="cell"
|
||||
style={{ fontFamily: tokens.font.mono, color: tokens.muted2 }}
|
||||
>
|
||||
{r.date ?? "—"}
|
||||
</span>
|
||||
{showAsk && (
|
||||
<span
|
||||
role="cell"
|
||||
style={{
|
||||
fontSize: "8px",
|
||||
letterSpacing: ".5px",
|
||||
background: tokens.badgeTint,
|
||||
border: `1px solid ${tokens.line2}`,
|
||||
borderRadius: "3px",
|
||||
padding: "1px 5px",
|
||||
// accentDeep (not accent): 8px accent on badgeTint ~2.8:1 < AA;
|
||||
// accentDeep keeps the blue brand cue at ≈5:1.
|
||||
color: tokens.accentDeep,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "7px",
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: "10px",
|
||||
color: tokens.muted,
|
||||
}}
|
||||
>
|
||||
{r.source ?? "—"}
|
||||
<span
|
||||
style={{
|
||||
fontSize: "8px",
|
||||
letterSpacing: ".5px",
|
||||
background: tokens.badgeTint,
|
||||
border: `1px solid ${tokens.line2}`,
|
||||
borderRadius: "3px",
|
||||
padding: "1px 5px",
|
||||
// accentDeep (not accent): 8px accent on badgeTint ~2.8:1 < AA;
|
||||
// accentDeep keeps the blue brand cue at ≈5:1.
|
||||
color: tokens.accentDeep,
|
||||
}}
|
||||
>
|
||||
{r.source ?? "—"}
|
||||
</span>
|
||||
{r.ask}
|
||||
<span style={{ color: r.deltaColor }}>{r.delta}</span>
|
||||
</span>
|
||||
{r.ask}
|
||||
<span style={{ color: r.deltaColor }}>{r.delta}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -8,10 +8,11 @@
|
|||
// backend #2045). It is now an HONEST methodology panel with STATIC, truthful
|
||||
// documentation text only: how the estimate is built, and an explicit note that
|
||||
// the location coefficient does not exist yet. Keeps the same drawer shell /
|
||||
// slide animation / close button. Open/close is driven entirely by props; the
|
||||
// only effect is an Escape-to-close keydown listener while open.
|
||||
// slide animation / close button. Open/close is driven entirely by props; while
|
||||
// open it is a real modal dialog (role=dialog/aria-modal, focus-trap, Esc) with
|
||||
// semantics mirrored from SectionOverlay.
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { tokens } from "./tokens";
|
||||
|
||||
interface LocationDrawerProps {
|
||||
|
|
@ -20,15 +21,76 @@ interface LocationDrawerProps {
|
|||
}
|
||||
|
||||
export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
||||
// Escape-to-close (UI effect only — no HTTP). Active while the drawer is open.
|
||||
// Modal-dialog plumbing — mirrors SectionOverlay. `dialogRef` is the dialog
|
||||
// root, `lastFocused` keeps the trigger so focus can be restored on close, and
|
||||
// `onCloseRef` holds the latest onClose so the effect (keyed only on `open`)
|
||||
// never re-runs / re-traps focus when page.tsx passes a fresh arrow each render.
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const lastFocused = useRef<Element | null>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
// Focus management + Escape/Tab handling while open. Unlike SectionOverlay
|
||||
// (mounted only when open) this drawer stays mounted for the slide animation,
|
||||
// so the effect is keyed on `open`: trap focus on open, restore it on close.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
const dialog = dialogRef.current;
|
||||
// Remember the trigger, then move focus into the dialog.
|
||||
lastFocused.current = document.activeElement;
|
||||
dialog?.focus();
|
||||
|
||||
function getFocusable(): HTMLElement[] {
|
||||
if (!dialog) return [];
|
||||
const nodes = dialog.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
return Array.from(nodes).filter((el) => !el.hasAttribute("disabled"));
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
// Stop the event so a stacked overlay below can't also consume it and so
|
||||
// the browser runs no default action; then close this (topmost) drawer.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab" || !dialog) return;
|
||||
|
||||
const focusable = getFocusable();
|
||||
if (focusable.length === 0) {
|
||||
// Nothing tabbable inside — keep focus pinned on the dialog itself.
|
||||
e.preventDefault();
|
||||
dialog.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const activeEl = document.activeElement;
|
||||
const inside = dialog.contains(activeEl);
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (activeEl === first || activeEl === dialog || !inside) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else if (activeEl === last || !inside) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
// Return focus to whatever opened the drawer.
|
||||
(lastFocused.current as HTMLElement | null)?.focus?.();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
// `onClose` is read via onCloseRef; refs are stable → only `open` is reactive.
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -37,6 +99,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
|||
width: 32px;
|
||||
height: 32px;
|
||||
flex: 0 0 auto;
|
||||
padding: 0;
|
||||
border: 1px solid ${tokens.line};
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
|
|
@ -44,7 +107,9 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
|||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: ${tokens.muted};
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
background: ${tokens.surface.w60};
|
||||
transition: all .15s;
|
||||
}
|
||||
|
|
@ -69,13 +134,18 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
|||
}}
|
||||
/>
|
||||
|
||||
{/* drawer */}
|
||||
{/* drawer — modal dialog (role / focus-trap / inert mirror SectionOverlay) */}
|
||||
<div
|
||||
// Off-canvas when closed → keep it out of the a11y tree so it isn't
|
||||
// flagged as content outside a landmark (axe "region"). No native
|
||||
// focusable elements inside (close is a div), so aria-hidden is safe.
|
||||
// Full dialog semantics (role=dialog/focus-trap/inert) land in P3.
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="ld-drawer-title"
|
||||
tabIndex={-1}
|
||||
// Off-canvas when closed → keep it out of the a11y tree (axe "region")
|
||||
// AND inert, so the now-focusable close button is not a focusable node
|
||||
// inside an aria-hidden subtree (axe "aria-hidden-focus").
|
||||
aria-hidden={!open}
|
||||
inert={!open ? true : undefined}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
|
|
@ -146,12 +216,13 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
|||
style={{
|
||||
fontSize: 9,
|
||||
letterSpacing: "3px",
|
||||
color: tokens.muted2,
|
||||
color: tokens.body2,
|
||||
}}
|
||||
>
|
||||
МЕТОДИКА
|
||||
</div>
|
||||
<div
|
||||
id="ld-drawer-title"
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 300,
|
||||
|
|
@ -163,9 +234,14 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
|||
ПОЯСНЕНИЕ К РАСЧЁТУ
|
||||
</div>
|
||||
</div>
|
||||
<div className="ld-close" onClick={onClose}>
|
||||
<button
|
||||
type="button"
|
||||
className="ld-close"
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
✕
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* divider under header */}
|
||||
|
|
@ -181,7 +257,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
|||
style={{
|
||||
fontSize: 9,
|
||||
letterSpacing: "2px",
|
||||
color: tokens.muted2,
|
||||
color: tokens.body2,
|
||||
marginTop: 22,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
|
|
@ -195,7 +271,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
|||
color: tokens.body2,
|
||||
}}
|
||||
>
|
||||
Агрегируем объявления (Авито, Циан, Яндекс, Домклик) и сделки
|
||||
Агрегируем объявления (Циан, Я.Недвижимость, Авито, N1.ru) и сделки
|
||||
Росреестра по сопоставимым квартирам. Медиана{" "}
|
||||
<b style={{ color: tokens.ink2 }}>₽/м²</b> → 3 оценки:{" "}
|
||||
<b style={{ color: tokens.ink2 }}>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,26 @@ interface ObjectSummaryProps {
|
|||
onNavigate: (i: number) => void;
|
||||
}
|
||||
|
||||
// Audit M11 — name the unnamed «N · XX,XX млн» numbers. Each summary row value
|
||||
// is built in mappers.mapSummary as "count · metric"; the metric is different
|
||||
// per row and the «млн» medians (Аналоги/Сделки) are NOT the headline median
|
||||
// (e.median_price_rub is area-normalised to the subject; these are raw medians
|
||||
// of the analog/deal listing prices), so users couldn't tell what they were.
|
||||
// This caption labels each ·-separated token token-for-token. Keyed by label
|
||||
// content so it matches both the mapper labels ("Продажи рядом") and the older
|
||||
// fixture labels ("Продажи в доме"). Sources in mappers.mapSummary:
|
||||
// Продажи рядом : houseSold · kpi.median_exposure_days
|
||||
// Аналоги : e.n_analogs · median(e.analogs.map(a => a.price_rub))
|
||||
// Сделки : dealCount · streetDeals.median_price_rub ?? median(deals)
|
||||
// Аналитика : kpi.median_exposure_days · -kpi.median_bargain_pct
|
||||
function rowHint(label: string): string {
|
||||
if (label.startsWith("Продажи")) return "кол-во · медиана экспозиции";
|
||||
if (label === "Аналоги") return "кол-во · медиана объявлений";
|
||||
if (label === "Сделки") return "кол-во · медиана сделок";
|
||||
if (label === "Аналитика") return "экспозиция · торг";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function ObjectSummary({
|
||||
data = OBJECT_SUMMARY_FIXTURE,
|
||||
onNavigate,
|
||||
|
|
@ -109,7 +129,7 @@ export function ObjectSummary({
|
|||
style={{
|
||||
fontSize: 9,
|
||||
letterSpacing: 1.2,
|
||||
color: tokens.muted3,
|
||||
color: tokens.body2,
|
||||
marginBottom: 13,
|
||||
}}
|
||||
>
|
||||
|
|
@ -158,7 +178,9 @@ export function ObjectSummary({
|
|||
|
||||
{/* Per-section totals (clickable) */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
|
||||
{data.summary.rows.map((row, i) => (
|
||||
{data.summary.rows
|
||||
.filter((row) => row.value !== "—")
|
||||
.map((row, i, visible) => (
|
||||
<button
|
||||
key={row.label}
|
||||
type="button"
|
||||
|
|
@ -176,7 +198,7 @@ export function ObjectSummary({
|
|||
fontFamily: "inherit",
|
||||
textAlign: "left",
|
||||
borderBottom:
|
||||
i < data.summary.rows.length - 1
|
||||
i < visible.length - 1
|
||||
? `1px dotted ${tokens.lineGradient}`
|
||||
: undefined,
|
||||
}}
|
||||
|
|
@ -206,13 +228,34 @@ export function ObjectSummary({
|
|||
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: 11,
|
||||
color: tokens.ink2,
|
||||
whiteSpace: "nowrap",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-end",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{row.value}
|
||||
<span
|
||||
style={{
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: 11,
|
||||
color: tokens.ink2,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{row.value}
|
||||
</span>
|
||||
{rowHint(row.label) && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 8,
|
||||
letterSpacing: 0.2,
|
||||
color: tokens.muted3,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{rowHint(row.label)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span style={{ color: tokens.accent, fontSize: 11 }}>›</span>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -293,10 +293,13 @@ const styles = `
|
|||
.pp-eval-btn:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(46,139,255,.35)}
|
||||
`;
|
||||
|
||||
// Primary field captions ("АДРЕС", "ПЛОЩАДЬ", "КОМНАТ", …) — the labels the user
|
||||
// reads first. C1 hierarchy: bumped from muted2 to body2 (#46586c, ~6:1) so they
|
||||
// read as primary against the lighter units/hints which stay on muted (optHint).
|
||||
const hintLabel: CSSProperties = {
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 1.5,
|
||||
color: tokens.muted2,
|
||||
color: tokens.body2,
|
||||
marginBottom: 3,
|
||||
};
|
||||
|
||||
|
|
@ -529,6 +532,11 @@ export default function ParamsPanel({
|
|||
initialValues?.address ?? "",
|
||||
);
|
||||
const [suggestOpen, setSuggestOpen] = useState(false);
|
||||
// Address combobox a11y (M6): stable listbox id + roving highlight index for
|
||||
// aria-activedescendant / ArrowDown-ArrowUp keyboard nav over the suggestions.
|
||||
const addressListId = useId();
|
||||
const addressOptId = (i: number) => `${addressListId}-opt-${i}`;
|
||||
const [addressActive, setAddressActive] = useState(-1);
|
||||
const [coords, setCoords] = useState<{ lat: number; lon: number } | null>(
|
||||
initialValues?.lat != null && initialValues?.lon != null
|
||||
? { lat: initialValues.lat, lon: initialValues.lon }
|
||||
|
|
@ -544,6 +552,7 @@ export default function ParamsPanel({
|
|||
setAddress(v);
|
||||
setCoords(null); // a manual edit invalidates a previously picked point
|
||||
setSuggestOpen(v.trim().length >= 3);
|
||||
setAddressActive(-1); // a new query invalidates the previous highlight
|
||||
if (fieldErrors.address)
|
||||
setFieldErrors((prev) => ({ ...prev, address: undefined }));
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
|
@ -555,6 +564,65 @@ export default function ParamsPanel({
|
|||
setAddressQuery(s.full_address);
|
||||
setCoords({ lat: s.lat, lon: s.lon });
|
||||
setSuggestOpen(false);
|
||||
setAddressActive(-1);
|
||||
};
|
||||
|
||||
// Address combobox keyboard (M6). aria-activedescendant pattern: focus STAYS on
|
||||
// the input; arrows move `addressActive` over the live suggestions, Enter on a
|
||||
// highlighted row selects it (else submits the form), Escape closes ONLY the
|
||||
// popup (stopPropagation so it doesn't also dismiss an outer drawer/overlay).
|
||||
// The geocode/suggest fetch + pickSuggestion side-effects are untouched.
|
||||
const handleAddressKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
const list = suggest.data ?? [];
|
||||
const popupOpen = suggestOpen && addressQuery.trim().length >= 3;
|
||||
const listOpen = popupOpen && list.length > 0;
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
if (listOpen) {
|
||||
e.preventDefault();
|
||||
setAddressActive((i) => Math.min(list.length - 1, i + 1));
|
||||
} else if (address.trim().length >= 3) {
|
||||
e.preventDefault();
|
||||
setSuggestOpen(true); // reopen after Escape without retyping
|
||||
}
|
||||
break;
|
||||
case "ArrowUp":
|
||||
if (listOpen) {
|
||||
e.preventDefault();
|
||||
setAddressActive((i) => Math.max(0, i - 1));
|
||||
}
|
||||
break;
|
||||
case "Home":
|
||||
if (listOpen) {
|
||||
e.preventDefault();
|
||||
setAddressActive(0);
|
||||
}
|
||||
break;
|
||||
case "End":
|
||||
if (listOpen) {
|
||||
e.preventDefault();
|
||||
setAddressActive(list.length - 1);
|
||||
}
|
||||
break;
|
||||
case "Enter":
|
||||
if (listOpen && addressActive >= 0 && addressActive < list.length) {
|
||||
e.preventDefault();
|
||||
pickSuggestion(list[addressActive]);
|
||||
} else {
|
||||
handleSubmit();
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
if (popupOpen) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSuggestOpen(false);
|
||||
setAddressActive(-1);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// TODO(#395): CRM-интеграция не подключена — дропдаун присутствует визуально,
|
||||
|
|
@ -675,6 +743,18 @@ export default function ParamsPanel({
|
|||
)
|
||||
: 112;
|
||||
|
||||
// Subject area caption for the map pin (M10) — the SUBJECT's own m², from the
|
||||
// form, so the pin never borrows an analog's area. Empty area → no caption.
|
||||
const areaTrimmed = area.trim();
|
||||
const subjectAreaLabel = areaTrimmed ? `${areaTrimmed} м²` : null;
|
||||
|
||||
// Address combobox render state (M6). `popupOpen` = the dropdown is shown at
|
||||
// all (incl. loading / empty notes); `listboxOpen` = it holds real selectable
|
||||
// options, which is when the input advertises aria-expanded + activedescendant.
|
||||
const addressList = suggest.data ?? [];
|
||||
const addressPopupOpen = suggestOpen && addressQuery.trim().length >= 3;
|
||||
const addressListboxOpen = addressPopupOpen && addressList.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -829,8 +909,21 @@ export default function ParamsPanel({
|
|||
background: tokens.accent,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 11, fontWeight: 600 }}>
|
||||
{address.trim() || "Адрес квартиры"}
|
||||
{/* M10: the SUBJECT pin must show the subject's OWN address + area,
|
||||
never an analog's. The form area (subjectAreaLabel) is rendered
|
||||
here so an analog pin that lands near the centre can no longer be
|
||||
mistaken for the subject's caption. */}
|
||||
<span
|
||||
style={{ display: "flex", flexDirection: "column", gap: 1 }}
|
||||
>
|
||||
<span style={{ fontSize: 11, fontWeight: 600 }}>
|
||||
{address.trim() || "Адрес квартиры"}
|
||||
</span>
|
||||
{subjectAreaLabel && (
|
||||
<span style={{ fontSize: 9, color: tokens.muted }}>
|
||||
{subjectAreaLabel} · оцениваемая квартира
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
|
@ -943,7 +1036,7 @@ export default function ParamsPanel({
|
|||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 10, letterSpacing: 1.5, color: tokens.muted }}>
|
||||
<span style={{ fontSize: 10, letterSpacing: 1.5, color: tokens.body2 }}>
|
||||
РАДИУС АНАЛИЗА
|
||||
</span>
|
||||
{/* РАДИУС — selectable analysis radius (wired: parsed to radius_m on
|
||||
|
|
@ -1089,6 +1182,15 @@ export default function ParamsPanel({
|
|||
className="pp-input"
|
||||
type="text"
|
||||
value={address}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={addressListboxOpen}
|
||||
aria-controls={addressListboxOpen ? addressListId : undefined}
|
||||
aria-activedescendant={
|
||||
addressListboxOpen && addressActive >= 0
|
||||
? addressOptId(addressActive)
|
||||
: undefined
|
||||
}
|
||||
aria-invalid={fieldErrors.address ? true : undefined}
|
||||
aria-describedby={
|
||||
fieldErrors.address ? "pp-address-err" : undefined
|
||||
|
|
@ -1097,11 +1199,11 @@ export default function ParamsPanel({
|
|||
onFocus={() => {
|
||||
if (address.trim().length >= 3) setSuggestOpen(true);
|
||||
}}
|
||||
onBlur={() => setSuggestOpen(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSubmit();
|
||||
if (e.key === "Escape") setSuggestOpen(false);
|
||||
onBlur={() => {
|
||||
setSuggestOpen(false);
|
||||
setAddressActive(-1);
|
||||
}}
|
||||
onKeyDown={handleAddressKeyDown}
|
||||
placeholder="Город, улица, дом"
|
||||
autoComplete="off"
|
||||
style={
|
||||
|
|
@ -1128,34 +1230,51 @@ export default function ParamsPanel({
|
|||
<line x1="0" y1="7.5" x2="3" y2="7.5" stroke="#2e8bff" />
|
||||
<line x1="12" y1="7.5" x2="15" y2="7.5" stroke="#2e8bff" />
|
||||
</svg>
|
||||
{suggestOpen && addressQuery.trim().length >= 3 && (
|
||||
<div style={suggestPanel}>
|
||||
{suggest.data && suggest.data.length > 0 ? (
|
||||
suggest.data.map((s, i) => (
|
||||
{addressPopupOpen &&
|
||||
(addressListboxOpen ? (
|
||||
<div
|
||||
style={suggestPanel}
|
||||
role="listbox"
|
||||
id={addressListId}
|
||||
aria-label="Подсказки адреса"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{addressList.map((s, i) => (
|
||||
<div
|
||||
key={`${s.full_address}-${i}`}
|
||||
className="pp-dd-opt"
|
||||
role="option"
|
||||
id={addressOptId(i)}
|
||||
aria-selected={i === addressActive}
|
||||
// onMouseDown (not onClick) + preventDefault fires before
|
||||
// the input's onBlur, so the pick lands before the list closes.
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
pickSuggestion(s);
|
||||
}}
|
||||
style={suggestOpt}
|
||||
onMouseEnter={() => setAddressActive(i)}
|
||||
style={
|
||||
i === addressActive
|
||||
? { ...suggestOpt, background: "rgba(46,139,255,.1)" }
|
||||
: suggestOpt
|
||||
}
|
||||
>
|
||||
<div style={suggestOptMain}>{s.label}</div>
|
||||
{s.full_address !== s.label && (
|
||||
<div style={suggestOptSub}>{s.full_address}</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : suggest.isFetching ? (
|
||||
<div style={suggestNote}>Поиск…</div>
|
||||
) : (
|
||||
<div style={suggestNote}>Ничего не найдено</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// Loading / empty: a status note, NOT a role="listbox" (it has no
|
||||
// selectable options) so aria-required-children stays satisfied.
|
||||
<div style={suggestPanel} role="status">
|
||||
<div style={suggestNote}>
|
||||
{suggest.isFetching ? "Поиск…" : "Ничего не найдено"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{fieldErrors.address && (
|
||||
<div
|
||||
id="pp-address-err"
|
||||
|
|
|
|||
|
|
@ -14,12 +14,14 @@ import type { ResultPanelData } from "./mappers";
|
|||
const {
|
||||
accent,
|
||||
ink,
|
||||
body2,
|
||||
muted,
|
||||
muted2,
|
||||
muted3,
|
||||
line,
|
||||
line2,
|
||||
line3,
|
||||
lineSoft,
|
||||
bracket,
|
||||
barLo,
|
||||
barMid,
|
||||
|
|
@ -30,6 +32,7 @@ const {
|
|||
disabledBorder,
|
||||
disabledValue,
|
||||
disabledLabel,
|
||||
infoSoftBg,
|
||||
surface,
|
||||
font,
|
||||
} = tokens;
|
||||
|
|
@ -147,16 +150,39 @@ export default function ResultPanel({
|
|||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
{data.cards.map((card, ci) => (
|
||||
{data.cards.map((card, ci) => {
|
||||
// Price hierarchy (audit H2). 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 + 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. All three values stay (honesty); wiring (mapResultPanel) is
|
||||
// untouched — emphasis is presentation only.
|
||||
const isHeadline = ci === 1;
|
||||
const isRegistry = ci === 2;
|
||||
return (
|
||||
<div
|
||||
key={ci}
|
||||
style={{
|
||||
position: "relative",
|
||||
background: surface.w55,
|
||||
background: isHeadline
|
||||
? infoSoftBg
|
||||
: isRegistry
|
||||
? surface.w40
|
||||
: surface.w55,
|
||||
backdropFilter: "blur(6px)",
|
||||
border: `1px solid ${line2}`,
|
||||
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
|
||||
|
|
@ -166,20 +192,33 @@ export default function ResultPanel({
|
|||
top: -1,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderLeft: `1.5px solid ${bracket}`,
|
||||
borderTop: `1.5px solid ${bracket}`,
|
||||
borderLeft: `1.5px solid ${isHeadline ? accent : bracket}`,
|
||||
borderTop: `1.5px solid ${isHeadline ? accent : bracket}`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 1.5,
|
||||
color: muted2,
|
||||
color: isHeadline ? accent : isRegistry ? muted2 : body2,
|
||||
fontWeight: isHeadline ? 600 : undefined,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{lines(card.title)}
|
||||
</div>
|
||||
{isRegistry && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 8,
|
||||
letterSpacing: 1.2,
|
||||
color: muted3,
|
||||
marginTop: 3,
|
||||
}}
|
||||
>
|
||||
СПРАВОЧНО
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
|
|
@ -191,9 +230,10 @@ export default function ResultPanel({
|
|||
<span
|
||||
style={{
|
||||
fontFamily: font.mono,
|
||||
fontSize: 38,
|
||||
fontWeight: 400,
|
||||
fontSize: isHeadline ? 40 : isRegistry ? 28 : 38,
|
||||
fontWeight: isHeadline ? 600 : 400,
|
||||
letterSpacing: -1,
|
||||
color: isHeadline ? accent : isRegistry ? muted : undefined,
|
||||
}}
|
||||
>
|
||||
{card.value}
|
||||
|
|
@ -390,7 +430,8 @@ export default function ResultPanel({
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ranges + radar */}
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ export default function SectionOverlay({
|
|||
>
|
||||
<span style={{ fontSize: 13 }}>←</span>
|
||||
<span
|
||||
style={{ fontSize: 10, letterSpacing: 1.5, color: tokens.muted }}
|
||||
style={{ fontSize: 10, letterSpacing: 1.5, color: tokens.body2 }}
|
||||
>
|
||||
К ОЦЕНКЕ
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,36 @@
|
|||
import type { CSSProperties } from "react";
|
||||
import { Fragment } from "react";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
import { safeUrl } from "@/lib/safeUrl";
|
||||
import { tokens } from "./tokens";
|
||||
import { adRows, dealRows, marketAds, marketDeals } from "./fixtures";
|
||||
import type { SourcesData } from "./mappers";
|
||||
import type { AdRowData, DealRowData, SourcesData } from "./mappers";
|
||||
|
||||
// Overlay 05 — РЫНОК · АНАЛОГИ И СДЕЛКИ.
|
||||
// Faithful markup port from "МЕРА Оценка.dc.html" (lines 492-537).
|
||||
// Card A: объявления в продаже (3 KPI + filter chips + adRows table).
|
||||
// Card B: фактические сделки (3 KPI + dealRows table).
|
||||
// Markup is data-driven via the `data` prop; the design fixtures stay as the
|
||||
// DEFAULT so unwired/storybook usage still renders pixel-identically.
|
||||
// DEFAULT for unwired/storybook usage. (M11 below may hide an all-«—» column,
|
||||
// so the storybook render can differ from the design HTML by a dead column.)
|
||||
//
|
||||
// M7 — the two "tables" are CSS grids of <span>s. They now carry real table
|
||||
// semantics via ARIA roles (table / row / columnheader / cell) layered onto the
|
||||
// existing grid so the visual layout is unchanged (the row <div> keeps display:
|
||||
// grid; the link cell wraps its <a> in a display:contents role="cell" so the
|
||||
// anchor stays the grid item AND keeps its link role).
|
||||
// M11 — columns whose every cell is empty/«—» (e.g. ДИСТ. when no distances are
|
||||
// known) are dropped from BOTH the header and the body (no dead column).
|
||||
|
||||
// One-off tints not present in the v2 palette (kept literal, faithful to design).
|
||||
const KPI_BG = "rgba(238,244,250,.5)";
|
||||
const BADGE_BG = "#eaf1f8";
|
||||
|
||||
const AD_GRID = "1.9fr 1.3fr 1fr 1fr 1.1fr .6fr .9fr";
|
||||
const DEAL_GRID = "2fr 1.3fr 1.2fr 1fr 1.1fr 1fr";
|
||||
// Per-column grid fractions live on the AD_COLS / DEAL_COLS `fr` fields below;
|
||||
// the row's gridTemplateColumns is rebuilt from the VISIBLE columns so a hidden
|
||||
// (all-dash) column also drops its track. Original full templates, for reference:
|
||||
// AD = "1.9fr 1.3fr 1fr 1fr 1.1fr .6fr .9fr"
|
||||
// DEAL = "2fr 1.3fr 1.2fr 1fr 1.1fr 1fr"
|
||||
|
||||
const card: CSSProperties = {
|
||||
background: tokens.surface.w55,
|
||||
|
|
@ -64,7 +77,8 @@ const kpiRow: CSSProperties = {
|
|||
const kpiLabel: CSSProperties = {
|
||||
fontSize: "9px",
|
||||
letterSpacing: "1.5px",
|
||||
color: tokens.muted2,
|
||||
// C1 — key caption (labels the headline KPI value) → body2.
|
||||
color: tokens.body2,
|
||||
};
|
||||
|
||||
const tableHead: CSSProperties = {
|
||||
|
|
@ -72,7 +86,8 @@ const tableHead: CSSProperties = {
|
|||
padding: "9px 18px",
|
||||
fontSize: "9px",
|
||||
letterSpacing: "1px",
|
||||
color: tokens.muted2,
|
||||
// C1 — column headers are primary labels → body2.
|
||||
color: tokens.body2,
|
||||
borderBottom: `1px solid ${tokens.lineSoft2}`,
|
||||
};
|
||||
|
||||
|
|
@ -135,6 +150,214 @@ const linkBtn: CSSProperties = {
|
|||
textDecoration: "none",
|
||||
};
|
||||
|
||||
// ── Table column model (M7 semantics + M11 hide-all-dash) ───────────────────
|
||||
// Each column owns its header cell, grid fraction, body-cell renderer and an
|
||||
// `empty` test. A column is hidden when EVERY row's cell is empty/«—».
|
||||
|
||||
interface Col<R> {
|
||||
key: string;
|
||||
/** Grid fraction in gridTemplateColumns. */
|
||||
fr: string;
|
||||
/** Header cell (role="columnheader"); aria-label for a visually-empty header. */
|
||||
head: ReactNode;
|
||||
/** Body cell — returns the full grid-item element (carries role="cell"). */
|
||||
cell: (row: R, index: number) => ReactNode;
|
||||
/** True when this row contributes no value to the column (for hide-all-dash). */
|
||||
empty: (row: R) => boolean;
|
||||
}
|
||||
|
||||
/** Empty / placeholder cell value (null, blank, or a bare em-dash). */
|
||||
function isDash(v: unknown): boolean {
|
||||
return (
|
||||
v == null ||
|
||||
(typeof v === "string" && (v.trim() === "" || v.trim() === "—"))
|
||||
);
|
||||
}
|
||||
|
||||
/** Drop any column whose every cell is empty/«—»; keep all columns if no rows. */
|
||||
function visibleCols<R>(cols: Col<R>[], rows: R[]): Col<R>[] {
|
||||
if (rows.length === 0) return cols;
|
||||
return cols.filter((c) => rows.some((r) => !c.empty(r)));
|
||||
}
|
||||
|
||||
const AD_COLS: Col<AdRowData>[] = [
|
||||
{
|
||||
key: "addr",
|
||||
fr: "1.9fr",
|
||||
head: <span role="columnheader">АДРЕС</span>,
|
||||
cell: (r) => (
|
||||
<span role="cell" style={{ color: tokens.body }}>
|
||||
{r.addr}
|
||||
</span>
|
||||
),
|
||||
empty: (r) => isDash(r.addr),
|
||||
},
|
||||
{
|
||||
key: "meta",
|
||||
fr: "1.3fr",
|
||||
head: <span role="columnheader">ПАРАМЕТРЫ</span>,
|
||||
cell: (r) => (
|
||||
<span role="cell" style={metaSpan}>
|
||||
{r.meta}
|
||||
</span>
|
||||
),
|
||||
empty: (r) => isDash(r.meta),
|
||||
},
|
||||
{
|
||||
key: "source",
|
||||
fr: "1fr",
|
||||
head: <span role="columnheader">ИСТОЧНИК</span>,
|
||||
cell: (r) => (
|
||||
<span role="cell">
|
||||
<span style={badge}>{r.source ?? "Avito"}</span>
|
||||
</span>
|
||||
),
|
||||
empty: () => false,
|
||||
},
|
||||
{
|
||||
key: "ppm",
|
||||
fr: "1fr",
|
||||
head: <span role="columnheader">₽/М²</span>,
|
||||
cell: (r) => (
|
||||
<span role="cell" style={monoMuted}>
|
||||
{r.ppm}
|
||||
</span>
|
||||
),
|
||||
empty: (r) => isDash(r.ppm),
|
||||
},
|
||||
{
|
||||
key: "price",
|
||||
fr: "1.1fr",
|
||||
head: <span role="columnheader">СТОИМОСТЬ</span>,
|
||||
cell: (r) => (
|
||||
<span role="cell" style={monoInk}>
|
||||
{r.price}
|
||||
</span>
|
||||
),
|
||||
empty: (r) => isDash(r.price),
|
||||
},
|
||||
{
|
||||
key: "dist",
|
||||
fr: ".6fr",
|
||||
head: <span role="columnheader">ДИСТ.</span>,
|
||||
cell: (r) => (
|
||||
<span role="cell" style={monoMuted2}>
|
||||
{r.dist ?? "—"}
|
||||
</span>
|
||||
),
|
||||
empty: (r) => isDash(r.dist),
|
||||
},
|
||||
{
|
||||
key: "link",
|
||||
fr: ".9fr",
|
||||
// M11 — visually-empty header in the design, labelled for screen readers.
|
||||
head: <span role="columnheader" aria-label="Ссылка на оригинал" />,
|
||||
// role="cell" sits on a display:contents wrapper so the <a> stays the grid
|
||||
// item (identical layout) AND keeps its link role inside the cell.
|
||||
cell: (r) => {
|
||||
// safeUrl gates href against javascript:/data: schemes (XSS). url is
|
||||
// undefined for the design fixtures (→ "Росреестр ↗" fallback, keeps the
|
||||
// storybook markup pixel-identical), null for a real row with no link
|
||||
// (→ "—"), and a string when a real listing URL is available.
|
||||
const href = typeof r.url === "string" ? safeUrl(r.url) : null;
|
||||
return (
|
||||
<span role="cell" style={{ display: "contents" }}>
|
||||
{href ? (
|
||||
<a
|
||||
className="sv-rr"
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={linkBtn}
|
||||
>
|
||||
Источник ↗
|
||||
</a>
|
||||
) : r.url === null ? (
|
||||
<span style={monoMuted2}>—</span>
|
||||
) : (
|
||||
<span className="sv-rr" style={linkBtn}>
|
||||
Росреестр ↗
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
empty: () => false,
|
||||
},
|
||||
];
|
||||
|
||||
const DEAL_COLS: Col<DealRowData>[] = [
|
||||
{
|
||||
key: "addr",
|
||||
fr: "2fr",
|
||||
head: <span role="columnheader">АДРЕС</span>,
|
||||
cell: (r) => (
|
||||
<span role="cell" style={{ color: tokens.body }}>
|
||||
{r.addr}
|
||||
</span>
|
||||
),
|
||||
empty: (r) => isDash(r.addr),
|
||||
},
|
||||
{
|
||||
key: "meta",
|
||||
fr: "1.3fr",
|
||||
head: <span role="columnheader">ПАРАМЕТРЫ</span>,
|
||||
cell: (r) => (
|
||||
<span role="cell" style={metaSpan}>
|
||||
{r.meta}
|
||||
</span>
|
||||
),
|
||||
empty: (r) => isDash(r.meta),
|
||||
},
|
||||
{
|
||||
key: "source",
|
||||
fr: "1.2fr",
|
||||
head: <span role="columnheader">ИСТОЧНИК</span>,
|
||||
cell: (r) => (
|
||||
<span role="cell">
|
||||
<span style={badge}>{r.source ?? "Росреестр"}</span>{" "}
|
||||
<span style={{ fontSize: "9px", color: tokens.muted2 }}>
|
||||
{r.tier ?? "по улице"}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
empty: () => false,
|
||||
},
|
||||
{
|
||||
key: "ppm",
|
||||
fr: "1fr",
|
||||
head: <span role="columnheader">₽/М²</span>,
|
||||
cell: (r) => (
|
||||
<span role="cell" style={monoMuted}>
|
||||
{r.ppm}
|
||||
</span>
|
||||
),
|
||||
empty: (r) => isDash(r.ppm),
|
||||
},
|
||||
{
|
||||
key: "price",
|
||||
fr: "1.1fr",
|
||||
head: <span role="columnheader">ЦЕНА СДЕЛКИ</span>,
|
||||
cell: (r) => (
|
||||
<span role="cell" style={monoInk}>
|
||||
{r.price}
|
||||
</span>
|
||||
),
|
||||
empty: (r) => isDash(r.price),
|
||||
},
|
||||
{
|
||||
key: "date",
|
||||
fr: "1fr",
|
||||
head: <span role="columnheader">ДАТА</span>,
|
||||
cell: (r) => (
|
||||
<span role="cell" style={monoMuted2}>
|
||||
{r.date ?? "—"}
|
||||
</span>
|
||||
),
|
||||
empty: (r) => isDash(r.date),
|
||||
},
|
||||
];
|
||||
|
||||
function KpiCell({
|
||||
label,
|
||||
value,
|
||||
|
|
@ -174,6 +397,44 @@ function KpiCell({
|
|||
);
|
||||
}
|
||||
|
||||
/** Header row + body rows wrapped in role="table"; columns are pre-filtered. */
|
||||
function DataTable<R>({
|
||||
label,
|
||||
cols,
|
||||
rows,
|
||||
grid,
|
||||
}: {
|
||||
label: string;
|
||||
cols: Col<R>[];
|
||||
rows: R[];
|
||||
grid: string;
|
||||
}) {
|
||||
return (
|
||||
<div role="table" aria-label={label}>
|
||||
<div
|
||||
role="row"
|
||||
style={{ ...tableHead, display: "grid", gridTemplateColumns: grid }}
|
||||
>
|
||||
{cols.map((c) => (
|
||||
<Fragment key={c.key}>{c.head}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
{rows.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
role="row"
|
||||
className="sv-row"
|
||||
style={{ ...tableRow, display: "grid", gridTemplateColumns: grid }}
|
||||
>
|
||||
{cols.map((c) => (
|
||||
<Fragment key={c.key}>{c.cell(r, i)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default presentation data (unwired/storybook usage): the existing design
|
||||
// fixtures. AdRow[]/DealRow[] satisfy AdRowData[]/DealRowData[] (extras optional).
|
||||
const SOURCES_FIXTURE: SourcesData = {
|
||||
|
|
@ -190,6 +451,11 @@ interface SourcesViewProps {
|
|||
export default function SourcesView({
|
||||
data = SOURCES_FIXTURE,
|
||||
}: SourcesViewProps) {
|
||||
const adCols = visibleCols(AD_COLS, data.adRows);
|
||||
const adGrid = adCols.map((c) => c.fr).join(" ");
|
||||
const dealCols = visibleCols(DEAL_COLS, data.dealRows);
|
||||
const dealGrid = dealCols.map((c) => c.fr).join(" ");
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
|
||||
<style>{`
|
||||
|
|
@ -260,66 +526,12 @@ export default function SourcesView({
|
|||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
...tableHead,
|
||||
display: "grid",
|
||||
gridTemplateColumns: AD_GRID,
|
||||
}}
|
||||
>
|
||||
<span>АДРЕС</span>
|
||||
<span>ПАРАМЕТРЫ</span>
|
||||
<span>ИСТОЧНИК</span>
|
||||
<span>₽/М²</span>
|
||||
<span>СТОИМОСТЬ</span>
|
||||
<span>ДИСТ.</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{data.adRows.map((r, i) => {
|
||||
// safeUrl gates href against javascript:/data: schemes (XSS). url is
|
||||
// undefined for the design fixtures (→ "Росреестр ↗" fallback, keeps
|
||||
// the storybook markup pixel-identical), null for a real row with no
|
||||
// link (→ "—"), and a string when a real listing URL is available.
|
||||
const href = typeof r.url === "string" ? safeUrl(r.url) : null;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="sv-row"
|
||||
style={{
|
||||
...tableRow,
|
||||
display: "grid",
|
||||
gridTemplateColumns: AD_GRID,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: tokens.body }}>{r.addr}</span>
|
||||
<span style={metaSpan}>{r.meta}</span>
|
||||
<span>
|
||||
<span style={badge}>{r.source ?? "Avito"}</span>
|
||||
</span>
|
||||
<span style={monoMuted}>{r.ppm}</span>
|
||||
<span style={monoInk}>{r.price}</span>
|
||||
<span style={monoMuted2}>{r.dist ?? "—"}</span>
|
||||
{href ? (
|
||||
<a
|
||||
className="sv-rr"
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={linkBtn}
|
||||
>
|
||||
Источник ↗
|
||||
</a>
|
||||
) : r.url === null ? (
|
||||
<span style={monoMuted2}>—</span>
|
||||
) : (
|
||||
<span className="sv-rr" style={linkBtn}>
|
||||
Росреестр ↗
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<DataTable
|
||||
label="Объявления — аналогичные квартиры в продаже"
|
||||
cols={adCols}
|
||||
rows={data.adRows}
|
||||
grid={adGrid}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* фактические сделки */}
|
||||
|
|
@ -359,44 +571,12 @@ export default function SourcesView({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
...tableHead,
|
||||
display: "grid",
|
||||
gridTemplateColumns: DEAL_GRID,
|
||||
}}
|
||||
>
|
||||
<span>АДРЕС</span>
|
||||
<span>ПАРАМЕТРЫ</span>
|
||||
<span>ИСТОЧНИК</span>
|
||||
<span>₽/М²</span>
|
||||
<span>ЦЕНА СДЕЛКИ</span>
|
||||
<span>ДАТА</span>
|
||||
</div>
|
||||
|
||||
{data.dealRows.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="sv-row"
|
||||
style={{
|
||||
...tableRow,
|
||||
display: "grid",
|
||||
gridTemplateColumns: DEAL_GRID,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: tokens.body }}>{r.addr}</span>
|
||||
<span style={metaSpan}>{r.meta}</span>
|
||||
<span>
|
||||
<span style={badge}>{r.source ?? "Росреестр"}</span>{" "}
|
||||
<span style={{ fontSize: "9px", color: tokens.muted2 }}>
|
||||
{r.tier ?? "по улице"}
|
||||
</span>
|
||||
</span>
|
||||
<span style={monoMuted}>{r.ppm}</span>
|
||||
<span style={monoInk}>{r.price}</span>
|
||||
<span style={monoMuted2}>{r.date ?? "—"}</span>
|
||||
</div>
|
||||
))}
|
||||
<DataTable
|
||||
label="Фактические сделки по аналогичным квартирам"
|
||||
cols={dealCols}
|
||||
rows={data.dealRows}
|
||||
grid={dealGrid}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -375,12 +375,12 @@ export default function TopNav({
|
|||
cx="7.5"
|
||||
cy="5"
|
||||
r="3"
|
||||
stroke="#6f8195"
|
||||
stroke={tokens.muted}
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
<path
|
||||
d="M2 13c0-3 2.5-4.5 5.5-4.5S13 10 13 13"
|
||||
stroke="#6f8195"
|
||||
stroke={tokens.muted}
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
</svg>
|
||||
|
|
@ -391,10 +391,10 @@ export default function TopNav({
|
|||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
|
||||
<path
|
||||
d="M3 1.5h6l3 3v9H3z"
|
||||
stroke="#6f8195"
|
||||
stroke={tokens.muted}
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
<path d="M9 1.5v3h3" stroke="#6f8195" strokeWidth="1.2" />
|
||||
<path d="M9 1.5v3h3" stroke={tokens.muted} strokeWidth="1.2" />
|
||||
</svg>
|
||||
Мои отчёты{" "}
|
||||
<span
|
||||
|
|
@ -415,12 +415,12 @@ export default function TopNav({
|
|||
cx="7.5"
|
||||
cy="7.5"
|
||||
r="2.3"
|
||||
stroke="#6f8195"
|
||||
stroke={tokens.muted}
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
<path
|
||||
d="M7.5 1v2M7.5 12v2M1 7.5h2M12 7.5h2"
|
||||
stroke="#6f8195"
|
||||
stroke={tokens.muted}
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
</svg>
|
||||
|
|
@ -433,12 +433,12 @@ export default function TopNav({
|
|||
cx="7.5"
|
||||
cy="7.5"
|
||||
r="6"
|
||||
stroke="#6f8195"
|
||||
stroke={tokens.muted}
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
<path
|
||||
d="M5.8 5.8a1.7 1.7 0 1 1 2.4 2.1c-.5.4-.7.7-.7 1.3M7.5 11.2v.2"
|
||||
stroke="#6f8195"
|
||||
stroke={tokens.muted}
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -444,7 +444,15 @@ function parseAddress(full: string | null): { address: string; city: string } {
|
|||
.find((p) => HOUSE_RE.test(p));
|
||||
address = house ? `${street}, ${house}` : street;
|
||||
} else {
|
||||
address = parts[0];
|
||||
// No street keyword (prefix-less address, e.g. "Белинского, 86"). Don't
|
||||
// silently drop the house number: pair a house token with its preceding
|
||||
// street name; otherwise keep the full comma-joined string rather than
|
||||
// only parts[0] (which would lose the house).
|
||||
const houseIdx = parts.findIndex((p) => HOUSE_RE.test(houseClean(p)));
|
||||
address =
|
||||
houseIdx > 0
|
||||
? `${parts[houseIdx - 1]}, ${houseClean(parts[houseIdx])}`
|
||||
: parts.join(", ");
|
||||
}
|
||||
return { address, city };
|
||||
}
|
||||
|
|
@ -743,6 +751,11 @@ const MAP_CLAMP_HI = 96;
|
|||
// don't stack into an unreadable blob on the small map.
|
||||
const MIN_PIN_DIST = 22;
|
||||
const KEEP_CAP = 3;
|
||||
// M10: the subject projects to the map centre (lat==tLat, lon==tLon → 50%,50%),
|
||||
// where ParamsPanel draws the subject pin. Reserve that spot so no analog price
|
||||
// pin stacks under the subject's address caption (which made the subject look
|
||||
// labelled with an analog's area/price).
|
||||
const SUBJECT_POS = { left: 50, top: 50 };
|
||||
|
||||
/** Analog price pins for the 01 ParamsPanel map. null/empty/un-geocoded → []. */
|
||||
export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] {
|
||||
|
|
@ -790,6 +803,11 @@ export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] {
|
|||
MAP_CLAMP_LO,
|
||||
MAP_CLAMP_HI,
|
||||
);
|
||||
// Drop analogs that land on the subject pin (centre) — otherwise the closest
|
||||
// analog's "21,2 млн / 80 м²" reads as the subject's caption (M10).
|
||||
const onSubject =
|
||||
Math.hypot(SUBJECT_POS.left - left, SUBJECT_POS.top - top) < MIN_PIN_DIST;
|
||||
if (onSubject) continue;
|
||||
const overlaps = kept.some(
|
||||
(k) => Math.hypot(k.left - left, k.top - top) < MIN_PIN_DIST,
|
||||
);
|
||||
|
|
@ -1663,8 +1681,25 @@ function buildAdFilters(e: AggregatedEstimate | null): string[] {
|
|||
: String(Math.ceil(maxKm));
|
||||
filters.push(`Расстояние ≤ ${km}${NBSP}км`);
|
||||
}
|
||||
if (e.rooms != null) {
|
||||
filters.push(`Планировка ${e.rooms <= 0 ? "студия" : `${e.rooms}-к`}`);
|
||||
// Planning chip — derived from the rooms ACTUALLY present in the rendered
|
||||
// analog set (same source as the distance/area chips), NOT from the subject's
|
||||
// e.rooms. Analogs are selected by area band + geo proximity, NOT by room
|
||||
// count, so the set may span several planning types. Only claim a concrete
|
||||
// "N-к" filter when the analogs are genuinely homogeneous; otherwise stay
|
||||
// neutral ("все планировки") so the chip never asserts a filter that was not
|
||||
// applied to the data.
|
||||
const analogRooms = Array.from(
|
||||
new Set(
|
||||
e.analogs
|
||||
.map((a) => a.rooms)
|
||||
.filter((r): r is number => r != null && Number.isFinite(r)),
|
||||
),
|
||||
);
|
||||
if (analogRooms.length === 1) {
|
||||
const r = analogRooms[0];
|
||||
filters.push(`Планировка ${r <= 0 ? "студия" : `${r}-к`}`);
|
||||
} else if (analogRooms.length > 1) {
|
||||
filters.push("Все планировки");
|
||||
}
|
||||
const areas = e.analogs
|
||||
.map((a) => a.area_m2)
|
||||
|
|
|
|||
|
|
@ -23,12 +23,19 @@ export const tokens = {
|
|||
body: "#39506a",
|
||||
body2: "#46586c",
|
||||
|
||||
// Muted text (light → lighter)
|
||||
muted: "#6f8195",
|
||||
muted2: "#91a1b4",
|
||||
muted3: "#9aaabb",
|
||||
muted4: "#b3c0cf",
|
||||
hint: "#8a9aac", // small helper / caption text
|
||||
// Muted text (more prominent → quieter). Darkened to clear WCAG AA (≥4.5:1)
|
||||
// against the worst-case (darkest) canvas stop #e6eef7 — the artboard bg is a
|
||||
// light gradient #f4f8fc→#e6eef7 plus a faint blueprint grid, so micro-labels
|
||||
// (9–11px, further shrunk ~0.75× by scale-fit) were failing AA badly
|
||||
// (audit #2081 C1: muted2 2.5:1, muted4 1.7:1). Ratios below are vs #e6eef7;
|
||||
// they are higher over glass surfaces and the lighter gradient stops. Primary
|
||||
// labels should use body2/ink2 (6+:1) — these muted stops are for genuinely
|
||||
// secondary / hint text only. HSV hue+saturation preserved from the originals.
|
||||
muted: "#586777", // 4.95:1 — most prominent muted (labels, ₽/м², «КАК РАССЧИТАНО»)
|
||||
muted2: "#5f6a76", // 4.71:1 — card titles / form field captions
|
||||
muted3: "#626c77", // 4.56:1 — eyebrows («ИТОГИ ПО РАЗДЕЛАМ»), «нет данных»
|
||||
muted4: "#656c75", // 4.54:1 — true hints only («опц.»/«скоро»/version)
|
||||
hint: "#5f6b77", // 4.65:1 — small helper / caption text
|
||||
|
||||
// Borders & hairlines (strong → soft)
|
||||
line: "#c2d3e3",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue