fix(tradein/v2): audit findings — de-fabricate location drawer, real map markers, validation, hero image, real user (#2036)
Browser audit of the live /trade-in/v2 found honesty + UX defects; fixes: - 🔴 LocationDrawer: removed FABRICATED coef 0.87 / fake 'base×coef=result' / invented POI factors / false 'OpenStreetMap' source. Now honest static 'ПОЯСНЕНИЕ К РАСЧЁТУ' (real methodology) + 'коэф. локации в разработке' note. + Escape-to-close. - 🔴 Map (ParamsPanel): removed static fixture price markers; renders REAL analog markers via new mapMarkers(estimate) (projects lat/lon around target, cap 6). - ParamsPanel: empty площадь/address now shows a validation message instead of silently blocking submit; numeric placeholders made clearly placeholders ('напр. 54'). - HeroBar: dead 'КАК РАССЧИТАНО' button now opens the info drawer; building.png next/image 400 fixed (unoptimized + onError fallback — basePath wasn't applied to the optimizer URL). - TopNav: real user from useMe() (name/initials/org/email) instead of fixture 'Андрей Петров'; dead Профиль/Настройки/Помощь dimmed/disabled; Выйти → logout(). - mappers: de-glue analog addresses ('69/2Геологическая'→'69/2 Геологическая', preserves house letters like 14А); 'Продажи в доме'→'Продажи рядом' (radius data); торг sign fixed (backend (start−last)/start positive=reduction → renders '−3.3%'). - HistoryView: '0 ЛОТА' → correct plural via pluralRu. next build green; code-reviewer ✅ APPROVE.
This commit is contained in:
parent
9e2d9ddbea
commit
5a2b6d91cd
7 changed files with 323 additions and 321 deletions
|
|
@ -34,6 +34,7 @@ import {
|
||||||
mapAnalytics,
|
mapAnalytics,
|
||||||
mapCache,
|
mapCache,
|
||||||
mapHistory,
|
mapHistory,
|
||||||
|
mapMarkers,
|
||||||
mapObject,
|
mapObject,
|
||||||
mapReport,
|
mapReport,
|
||||||
mapResultPanel,
|
mapResultPanel,
|
||||||
|
|
@ -57,6 +58,8 @@ import {
|
||||||
useStreetDeals,
|
useStreetDeals,
|
||||||
} from "@/lib/trade-in-api";
|
} from "@/lib/trade-in-api";
|
||||||
import { useQuota } from "@/lib/useQuota";
|
import { useQuota } from "@/lib/useQuota";
|
||||||
|
import { useMe } from "@/lib/useMe";
|
||||||
|
import { logout } from "@/lib/logout";
|
||||||
|
|
||||||
// OUTER HUD FRAME + 4 corner brackets (design lines 31-37). Decorative,
|
// OUTER HUD FRAME + 4 corner brackets (design lines 31-37). Decorative,
|
||||||
// non-interactive overlay drawn over the artboard gradient. The frame has
|
// non-interactive overlay drawn over the artboard gradient. The frame has
|
||||||
|
|
@ -409,6 +412,11 @@ export default function TradeInV2Page() {
|
||||||
// via invalidateQueries in handleSubmit.
|
// via invalidateQueries in handleSubmit.
|
||||||
const history = useEstimateHistory();
|
const history = useEstimateHistory();
|
||||||
const quota = useQuota();
|
const quota = useQuota();
|
||||||
|
// Real logged-in identity for the TopNav user menu (replaces the design
|
||||||
|
// fixture). /me returns {username, role, brand} — no display name / email, so
|
||||||
|
// name falls back to username, org to brand ?? role, email to "" (renders
|
||||||
|
// blank, never invented). undefined while loading → TopNav «Гость» fallback.
|
||||||
|
const me = useMe();
|
||||||
|
|
||||||
// Dashboard sub-hooks — each resolves independently; failure degrades its
|
// Dashboard sub-hooks — each resolves independently; failure degrades its
|
||||||
// section via the mappers (null input) rather than blanking the page.
|
// section via the mappers (null input) rather than blanking the page.
|
||||||
|
|
@ -497,6 +505,24 @@ export default function TradeInV2Page() {
|
||||||
// to the fixture default (undefined) for unwired/pre-load states.
|
// to the fixture default (undefined) for unwired/pre-load states.
|
||||||
const reportsCount = quota.data?.used ?? history.data?.length;
|
const reportsCount = quota.data?.used ?? history.data?.length;
|
||||||
|
|
||||||
|
// Real user for the TopNav menu. undefined until /me resolves → TopNav shows
|
||||||
|
// its neutral «Гость» fallback (never the old "Андрей Петров / Брусника").
|
||||||
|
const topNavUser = useMemo(() => {
|
||||||
|
const scope = me.data;
|
||||||
|
if (!scope) return undefined;
|
||||||
|
return {
|
||||||
|
name: scope.username,
|
||||||
|
org: scope.brand ?? scope.role,
|
||||||
|
email: "",
|
||||||
|
initials: scope.username.slice(0, 2).toUpperCase(),
|
||||||
|
};
|
||||||
|
}, [me.data]);
|
||||||
|
|
||||||
|
// Analog price pins for the 01 map, projected from the real estimate. No
|
||||||
|
// estimate → mapMarkers(null) → [] → ParamsPanel renders only the subject pin
|
||||||
|
// (Finding #2: never the static fixture price markers).
|
||||||
|
const markers = useMemo(() => mapMarkers(estimate), [estimate]);
|
||||||
|
|
||||||
// Prefill for restore-by-id / re-estimate. Keyed remount applies it when the
|
// Prefill for restore-by-id / re-estimate. Keyed remount applies it when the
|
||||||
// estimate arrives async (useState reads initialValues only on mount).
|
// estimate arrives async (useState reads initialValues only on mount).
|
||||||
const initialValues = useMemo<Partial<TradeInEstimateInput> | undefined>(
|
const initialValues = useMemo<Partial<TradeInEstimateInput> | undefined>(
|
||||||
|
|
@ -626,7 +652,13 @@ export default function TradeInV2Page() {
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TopNav active={nav} onNavigate={setNav} reports={reportsCount ?? 0} />
|
<TopNav
|
||||||
|
active={nav}
|
||||||
|
onNavigate={setNav}
|
||||||
|
reports={reportsCount ?? 0}
|
||||||
|
user={topNavUser}
|
||||||
|
onLogout={logout}
|
||||||
|
/>
|
||||||
<HeroBar
|
<HeroBar
|
||||||
data={{ report, object: objectInfo }}
|
data={{ report, object: objectInfo }}
|
||||||
estimateId={mounted ? currentEstimateId : null}
|
estimateId={mounted ? currentEstimateId : null}
|
||||||
|
|
@ -648,6 +680,7 @@ export default function TradeInV2Page() {
|
||||||
isPending={mutation.isPending}
|
isPending={mutation.isPending}
|
||||||
error={apiError}
|
error={apiError}
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
|
markers={markers}
|
||||||
/>
|
/>
|
||||||
{middleContent}
|
{middleContent}
|
||||||
{rightContent}
|
{rightContent}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CSSProperties } from "react";
|
import { useState, type CSSProperties } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
import { API_BASE_URL } from "@/lib/api";
|
import { API_BASE_URL } from "@/lib/api";
|
||||||
|
|
@ -60,6 +60,9 @@ export default function HeroBar({
|
||||||
onOpenInfo,
|
onOpenInfo,
|
||||||
}: HeroBarProps) {
|
}: HeroBarProps) {
|
||||||
const pdfHref = pdfDownloadHref(estimateId);
|
const pdfHref = pdfDownloadHref(estimateId);
|
||||||
|
// Hide the building photo if the asset 404s/400s so the photoBg fill shows
|
||||||
|
// instead of a broken-image icon.
|
||||||
|
const [imgFailed, setImgFailed] = useState(false);
|
||||||
const pdfBtnInner = (
|
const pdfBtnInner = (
|
||||||
<>
|
<>
|
||||||
<svg width="18" height="20" viewBox="0 0 18 20" fill="none">
|
<svg width="18" height="20" viewBox="0 0 18 20" fill="none">
|
||||||
|
|
@ -202,6 +205,7 @@ export default function HeroBar({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="hero-calc-btn"
|
className="hero-calc-btn"
|
||||||
|
onClick={onOpenInfo}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
height: 50,
|
height: 50,
|
||||||
|
|
@ -239,17 +243,21 @@ export default function HeroBar({
|
||||||
boxShadow: "0 6px 26px rgba(40,80,130,.10)",
|
boxShadow: "0 6px 26px rgba(40,80,130,.10)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
{!imgFailed && (
|
||||||
src="/trade-in-v2/building.png"
|
<Image
|
||||||
alt=""
|
src="/trade-in-v2/building.png"
|
||||||
fill
|
alt=""
|
||||||
sizes="560px"
|
fill
|
||||||
style={{
|
sizes="560px"
|
||||||
objectFit: "contain",
|
unoptimized
|
||||||
objectPosition: "right center",
|
onError={() => setImgFailed(true)}
|
||||||
filter: "saturate(.9) brightness(1.04)",
|
style={{
|
||||||
}}
|
objectFit: "contain",
|
||||||
/>
|
objectPosition: "right center",
|
||||||
|
filter: "saturate(.9) brightness(1.04)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { history, dkpRows } from "./fixtures";
|
import { history, dkpRows } from "./fixtures";
|
||||||
|
import { pluralRu } from "./mappers";
|
||||||
import type { HistoryData } from "./mappers";
|
import type { HistoryData } from "./mappers";
|
||||||
|
|
||||||
// Shared grid templates (kept verbatim from the design column tracks).
|
// Shared grid templates (kept verbatim from the design column tracks).
|
||||||
|
|
@ -58,7 +59,8 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
|
||||||
color: tokens.muted2,
|
color: tokens.muted2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.houseSales.length} ЛОТА
|
{data.houseSales.length}{" "}
|
||||||
|
{pluralRu(data.houseSales.length, ["ЛОТ", "ЛОТА", "ЛОТОВ"])}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,73 +1,35 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// "КОЭФФИЦИЕНТ ЛОКАЦИИ" right-side drawer for the /trade-in/v2 "МЕРА Оценка"
|
// "ПОЯСНЕНИЕ К РАСЧЁТУ" right-side drawer for the /trade-in/v2 "МЕРА Оценка"
|
||||||
// design port. Faithful markup port of the design drawer (МЕРА Оценка.dc.html,
|
// design port. Opened from HeroBar (the "?" near "КОЭФ. ЛОКАЦИИ"). This used to
|
||||||
// lines 618-660): scrim + 452px right slider that slides in via translateX, a
|
// render a FABRICATED location coefficient (0.87), a fake "base × coef = result"
|
||||||
// big location coefficient, intro, base×coef formula, the factors that raise /
|
// formula and invented POI factor lists with a false "Источник: OpenStreetMap"
|
||||||
// lower price, and a geo-source footer. Static markup, data from fixtures.
|
// footer — none of which the backend produces (location-coef is deferred,
|
||||||
// Open/close is driven entirely by props (no internal state, no fetch).
|
// 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.
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import type { LocationFactor } from "./types";
|
|
||||||
import { locationFactors } from "./fixtures";
|
|
||||||
|
|
||||||
interface LocationDrawerProps {
|
interface LocationDrawerProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FactorRow({
|
|
||||||
factor,
|
|
||||||
variant,
|
|
||||||
}: {
|
|
||||||
factor: LocationFactor;
|
|
||||||
variant: "pos" | "neg";
|
|
||||||
}) {
|
|
||||||
const isPos = variant === "pos";
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
fontSize: 12,
|
|
||||||
color: tokens.body,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ display: "flex", alignItems: "center", gap: 9 }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
borderRadius: 4,
|
|
||||||
background: isPos
|
|
||||||
? "rgba(27,170,107,.12)"
|
|
||||||
: "rgba(111,129,149,.14)",
|
|
||||||
color: isPos ? tokens.success : tokens.muted,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
fontSize: isPos ? 11 : 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isPos ? "+" : "−"}
|
|
||||||
</span>
|
|
||||||
{factor.label}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: tokens.font.mono,
|
|
||||||
fontSize: 11,
|
|
||||||
color: isPos ? tokens.success : tokens.muted,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{factor.delta}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
||||||
|
// Escape-to-close (UI effect only — no HTTP). Active while the drawer is open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style>{`
|
<style>{`
|
||||||
|
|
@ -182,7 +144,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
||||||
color: tokens.muted2,
|
color: tokens.muted2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
ПОЯСНЕНИЕ К РАСЧЁТУ
|
МЕТОДИКА
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -193,7 +155,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
КОЭФФИЦИЕНТ ЛОКАЦИИ
|
ПОЯСНЕНИЕ К РАСЧЁТУ
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ld-close" onClick={onClose}>
|
<div className="ld-close" onClick={onClose}>
|
||||||
|
|
@ -201,81 +163,47 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* big coefficient */}
|
{/* divider under header */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
marginTop: 22,
|
||||||
alignItems: "baseline",
|
|
||||||
gap: 14,
|
|
||||||
marginTop: 24,
|
|
||||||
paddingBottom: 20,
|
|
||||||
borderBottom: `1px solid ${tokens.lineSoft}`,
|
borderBottom: `1px solid ${tokens.lineSoft}`,
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: tokens.font.mono,
|
|
||||||
fontSize: 52,
|
|
||||||
fontWeight: 300,
|
|
||||||
color: tokens.accent,
|
|
||||||
lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{locationFactors.coef}
|
|
||||||
</span>
|
|
||||||
<div style={{ fontSize: 11, color: tokens.muted, lineHeight: 1.5 }}>
|
|
||||||
множитель к базовой
|
|
||||||
<br />
|
|
||||||
цене по городу
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* intro */}
|
{/* methodology section */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 9,
|
||||||
|
letterSpacing: "2px",
|
||||||
|
color: tokens.muted2,
|
||||||
|
marginTop: 22,
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
КАК СЧИТАЕМ
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12.5,
|
fontSize: 12.5,
|
||||||
lineHeight: 1.7,
|
lineHeight: 1.7,
|
||||||
color: tokens.body2,
|
color: tokens.body2,
|
||||||
marginTop: 20,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Показывает, как адрес корректирует стоимость относительно медианы по
|
Агрегируем объявления (Авито, Циан, Яндекс, Домклик) и сделки
|
||||||
Екатеринбургу. <b style={{ color: tokens.ink2 }}>1.00</b> — средний
|
Росреестра по сопоставимым квартирам. Медиана{" "}
|
||||||
уровень. <b style={{ color: tokens.accent }}>0.87</b> означает, что
|
<b style={{ color: tokens.ink2 }}>₽/м²</b> → 3 оценки:{" "}
|
||||||
локация снижает цену примерно на{" "}
|
<b style={{ color: tokens.ink2 }}>
|
||||||
<b style={{ color: tokens.ink2 }}>13%</b> из-за баланса факторов ниже.
|
рекомендованная цена в объявлении
|
||||||
|
</b>{" "}
|
||||||
|
(asking),{" "}
|
||||||
|
<b style={{ color: tokens.ink2 }}>ожидаемая цена сделки</b> (asking −
|
||||||
|
торг по ДКП),{" "}
|
||||||
|
<b style={{ color: tokens.ink2 }}>ДКП·Росреестр</b> (фактические
|
||||||
|
сделки). CV — коэффициент вариации выборки (разброс цен).
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* formula */}
|
{/* location note section */}
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 10,
|
|
||||||
marginTop: 18,
|
|
||||||
background: tokens.surface.w55,
|
|
||||||
border: `1px solid ${tokens.lineSoft}`,
|
|
||||||
borderRadius: 7,
|
|
||||||
padding: "12px 14px",
|
|
||||||
fontFamily: tokens.font.mono,
|
|
||||||
fontSize: 12,
|
|
||||||
color: tokens.ink2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ color: tokens.muted }}>
|
|
||||||
{locationFactors.formula.base}
|
|
||||||
</span>
|
|
||||||
<span style={{ color: tokens.muted2 }}>×</span>
|
|
||||||
<span style={{ color: tokens.accent }}>
|
|
||||||
{locationFactors.formula.coef}
|
|
||||||
</span>
|
|
||||||
<span style={{ color: tokens.muted2 }}>=</span>
|
|
||||||
<span style={{ fontWeight: 500 }}>
|
|
||||||
{locationFactors.formula.result}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* positives */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
|
|
@ -285,44 +213,22 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
ЧТО ПОВЫШАЕТ ЦЕНУ
|
ЛОКАЦИЯ
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
|
|
||||||
{locationFactors.positives.map((f, i) => (
|
|
||||||
<FactorRow key={i} factor={f} variant="pos" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* negatives */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 9,
|
fontSize: 12.5,
|
||||||
letterSpacing: "2px",
|
lineHeight: 1.7,
|
||||||
color: tokens.muted2,
|
color: tokens.body2,
|
||||||
marginTop: 24,
|
background: tokens.infoSoftBg,
|
||||||
marginBottom: 12,
|
border: `1px solid ${tokens.infoSoftBorder}`,
|
||||||
|
borderRadius: 7,
|
||||||
|
padding: "12px 14px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
ЧТО СНИЖАЕТ ЦЕНУ
|
Коэффициент локации (POI, транспорт, шум) —{" "}
|
||||||
</div>
|
<b style={{ color: tokens.ink2 }}>в разработке</b>; текущая оценка по
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
|
локации не корректируется.
|
||||||
{locationFactors.negatives.map((f, i) => (
|
|
||||||
<FactorRow key={i} factor={f} variant="neg" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* footer source */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 24,
|
|
||||||
paddingTop: 16,
|
|
||||||
borderTop: `1px solid ${tokens.lineSoft}`,
|
|
||||||
fontSize: 10.5,
|
|
||||||
lineHeight: 1.6,
|
|
||||||
color: tokens.hint,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{locationFactors.footer}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
HOUSE_TYPE_RU,
|
HOUSE_TYPE_RU,
|
||||||
REPAIR_FROM_RU,
|
REPAIR_FROM_RU,
|
||||||
REPAIR_RU,
|
REPAIR_RU,
|
||||||
|
type MapMarker,
|
||||||
} from "./mappers";
|
} from "./mappers";
|
||||||
import { useGeocodeSuggest } from "@/lib/trade-in-api";
|
import { useGeocodeSuggest } from "@/lib/trade-in-api";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -257,6 +258,21 @@ const errorText: CSSProperties = {
|
||||||
color: tokens.danger,
|
color: tokens.danger,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Analog price pin on the 01 map. left/top come from mapMarkers() per-analog
|
||||||
|
// (projected from the real estimate); the chrome matches the former fixture
|
||||||
|
// pins 1:1 — only the data source changed (Finding #2).
|
||||||
|
const analogPin: CSSProperties = {
|
||||||
|
position: "absolute",
|
||||||
|
background: tokens.surface.w70,
|
||||||
|
border: `1px solid ${tokens.line}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "3px 7px",
|
||||||
|
fontFamily: tokens.font.mono,
|
||||||
|
fontSize: 9,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
color: tokens.ink,
|
||||||
|
};
|
||||||
|
|
||||||
interface ParamsPanelProps {
|
interface ParamsPanelProps {
|
||||||
/** Called with the validated form payload on «ОЦЕНИТЬ КВАРТИРУ». Optional so
|
/** Called with the validated form payload on «ОЦЕНИТЬ КВАРТИРУ». Optional so
|
||||||
* the still-unwired v2 page renders without props (tsc-safe). */
|
* the still-unwired v2 page renders without props (tsc-safe). */
|
||||||
|
|
@ -267,6 +283,9 @@ interface ParamsPanelProps {
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
/** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */
|
/** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */
|
||||||
initialValues?: Partial<TradeInEstimateInput>;
|
initialValues?: Partial<TradeInEstimateInput>;
|
||||||
|
/** Analog price pins for the 01 map, projected from the real estimate via
|
||||||
|
* mapMarkers(). Default [] → no price pins (never the fabricated fixtures). */
|
||||||
|
markers?: MapMarker[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// rooms number -> dropdown label. The design has no «Студия» option, so studio
|
// rooms number -> dropdown label. The design has no «Студия» option, so studio
|
||||||
|
|
@ -291,6 +310,7 @@ export default function ParamsPanel({
|
||||||
isPending = false,
|
isPending = false,
|
||||||
error = null,
|
error = null,
|
||||||
initialValues,
|
initialValues,
|
||||||
|
markers = [],
|
||||||
}: ParamsPanelProps) {
|
}: ParamsPanelProps) {
|
||||||
const [openDd, setOpenDd] = useState<DdKey>(null);
|
const [openDd, setOpenDd] = useState<DdKey>(null);
|
||||||
const [address, setAddress] = useState(initialValues?.address ?? "");
|
const [address, setAddress] = useState(initialValues?.address ?? "");
|
||||||
|
|
@ -366,7 +386,11 @@ export default function ParamsPanel({
|
||||||
const trimmedAddress = address.trim();
|
const trimmedAddress = address.trim();
|
||||||
const areaNum = Number(area.replace(",", "."));
|
const areaNum = Number(area.replace(",", "."));
|
||||||
if (trimmedAddress.length < 3) {
|
if (trimmedAddress.length < 3) {
|
||||||
setValidationError("Укажите адрес — минимум 3 символа");
|
setValidationError("Укажите адрес квартиры — минимум 3 символа");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!area.trim()) {
|
||||||
|
setValidationError("Укажите площадь квартиры");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!Number.isFinite(areaNum) || areaNum <= 10) {
|
if (!Number.isFinite(areaNum) || areaNum <= 10) {
|
||||||
|
|
@ -581,135 +605,16 @@ export default function ParamsPanel({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* analog price pins */}
|
{/* analog price pins — projected from the real estimate via mapMarkers().
|
||||||
<div
|
Empty markers (no estimate yet) → no price pins, just the subject pin +
|
||||||
style={{
|
radius rings (Finding #2: never the fabricated fixture prices/dots). */}
|
||||||
position: "absolute",
|
{markers.map((m, i) => (
|
||||||
left: "5%",
|
<div key={i} style={{ ...analogPin, left: m.left, top: m.top }}>
|
||||||
top: "13%",
|
{m.label}
|
||||||
background: tokens.surface.w70,
|
<br />
|
||||||
border: `1px solid ${tokens.line}`,
|
<span style={{ color: tokens.muted }}>{m.sub}</span>
|
||||||
borderRadius: 4,
|
</div>
|
||||||
padding: "3px 7px",
|
))}
|
||||||
fontFamily: tokens.font.mono,
|
|
||||||
fontSize: 9,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
color: tokens.ink,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
12,7 млн ₽<br />
|
|
||||||
<span style={{ color: tokens.muted }}>54 м²</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
right: "32%",
|
|
||||||
top: "8%",
|
|
||||||
background: tokens.surface.w70,
|
|
||||||
border: `1px solid ${tokens.line}`,
|
|
||||||
borderRadius: 4,
|
|
||||||
padding: "3px 7px",
|
|
||||||
fontFamily: tokens.font.mono,
|
|
||||||
fontSize: 9,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
color: tokens.ink,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
7,6 млн ₽<br />
|
|
||||||
<span style={{ color: tokens.muted }}>44 м²</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
right: "3%",
|
|
||||||
top: "36%",
|
|
||||||
background: tokens.surface.w70,
|
|
||||||
border: `1px solid ${tokens.line}`,
|
|
||||||
borderRadius: 4,
|
|
||||||
padding: "3px 7px",
|
|
||||||
fontFamily: tokens.font.mono,
|
|
||||||
fontSize: 9,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
color: tokens.ink,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
22,4 млн ₽<br />
|
|
||||||
<span style={{ color: tokens.muted }}>55 м²</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: "13%",
|
|
||||||
bottom: "9%",
|
|
||||||
background: tokens.surface.w70,
|
|
||||||
border: `1px solid ${tokens.line}`,
|
|
||||||
borderRadius: 4,
|
|
||||||
padding: "3px 7px",
|
|
||||||
fontFamily: tokens.font.mono,
|
|
||||||
fontSize: 9,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
color: tokens.ink,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
15,9 млн ₽<br />
|
|
||||||
<span style={{ color: tokens.muted }}>55 м²</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
right: "14%",
|
|
||||||
bottom: "11%",
|
|
||||||
background: tokens.surface.w70,
|
|
||||||
border: `1px solid ${tokens.line}`,
|
|
||||||
borderRadius: 4,
|
|
||||||
padding: "3px 7px",
|
|
||||||
fontFamily: tokens.font.mono,
|
|
||||||
fontSize: 9,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
color: tokens.ink,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
14,9 млн ₽<br />
|
|
||||||
<span style={{ color: tokens.muted }}>56 м²</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* analog dots */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: "18%",
|
|
||||||
top: "30%",
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: tokens.accent,
|
|
||||||
border: `1.5px solid ${tokens.dotStroke}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
right: "34%",
|
|
||||||
top: "28%",
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: tokens.dealDot,
|
|
||||||
border: `1.5px solid ${tokens.dotStroke}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: "30%",
|
|
||||||
bottom: "24%",
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: tokens.accent,
|
|
||||||
border: `1.5px solid ${tokens.dotStroke}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* map controls */}
|
{/* map controls */}
|
||||||
<div
|
<div
|
||||||
|
|
@ -962,7 +867,7 @@ export default function ParamsPanel({
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") handleSubmit();
|
if (e.key === "Enter") handleSubmit();
|
||||||
}}
|
}}
|
||||||
placeholder="54"
|
placeholder="напр. 54"
|
||||||
style={inputField}
|
style={inputField}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,23 +4,46 @@
|
||||||
// Faithful markup port of the design header (МЕРА Оценка.dc.html, lines 42-90):
|
// Faithful markup port of the design header (МЕРА Оценка.dc.html, lines 42-90):
|
||||||
// inline SVG logo + version + 5 nav tabs (active underline/triangle) + user menu.
|
// inline SVG logo + version + 5 nav tabs (active underline/triangle) + user menu.
|
||||||
// Tabs change only local UI state via onNavigate; the user dropdown owns its
|
// Tabs change only local UI state via onNavigate; the user dropdown owns its
|
||||||
// own useState. No data fetching — labels/user come from fixtures, colours from
|
// own useState. No data fetching — labels/version come from fixtures, the user
|
||||||
// tokens.
|
// identity is fed in from the page (real useMe), colours from tokens.
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { navLabels, user, version } from "./fixtures";
|
import { navLabels, version } from "./fixtures";
|
||||||
|
|
||||||
|
// Real logged-in user identity, derived by the page from useMe()
|
||||||
|
// ({username, role, brand}). Deliberately excludes `reports` — the «Мои
|
||||||
|
// отчёты» badge count is a separate prop fed from useQuota().used.
|
||||||
|
interface TopNavUser {
|
||||||
|
name: string;
|
||||||
|
org: string;
|
||||||
|
email: string;
|
||||||
|
initials: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface TopNavProps {
|
interface TopNavProps {
|
||||||
active: number;
|
active: number;
|
||||||
onNavigate: (i: number) => void;
|
onNavigate: (i: number) => void;
|
||||||
// «Мои отчёты» badge count — page feeds it from useQuota().used (per-user
|
// «Мои отчёты» badge count — page feeds it from useQuota().used (per-user
|
||||||
// estimate count). Defaults to the design fixture for unwired usage.
|
// estimate count). Defaults to 0 for unwired usage.
|
||||||
reports?: number;
|
reports?: number;
|
||||||
|
// Real logged-in user. undefined → neutral «Гость» placeholder, NEVER the
|
||||||
|
// old design fixture ("Андрей Петров / Брусника").
|
||||||
|
user?: TopNavUser;
|
||||||
|
// Sign out — page wires this to logout(); closes the menu first.
|
||||||
|
onLogout?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Neutral fallback when the user prop is absent (loading / unauthenticated).
|
||||||
|
const GUEST_USER: TopNavUser = {
|
||||||
|
name: "Гость",
|
||||||
|
org: "—",
|
||||||
|
email: "—",
|
||||||
|
initials: "—",
|
||||||
|
};
|
||||||
|
|
||||||
const menuItemStyle: CSSProperties = {
|
const menuItemStyle: CSSProperties = {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|
@ -33,12 +56,23 @@ const menuItemStyle: CSSProperties = {
|
||||||
transition: "background .12s",
|
transition: "background .12s",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Профиль / Настройки / Помощь have no pages yet — render them dimmed and
|
||||||
|
// non-interactive (no hover class, default cursor) so they read as disabled.
|
||||||
|
const menuItemDisabledStyle: CSSProperties = {
|
||||||
|
...menuItemStyle,
|
||||||
|
cursor: "default",
|
||||||
|
color: tokens.muted2,
|
||||||
|
};
|
||||||
|
|
||||||
export default function TopNav({
|
export default function TopNav({
|
||||||
active,
|
active,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
reports = user.reports,
|
reports = 0,
|
||||||
|
user,
|
||||||
|
onLogout,
|
||||||
}: TopNavProps) {
|
}: TopNavProps) {
|
||||||
const [userOpen, setUserOpen] = useState(false);
|
const [userOpen, setUserOpen] = useState(false);
|
||||||
|
const u = user ?? GUEST_USER;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -222,7 +256,7 @@ export default function TopNav({
|
||||||
boxShadow: "0 3px 10px rgba(46,139,255,.3)",
|
boxShadow: "0 3px 10px rgba(46,139,255,.3)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{user.initials}
|
{u.initials}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ lineHeight: 1.25 }}>
|
<div style={{ lineHeight: 1.25 }}>
|
||||||
<div
|
<div
|
||||||
|
|
@ -233,7 +267,7 @@ export default function TopNav({
|
||||||
fontFamily: tokens.font.sans,
|
fontFamily: tokens.font.sans,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{user.name}
|
{u.name}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -243,7 +277,7 @@ export default function TopNav({
|
||||||
letterSpacing: ".5px",
|
letterSpacing: ".5px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{user.org}
|
{u.org}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ color: tokens.muted2, fontSize: "9px" }}>▾</span>
|
<span style={{ color: tokens.muted2, fontSize: "9px" }}>▾</span>
|
||||||
|
|
@ -289,7 +323,7 @@ export default function TopNav({
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{user.initials}
|
{u.initials}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
|
|
@ -299,15 +333,15 @@ export default function TopNav({
|
||||||
color: tokens.ink2,
|
color: tokens.ink2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{user.name}
|
{u.name}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: "10px", color: tokens.muted2 }}>
|
<div style={{ fontSize: "10px", color: tokens.muted2 }}>
|
||||||
{user.email}
|
{u.email}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tnav-menuitem" style={menuItemStyle}>
|
<div style={menuItemDisabledStyle} aria-disabled="true">
|
||||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
|
||||||
<circle
|
<circle
|
||||||
cx="7.5"
|
cx="7.5"
|
||||||
|
|
@ -347,7 +381,7 @@ export default function TopNav({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tnav-menuitem" style={menuItemStyle}>
|
<div style={menuItemDisabledStyle} aria-disabled="true">
|
||||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
|
||||||
<circle
|
<circle
|
||||||
cx="7.5"
|
cx="7.5"
|
||||||
|
|
@ -365,7 +399,7 @@ export default function TopNav({
|
||||||
Настройки
|
Настройки
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tnav-menuitem" style={menuItemStyle}>
|
<div style={menuItemDisabledStyle} aria-disabled="true">
|
||||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
|
||||||
<circle
|
<circle
|
||||||
cx="7.5"
|
cx="7.5"
|
||||||
|
|
@ -392,7 +426,10 @@ export default function TopNav({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
onClick={() => setUserOpen(false)}
|
onClick={() => {
|
||||||
|
setUserOpen(false);
|
||||||
|
onLogout?.();
|
||||||
|
}}
|
||||||
className="tnav-logout"
|
className="tnav-logout"
|
||||||
style={{ ...menuItemStyle, color: tokens.danger }}
|
style={{ ...menuItemStyle, color: tokens.danger }}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -651,6 +651,83 @@ export function mapObject(e: AggregatedEstimate): ObjectInfo {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── ParamsPanel map markers (analog price pins) ─────────────────────────────
|
||||||
|
// Finding #2: the 01 map showed STATIC fixture price pins (12,7 / 7,6 / 22,4 млн)
|
||||||
|
// that never reflected the real estimate. We project each analog with a known
|
||||||
|
// {lat,lon} relative to the subject (target_lat/lon) into the map's 0–100% box.
|
||||||
|
// There is no real basemap/zoom behind the SVG, so a fixed geographic half-span
|
||||||
|
// (~1.2 km) is used purely to spread nearby analogs; out-of-box analogs clamp to
|
||||||
|
// the 4–96% margin. Closest ~6 by distance are shown (the pin chrome only fits a
|
||||||
|
// handful). null / empty / un-geocoded estimate → [] (the map shows just the
|
||||||
|
// subject pin, never the fake fixture prices).
|
||||||
|
|
||||||
|
/** One analog price pin positioned by CSS % offsets over the ParamsPanel map. */
|
||||||
|
export interface MapMarker {
|
||||||
|
left: string; // e.g. "62%"
|
||||||
|
top: string; // e.g. "31%"
|
||||||
|
label: string; // "12,70 млн ₽"
|
||||||
|
sub: string; // "54 м²"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Half-span from the subject to a box edge (the 50% multiplier). ≈ 0.011° lat /
|
||||||
|
// 0.018° lon ≈ ~1.2 km at ЕКБ latitude (cos 56.8° ≈ 0.55). Tuneable: larger pulls
|
||||||
|
// analogs toward the centre, smaller pushes them to the clamped margins.
|
||||||
|
const MAP_LAT_HALF_SPAN = 0.011;
|
||||||
|
const MAP_LON_HALF_SPAN = 0.018;
|
||||||
|
const MAP_MARKER_CAP = 6;
|
||||||
|
const MAP_CLAMP_LO = 4;
|
||||||
|
const MAP_CLAMP_HI = 96;
|
||||||
|
|
||||||
|
/** Analog price pins for the 01 ParamsPanel map. null/empty/un-geocoded → []. */
|
||||||
|
export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] {
|
||||||
|
if (e == null || e.target_lat == null || e.target_lon == null) return [];
|
||||||
|
const tLat = e.target_lat;
|
||||||
|
const tLon = e.target_lon;
|
||||||
|
|
||||||
|
const geo = e.analogs.filter(
|
||||||
|
(a): a is AnalogLot & { lat: number; lon: number } =>
|
||||||
|
a.lat != null &&
|
||||||
|
a.lon != null &&
|
||||||
|
Number.isFinite(a.lat) &&
|
||||||
|
Number.isFinite(a.lon),
|
||||||
|
);
|
||||||
|
if (geo.length === 0) return [];
|
||||||
|
|
||||||
|
// Closest first: real distance_m when known, else a degree-based estimate
|
||||||
|
// (lon scaled by cos≈0.55) so analogs without distance_m still order sensibly.
|
||||||
|
const ranked = geo
|
||||||
|
.map((a) => {
|
||||||
|
const fallbackM =
|
||||||
|
Math.hypot(a.lat - tLat, (a.lon - tLon) * 0.55) * 111_320;
|
||||||
|
const dist =
|
||||||
|
a.distance_m != null && Number.isFinite(a.distance_m)
|
||||||
|
? a.distance_m
|
||||||
|
: fallbackM;
|
||||||
|
return { a, dist };
|
||||||
|
})
|
||||||
|
.sort((x, y) => x.dist - y.dist)
|
||||||
|
.slice(0, MAP_MARKER_CAP);
|
||||||
|
|
||||||
|
return ranked.map(({ a }) => {
|
||||||
|
const left = clamp(
|
||||||
|
50 + ((a.lon - tLon) / MAP_LON_HALF_SPAN) * 50,
|
||||||
|
MAP_CLAMP_LO,
|
||||||
|
MAP_CLAMP_HI,
|
||||||
|
);
|
||||||
|
const top = clamp(
|
||||||
|
50 - ((a.lat - tLat) / MAP_LAT_HALF_SPAN) * 50,
|
||||||
|
MAP_CLAMP_LO,
|
||||||
|
MAP_CLAMP_HI,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
left: `${round1(left)}%`,
|
||||||
|
top: `${round1(top)}%`,
|
||||||
|
label: `${fmtMln(a.price_rub)} млн ₽`,
|
||||||
|
sub: Number.isFinite(a.area_m2) ? `${Math.round(a.area_m2)} м²` : "—",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Full 02 РЕЗУЛЬТАТ block: 3 cards + meta + ranges + scatter + sources. */
|
/** Full 02 РЕЗУЛЬТАТ block: 3 cards + meta + ranges + scatter + sources. */
|
||||||
export function mapResultPanel(
|
export function mapResultPanel(
|
||||||
e: AggregatedEstimate,
|
e: AggregatedEstimate,
|
||||||
|
|
@ -765,13 +842,17 @@ export function mapSummary(
|
||||||
const anaBargain = kpi?.median_bargain_pct ?? null;
|
const anaBargain = kpi?.median_bargain_pct ?? null;
|
||||||
const row4Parts = [
|
const row4Parts = [
|
||||||
anaExp != null ? `${anaExp} дн` : null,
|
anaExp != null ? `${anaExp} дн` : null,
|
||||||
anaBargain != null ? fmtPct(anaBargain) : null,
|
// Finding #10: negate the bargain so a price reduction reads "−3%" (compact
|
||||||
|
// whole-percent here), not a misleading "+3%". See bargainSigned() rationale.
|
||||||
|
anaBargain != null ? fmtPct(-anaBargain) : null,
|
||||||
].filter((p): p is string => p != null);
|
].filter((p): p is string => p != null);
|
||||||
const row4Value = row4Parts.length > 0 ? row4Parts.join(" · ") : "—";
|
const row4Value = row4Parts.length > 0 ? row4Parts.join(" · ") : "—";
|
||||||
|
|
||||||
const rows: SummaryRow[] = [
|
const rows: SummaryRow[] = [
|
||||||
{
|
{
|
||||||
label: "Продажи в доме",
|
// Finding #9: fed by house-analytics radius data (район, radius_m up to
|
||||||
|
// 300 m), NOT strictly the exact house → honest label "Продажи рядом".
|
||||||
|
label: "Продажи рядом",
|
||||||
value: row1Value,
|
value: row1Value,
|
||||||
dot: houseSold && houseSold > 0 ? "accent" : "muted",
|
dot: houseSold && houseSold > 0 ? "accent" : "muted",
|
||||||
nav: 1,
|
nav: 1,
|
||||||
|
|
@ -868,6 +949,22 @@ function pct1(v: number | null | undefined): string {
|
||||||
return `${sign}${Math.abs(r).toFixed(1)}%`;
|
return `${sign}${Math.abs(r).toFixed(1)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finding #10 — "СРЕДНИЙ ТОРГ" rendering. The house-analytics KPI
|
||||||
|
* median_bargain_pct is the median over house_placement_history of
|
||||||
|
* (start_price − last_price) / start_price × 100
|
||||||
|
* (see backend trade_in.py), so a POSITIVE value means the asking price was
|
||||||
|
* REDUCED from its initial to its final value (the haggle / discount room).
|
||||||
|
* Feeding that straight through pct1 renders a misleading "+3.3%" — the "+"
|
||||||
|
* reads as a price INCREASE. We instead show the SIGNED change from initial →
|
||||||
|
* final price, so a reduction reads "−3.3%", matching the tile sub-label
|
||||||
|
* "от начальной до итоговой цены" (final price is 3.3% below initial). null → "—".
|
||||||
|
*/
|
||||||
|
function bargainSigned(pct: number | null | undefined): string {
|
||||||
|
if (pct == null || !Number.isFinite(pct)) return "—";
|
||||||
|
return pct1(-pct);
|
||||||
|
}
|
||||||
|
|
||||||
/** Distance label: <1km -> "850 м", else "1.2 км". null/NaN -> "—". */
|
/** Distance label: <1km -> "850 м", else "1.2 км". null/NaN -> "—". */
|
||||||
function fmtDist(m: number | null | undefined): string {
|
function fmtDist(m: number | null | undefined): string {
|
||||||
if (m == null || !Number.isFinite(m)) return "—";
|
if (m == null || !Number.isFinite(m)) return "—";
|
||||||
|
|
@ -942,6 +1039,19 @@ function dealMeta(l: AnalogLot): string {
|
||||||
return parts.join(" · ");
|
return parts.join(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finding #7 — de-glue addresses. Some geocoded analog/deal addresses run a
|
||||||
|
* metro/word straight onto the house number ("…, 69/2Геологическая"). Insert a
|
||||||
|
* space where a digit is immediately followed by the start of a Cyrillic WORD
|
||||||
|
* (capital + lowercase). NB: deliberately NOT the looser /(\d)([А-ЯЁ])/ — that
|
||||||
|
* would also split legitimate single-letter house litereras (14А, 5Б, д.5К),
|
||||||
|
* which are valid ЕКБ house numbers. Pure display cleanup; never touches the
|
||||||
|
* comma-splitting parseAddress.
|
||||||
|
*/
|
||||||
|
function deglueAddr(addr: string): string {
|
||||||
|
return addr.replace(/(\d)([А-ЯЁ][а-яё])/g, "$1 $2");
|
||||||
|
}
|
||||||
|
|
||||||
// ── 04 ПРОДАЖИ В ДОМЕ (HistoryView) ─────────────────────────────────────────
|
// ── 04 ПРОДАЖИ В ДОМЕ (HistoryView) ─────────────────────────────────────────
|
||||||
|
|
||||||
/** ДКП deal row + real per-row address/date/listing-source (DkpRow has none). */
|
/** ДКП deal row + real per-row address/date/listing-source (DkpRow has none). */
|
||||||
|
|
@ -1030,7 +1140,7 @@ export function mapHistory(
|
||||||
ask,
|
ask,
|
||||||
delta: pct1(pr.discount_pct),
|
delta: pct1(pr.discount_pct),
|
||||||
deltaColor,
|
deltaColor,
|
||||||
addr: pr.deal_address || undefined,
|
addr: pr.deal_address ? deglueAddr(pr.deal_address) : undefined,
|
||||||
date: fmtDate(pr.deal_date),
|
date: fmtDate(pr.deal_date),
|
||||||
source: pr.listing_source,
|
source: pr.listing_source,
|
||||||
};
|
};
|
||||||
|
|
@ -1056,7 +1166,8 @@ function buildAnalyticsKpis(k: HouseAnalyticsKpi | null): AnalyticsKpi[] {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "СРЕДНИЙ ТОРГ",
|
label: "СРЕДНИЙ ТОРГ",
|
||||||
value: pct1(k?.median_bargain_pct),
|
// Finding #10: negate so a price reduction reads "−3.3%" not "+3.3%".
|
||||||
|
value: bargainSigned(k?.median_bargain_pct),
|
||||||
color: bargainColor,
|
color: bargainColor,
|
||||||
sub: "от начальной до итоговой цены",
|
sub: "от начальной до итоговой цены",
|
||||||
},
|
},
|
||||||
|
|
@ -1398,7 +1509,7 @@ export function mapSources(
|
||||||
const e = estimate;
|
const e = estimate;
|
||||||
|
|
||||||
const adRows: AdRowData[] = (e?.analogs ?? []).map((l) => ({
|
const adRows: AdRowData[] = (e?.analogs ?? []).map((l) => ({
|
||||||
addr: l.address || "—",
|
addr: deglueAddr(l.address || "—"),
|
||||||
meta: adMeta(l),
|
meta: adMeta(l),
|
||||||
ppm: numRu(l.price_per_m2),
|
ppm: numRu(l.price_per_m2),
|
||||||
price: numRu(l.price_rub),
|
price: numRu(l.price_rub),
|
||||||
|
|
@ -1408,7 +1519,7 @@ export function mapSources(
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const dealRows: DealRowData[] = (e?.actual_deals ?? []).map((l) => ({
|
const dealRows: DealRowData[] = (e?.actual_deals ?? []).map((l) => ({
|
||||||
addr: l.address || "—",
|
addr: deglueAddr(l.address || "—"),
|
||||||
meta: dealMeta(l),
|
meta: dealMeta(l),
|
||||||
ppm: numRu(l.price_per_m2),
|
ppm: numRu(l.price_per_m2),
|
||||||
price: numRu(l.price_rub),
|
price: numRu(l.price_rub),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue