gendesign/tradein-mvp/frontend/src/components/trade-in/v2/LocationDrawer.tsx
bot-backend ec7fd9c02c fix(tradein/ui): фронт под location-index — убрать обещание влияния на цену
Старый интерфейс рисовал в панели «КАК РАССЧИТАНО» пару «база -> результат»,
из чего клиент делал вывод, что локация подвинула его цену. Она её не двигала:
estimator.py про location_coef не знает вовсе. Пара удалена.

Подпись «КОЭФ. ЛОКАЦИИ» заменена на «ЛОКАЦИЯ» — слово «коэффициент»
подразумевает множитель. В тултипе прямо сказано: «Сравнение медианы руб/м2
района и города. На итоговую оценку не влияет.»

Три состояния недоступности теперь различимы вместо одного прочерка:
«вне ЕКБ» (индекс считаем только по Екатеринбургу), «мало данных»
(сопоставимых объявлений меньше порога) и загрузка. В панели каждое состояние
объяснено человеческим текстом, а при status=ok показано, по скольким
объявлениям и в каком радиусе посчитано.

Список «что рядом» сохранён как качественная справка, weight из ответа убран
(был внутренней ранжирующей величиной, клиенту не значил ничего).
2026-07-26 23:32:03 +03:00

503 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
// "ПОЯСНЕНИЕ К РАСЧЁТУ" right-side drawer for the /trade-in/v2 "МЕРА Оценка"
// design port. Opened from HeroBar (the "?" near "ЛОКАЦИЯ"). It used to render
// a FABRICATED location coefficient (0.87), a fake "base × coef = result"
// formula and invented POI factor lists with a false "Источник: OpenStreetMap"
// footer. That location-coef metric was replaced outright (see
// backend/app/services/location_index.py for the full audit): ±5% range,
// uncorrelated with real prices, never actually fed the estimate. This drawer
// now renders GET /trade-in/location-index (mapLocation, ./mappers.ts): a real
// % deviation of the local median ₽/м² from the citywide median — framed as a
// comparison metric, explicitly NOT a price adjustment — plus the real
// nearest-POI list. The three degraded states (loading / out_of_coverage /
// insufficient_data) each get their own honest explanation instead of one
// blank "недоступно". Keeps the same drawer shell / 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, useRef } from "react";
import { tokens } from "./tokens";
import { pluralRu } from "./mappers";
import type { LocationData } from "./mappers";
// Default presentation data (unwired usage / not fetched yet): the honest
// loading state, never a fabricated coefficient.
const LOCATION_FIXTURE: LocationData = {
status: "loading",
indexLabel: "—",
localMedianLabel: "—",
cityMedianLabel: "—",
sampleSize: 0,
radiusLabel: "—",
poiAvailable: false,
factors: [],
};
// data.indexLabel is already the signed, rounded fmtPct string produced by
// mapLocation ("+12%" / "8%" / "0%" / "—") — reusing it here (rather than a
// second raw-number field) keeps the sign/rounding logic in one place
// (./mappers.ts). Turns it into a plain-language comparison sentence instead
// of a bare percent, so it reads as "vs. the city", never as a price change.
function locationDirectionSentence(indexLabel: string): string {
if (indexLabel.startsWith("")) {
return `Район дешевле города на ${indexLabel.slice(1)}`;
}
if (indexLabel.startsWith("+")) {
return `Район дороже города на ${indexLabel.slice(1)}`;
}
if (indexLabel === "0%") return "Район на уровне медианы по городу";
return "—";
}
// «Что рядом» — qualitative POI list, independent of the numeric index
// (poi_status degrades separately from status, see mapLocation/./mappers.ts).
function PoiSection({ data }: { data: LocationData }) {
if (!data.poiAvailable) {
return (
<div style={{ marginTop: 12, fontSize: 11.5, color: tokens.muted3 }}>
Данные о ближайшей инфраструктуре сейчас недоступны.
</div>
);
}
return (
<>
<div
style={{
marginTop: 12,
marginBottom: 6,
fontSize: 10,
letterSpacing: "1px",
color: tokens.muted2,
}}
>
ЧТО РЯДОМ
</div>
{data.factors.length > 0 ? (
<ul
style={{
listStyle: "none",
margin: 0,
padding: 0,
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
{data.factors.map((f, i) => (
<li
key={i}
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 10,
fontSize: 11.5,
}}
>
<span style={{ color: tokens.ink2 }}>
{f.label}
{f.category !== f.label && (
<span style={{ color: tokens.muted3 }}> · {f.category}</span>
)}
</span>
<span
style={{
flex: "0 0 auto",
color: tokens.muted,
fontFamily: tokens.font.mono,
}}
>
{f.distance}
</span>
</li>
))}
</ul>
) : (
<div style={{ fontSize: 11.5, color: tokens.muted3 }}>
Объектов инфраструктуры в радиусе поиска не найдено.
</div>
)}
</>
);
}
interface LocationDrawerProps {
open: boolean;
onClose: () => void;
data?: LocationData;
}
export function LocationDrawer({
open,
onClose,
data = LOCATION_FIXTURE,
}: LocationDrawerProps) {
// 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 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?.();
};
// `onClose` is read via onCloseRef; refs are stable → only `open` is reactive.
}, [open]);
return (
<>
<style>{`
.ld-close {
width: 32px;
height: 32px;
flex: 0 0 auto;
padding: 0;
border: 1px solid ${tokens.line};
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: ${tokens.muted};
font-family: inherit;
font-size: 16px;
line-height: 1;
background: ${tokens.surface.w60};
transition: all .15s;
}
.ld-close:hover {
border-color: ${tokens.accent};
color: ${tokens.accent};
}
`}</style>
{/* scrim */}
<div
onClick={onClose}
style={{
position: "absolute",
inset: 0,
background: "rgba(22,42,70,.32)",
backdropFilter: "blur(2px)",
opacity: open ? 1 : 0,
pointerEvents: open ? "auto" : "none",
transition: "opacity .3s",
zIndex: 40,
}}
/>
{/* drawer — modal dialog (role / focus-trap / inert mirror SectionOverlay) */}
<div
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,
right: 0,
bottom: 0,
width: 452,
transform: open ? "translateX(0)" : "translateX(108%)",
transition: "transform .38s cubic-bezier(.22,.61,.36,1)",
zIndex: 41,
background:
"linear-gradient(180deg,rgba(245,249,253,.97),rgba(235,242,250,.97))",
backdropFilter: "blur(10px)",
borderLeft: `1px solid ${tokens.line3}`,
boxShadow: "-18px 0 50px rgba(40,80,130,.16)",
display: "flex",
flexDirection: "column",
padding: "30px 32px",
overflowY: "auto",
fontFamily: tokens.font.sans,
}}
>
{/* left accent rail */}
<div
style={{
position: "absolute",
left: 0,
top: 18,
bottom: 18,
width: 1,
background: `linear-gradient(180deg,transparent,${tokens.accent},transparent)`,
opacity: 0.5,
}}
/>
{/* corner brackets */}
<div
style={{
position: "absolute",
left: 14,
top: 14,
width: 16,
height: 16,
borderLeft: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`,
}}
/>
<div
style={{
position: "absolute",
right: 14,
bottom: 14,
width: 16,
height: 16,
borderRight: `1.5px solid ${tokens.accent}`,
borderBottom: `1.5px solid ${tokens.accent}`,
}}
/>
{/* header */}
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
}}
>
<div>
<div
style={{
fontSize: 9,
letterSpacing: "3px",
color: tokens.body2,
}}
>
МЕТОДИКА
</div>
<div
id="ld-drawer-title"
style={{
fontSize: 22,
fontWeight: 300,
letterSpacing: "1.5px",
color: tokens.ink2,
marginTop: 8,
}}
>
ПОЯСНЕНИЕ К РАСЧЁТУ
</div>
</div>
<button
type="button"
className="ld-close"
onClick={onClose}
aria-label="Закрыть"
>
</button>
</div>
{/* divider under header */}
<div
style={{
marginTop: 22,
borderBottom: `1px solid ${tokens.lineSoft}`,
}}
/>
{/* methodology section */}
<div
style={{
fontSize: 9,
letterSpacing: "2px",
color: tokens.body2,
marginTop: 22,
marginBottom: 12,
}}
>
КАК СЧИТАЕМ
</div>
<div
style={{
fontSize: 12.5,
lineHeight: 1.7,
color: tokens.body2,
}}
>
Агрегируем объявления (Циан, Я.Недвижимость, Авито, Домклик) и сделки
Росреестра по сопоставимым квартирам. Медиана{" "}
<b style={{ color: tokens.ink2 }}>/м²</b> 3 оценки:{" "}
<b style={{ color: tokens.ink2 }}>
рекомендованная цена в объявлении
</b>
, <b style={{ color: tokens.ink2 }}>ожидаемая цена сделки</b> (с
учётом торга по ДКП),{" "}
<b style={{ color: tokens.ink2 }}>ДКП·Росреестр</b> (фактические
сделки). CV коэффициент вариации выборки (разброс цен).
</div>
{/* location section */}
<div
style={{
fontSize: 9,
letterSpacing: "2px",
color: tokens.body2,
marginTop: 26,
marginBottom: 12,
}}
>
ЛОКАЦИЯ
</div>
<div
style={{
fontSize: 12.5,
lineHeight: 1.7,
color: tokens.body2,
background: tokens.infoSoftBg,
border: `1px solid ${tokens.infoSoftBorder}`,
borderRadius: 7,
padding: "12px 14px",
}}
>
{data.status === "ok" && (
<>
<div
style={{ fontSize: 13, fontWeight: 500, color: tokens.ink2 }}
>
{locationDirectionSentence(data.indexLabel)}
</div>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
marginTop: 10,
}}
>
<span>Медиана /м² рядом (радиус {data.radiusLabel})</span>
<span
style={{ fontFamily: tokens.font.mono, color: tokens.ink2 }}
>
{data.localMedianLabel}
</span>
</div>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
marginTop: 4,
}}
>
<span>Медиана /м² по Екатеринбургу</span>
<span
style={{ fontFamily: tokens.font.mono, color: tokens.ink2 }}
>
{data.cityMedianLabel}
</span>
</div>
{/* Honest, not illustrative: this metric никогда не идёт в цену
(аналоги уже берутся из этого же района — повторный учёт
локации был бы задвоением, см. app/services/location_index.py). */}
<div
style={{
marginTop: 8,
fontSize: 10.5,
lineHeight: 1.5,
color: tokens.muted3,
}}
>
Посчитано по {data.sampleSize}{" "}
{pluralRu(data.sampleSize, [
"объявлению",
"объявлениям",
"объявлениям",
])}{" "}
в радиусе {data.radiusLabel}. Справочно на итоговую оценку не
влияет: аналоги для расчёта уже берутся из этого района.
</div>
<PoiSection data={data} />
</>
)}
{data.status === "out_of_coverage" && (
<>
Локационный индекс считаем только по{" "}
<b style={{ color: tokens.ink2 }}>Екатеринбургу</b> этот адрес
вне зоны покрытия, сравнение с городом недоступно.
</>
)}
{data.status === "insufficient_data" && (
<>
Рядом нашлось только{" "}
<b style={{ color: tokens.ink2 }}>{data.sampleSize}</b>{" "}
сопоставимых объявлений (радиус {data.radiusLabel}) этого
недостаточно для надёжного сравнения с городом.
<PoiSection data={data} />
</>
)}
{data.status === "loading" && "Считаем локационный индекс…"}
</div>
</div>
</>
);
}