Compare commits

..

No commits in common. "fb25180929f70f2f46fe59397624e8bde7157ba7" and "f3e10a04adde40117128fbda1f21e09655ca31e9" have entirely different histories.

7 changed files with 320 additions and 322 deletions

View file

@ -34,7 +34,6 @@ import {
mapAnalytics, mapAnalytics,
mapCache, mapCache,
mapHistory, mapHistory,
mapMarkers,
mapObject, mapObject,
mapReport, mapReport,
mapResultPanel, mapResultPanel,
@ -58,8 +57,6 @@ 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
@ -412,11 +409,6 @@ 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.
@ -505,24 +497,6 @@ 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>(
@ -652,13 +626,7 @@ export default function TradeInV2Page() {
flexDirection: "column", flexDirection: "column",
}} }}
> >
<TopNav <TopNav active={nav} onNavigate={setNav} reports={reportsCount ?? 0} />
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}
@ -680,7 +648,6 @@ export default function TradeInV2Page() {
isPending={mutation.isPending} isPending={mutation.isPending}
error={apiError} error={apiError}
initialValues={initialValues} initialValues={initialValues}
markers={markers}
/> />
{middleContent} {middleContent}
{rightContent} {rightContent}

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, type CSSProperties } from "react"; import 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,9 +60,6 @@ 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">
@ -205,7 +202,6 @@ 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,
@ -243,21 +239,17 @@ export default function HeroBar({
boxShadow: "0 6px 26px rgba(40,80,130,.10)", boxShadow: "0 6px 26px rgba(40,80,130,.10)",
}} }}
> >
{!imgFailed && (
<Image <Image
src="/trade-in-v2/building.png" src="/trade-in-v2/building.png"
alt="" alt=""
fill fill
sizes="560px" sizes="560px"
unoptimized
onError={() => setImgFailed(true)}
style={{ style={{
objectFit: "contain", objectFit: "contain",
objectPosition: "right center", objectPosition: "right center",
filter: "saturate(.9) brightness(1.04)", filter: "saturate(.9) brightness(1.04)",
}} }}
/> />
)}
<div <div
style={{ style={{
position: "absolute", position: "absolute",

View file

@ -7,7 +7,6 @@
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).
@ -59,8 +58,7 @@ 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>

View file

@ -1,35 +1,73 @@
"use client"; "use client";
// "ПОЯСНЕНИЕ К РАСЧЁТУ" right-side drawer for the /trade-in/v2 "МЕРА Оценка" // "КОЭФФИЦИЕНТ ЛОКАЦИИ" right-side drawer for the /trade-in/v2 "МЕРА Оценка"
// design port. Opened from HeroBar (the "?" near "КОЭФ. ЛОКАЦИИ"). This used to // design port. Faithful markup port of the design drawer (МЕРА Оценка.dc.html,
// render a FABRICATED location coefficient (0.87), a fake "base × coef = result" // lines 618-660): scrim + 452px right slider that slides in via translateX, a
// formula and invented POI factor lists with a false "Источник: OpenStreetMap" // big location coefficient, intro, base×coef formula, the factors that raise /
// footer — none of which the backend produces (location-coef is deferred, // lower price, and a geo-source footer. Static markup, data from fixtures.
// backend #2045). It is now an HONEST methodology panel with STATIC, truthful // Open/close is driven entirely by props (no internal state, no fetch).
// 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;
} }
export function LocationDrawer({ open, onClose }: LocationDrawerProps) { function FactorRow({
// Escape-to-close (UI effect only — no HTTP). Active while the drawer is open. factor,
useEffect(() => { variant,
if (!open) return; }: {
const onKey = (e: KeyboardEvent) => { factor: LocationFactor;
if (e.key === "Escape") onClose(); variant: "pos" | "neg";
}; }) {
window.addEventListener("keydown", onKey); const isPos = variant === "pos";
return () => window.removeEventListener("keydown", onKey); return (
}, [open, onClose]); <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) {
return ( return (
<> <>
<style>{` <style>{`
@ -144,7 +182,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
color: tokens.muted2, color: tokens.muted2,
}} }}
> >
МЕТОДИКА ПОЯСНЕНИЕ К РАСЧЁТУ
</div> </div>
<div <div
style={{ style={{
@ -155,7 +193,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}>
@ -163,47 +201,81 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
</div> </div>
</div> </div>
{/* divider under header */} {/* big coefficient */}
<div <div
style={{ style={{
marginTop: 22, display: "flex",
alignItems: "baseline",
gap: 14,
marginTop: 24,
paddingBottom: 20,
borderBottom: `1px solid ${tokens.lineSoft}`, borderBottom: `1px solid ${tokens.lineSoft}`,
}} }}
/> >
<span
{/* methodology section */}
<div
style={{ style={{
fontSize: 9, fontFamily: tokens.font.mono,
letterSpacing: "2px", fontSize: 52,
color: tokens.muted2, fontWeight: 300,
marginTop: 22, color: tokens.accent,
marginBottom: 12, lineHeight: 1,
}} }}
> >
КАК СЧИТАЕМ {locationFactors.coef}
</span>
<div style={{ fontSize: 11, color: tokens.muted, lineHeight: 1.5 }}>
множитель к базовой
<br />
цене по городу
</div> </div>
</div>
{/* intro */}
<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.ink2 }}>/м²</b> 3 оценки:{" "} уровень. <b style={{ color: tokens.accent }}>0.87</b> означает, что
<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>
{/* location note section */} {/* formula */}
<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,
@ -213,22 +285,44 @@ 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: 12.5, fontSize: 9,
lineHeight: 1.7, letterSpacing: "2px",
color: tokens.body2, color: tokens.muted2,
background: tokens.infoSoftBg, marginTop: 24,
border: `1px solid ${tokens.infoSoftBorder}`, marginBottom: 12,
borderRadius: 7,
padding: "12px 14px",
}} }}
> >
Коэффициент локации (POI, транспорт, шум) {" "} ЧТО СНИЖАЕТ ЦЕНУ
<b style={{ color: tokens.ink2 }}>в разработке</b>; текущая оценка по </div>
локации не корректируется. <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>
</> </>

View file

@ -18,7 +18,6 @@ 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 {
@ -258,21 +257,6 @@ 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). */
@ -283,9 +267,6 @@ 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
@ -310,7 +291,6 @@ 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 ?? "");
@ -386,11 +366,7 @@ 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) {
@ -605,16 +581,135 @@ export default function ParamsPanel({
/> />
</div> </div>
{/* analog price pins projected from the real estimate via mapMarkers(). {/* analog price pins */}
Empty markers (no estimate yet) no price pins, just the subject pin + <div
radius rings (Finding #2: never the fabricated fixture prices/dots). */} style={{
{markers.map((m, i) => ( position: "absolute",
<div key={i} style={{ ...analogPin, left: m.left, top: m.top }}> left: "5%",
{m.label} top: "13%",
<br /> background: tokens.surface.w70,
<span style={{ color: tokens.muted }}>{m.sub}</span> border: `1px solid ${tokens.line}`,
borderRadius: 4,
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>
))} <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
@ -867,7 +962,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>

View file

@ -4,46 +4,23 @@
// 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/version come from fixtures, the user // own useState. No data fetching — labels/user come from fixtures, colours from
// identity is fed in from the page (real useMe), colours from tokens. // 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, version } from "./fixtures"; import { navLabels, user, 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 0 for unwired usage. // estimate count). Defaults to the design fixture 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",
@ -56,23 +33,12 @@ 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 = 0, reports = user.reports,
user,
onLogout,
}: TopNavProps) { }: TopNavProps) {
const [userOpen, setUserOpen] = useState(false); const [userOpen, setUserOpen] = useState(false);
const u = user ?? GUEST_USER;
return ( return (
<> <>
@ -256,7 +222,7 @@ export default function TopNav({
boxShadow: "0 3px 10px rgba(46,139,255,.3)", boxShadow: "0 3px 10px rgba(46,139,255,.3)",
}} }}
> >
{u.initials} {user.initials}
</div> </div>
<div style={{ lineHeight: 1.25 }}> <div style={{ lineHeight: 1.25 }}>
<div <div
@ -267,7 +233,7 @@ export default function TopNav({
fontFamily: tokens.font.sans, fontFamily: tokens.font.sans,
}} }}
> >
{u.name} {user.name}
</div> </div>
<div <div
style={{ style={{
@ -277,7 +243,7 @@ export default function TopNav({
letterSpacing: ".5px", letterSpacing: ".5px",
}} }}
> >
{u.org} {user.org}
</div> </div>
</div> </div>
<span style={{ color: tokens.muted2, fontSize: "9px" }}></span> <span style={{ color: tokens.muted2, fontSize: "9px" }}></span>
@ -323,7 +289,7 @@ export default function TopNav({
fontWeight: 600, fontWeight: 600,
}} }}
> >
{u.initials} {user.initials}
</div> </div>
<div> <div>
<div <div
@ -333,15 +299,15 @@ export default function TopNav({
color: tokens.ink2, color: tokens.ink2,
}} }}
> >
{u.name} {user.name}
</div> </div>
<div style={{ fontSize: "10px", color: tokens.muted2 }}> <div style={{ fontSize: "10px", color: tokens.muted2 }}>
{u.email} {user.email}
</div> </div>
</div> </div>
</div> </div>
<div style={menuItemDisabledStyle} aria-disabled="true"> <div className="tnav-menuitem" style={menuItemStyle}>
<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"
@ -381,7 +347,7 @@ export default function TopNav({
</span> </span>
</div> </div>
<div style={menuItemDisabledStyle} aria-disabled="true"> <div className="tnav-menuitem" style={menuItemStyle}>
<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"
@ -399,7 +365,7 @@ export default function TopNav({
Настройки Настройки
</div> </div>
<div style={menuItemDisabledStyle} aria-disabled="true"> <div className="tnav-menuitem" style={menuItemStyle}>
<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"
@ -426,10 +392,7 @@ export default function TopNav({
/> />
<div <div
onClick={() => { onClick={() => setUserOpen(false)}
setUserOpen(false);
onLogout?.();
}}
className="tnav-logout" className="tnav-logout"
style={{ ...menuItemStyle, color: tokens.danger }} style={{ ...menuItemStyle, color: tokens.danger }}
> >

View file

@ -651,83 +651,6 @@ 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 0100% 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 496% 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,
@ -842,17 +765,13 @@ 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,
// Finding #10: negate the bargain so a price reduction reads "3%" (compact anaBargain != null ? fmtPct(anaBargain) : null,
// 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[] = [
{ {
// Finding #9: fed by house-analytics radius data (район, radius_m up to label: "Продажи в доме",
// 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,
@ -949,22 +868,6 @@ 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 "—";
@ -1039,19 +942,6 @@ 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). */
@ -1140,7 +1030,7 @@ export function mapHistory(
ask, ask,
delta: pct1(pr.discount_pct), delta: pct1(pr.discount_pct),
deltaColor, deltaColor,
addr: pr.deal_address ? deglueAddr(pr.deal_address) : undefined, addr: pr.deal_address || undefined,
date: fmtDate(pr.deal_date), date: fmtDate(pr.deal_date),
source: pr.listing_source, source: pr.listing_source,
}; };
@ -1166,8 +1056,7 @@ function buildAnalyticsKpis(k: HouseAnalyticsKpi | null): AnalyticsKpi[] {
}, },
{ {
label: "СРЕДНИЙ ТОРГ", label: "СРЕДНИЙ ТОРГ",
// Finding #10: negate so a price reduction reads "3.3%" not "+3.3%". value: pct1(k?.median_bargain_pct),
value: bargainSigned(k?.median_bargain_pct),
color: bargainColor, color: bargainColor,
sub: "от начальной до итоговой цены", sub: "от начальной до итоговой цены",
}, },
@ -1509,7 +1398,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: deglueAddr(l.address || "—"), addr: 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),
@ -1519,7 +1408,7 @@ export function mapSources(
})); }));
const dealRows: DealRowData[] = (e?.actual_deals ?? []).map((l) => ({ const dealRows: DealRowData[] = (e?.actual_deals ?? []).map((l) => ({
addr: deglueAddr(l.address || "—"), addr: 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),