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,
|
||||
mapCache,
|
||||
mapHistory,
|
||||
mapMarkers,
|
||||
mapObject,
|
||||
mapReport,
|
||||
mapResultPanel,
|
||||
|
|
@ -57,6 +58,8 @@ import {
|
|||
useStreetDeals,
|
||||
} from "@/lib/trade-in-api";
|
||||
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,
|
||||
// non-interactive overlay drawn over the artboard gradient. The frame has
|
||||
|
|
@ -409,6 +412,11 @@ export default function TradeInV2Page() {
|
|||
// via invalidateQueries in handleSubmit.
|
||||
const history = useEstimateHistory();
|
||||
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
|
||||
// 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.
|
||||
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
|
||||
// estimate arrives async (useState reads initialValues only on mount).
|
||||
const initialValues = useMemo<Partial<TradeInEstimateInput> | undefined>(
|
||||
|
|
@ -626,7 +652,13 @@ export default function TradeInV2Page() {
|
|||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<TopNav active={nav} onNavigate={setNav} reports={reportsCount ?? 0} />
|
||||
<TopNav
|
||||
active={nav}
|
||||
onNavigate={setNav}
|
||||
reports={reportsCount ?? 0}
|
||||
user={topNavUser}
|
||||
onLogout={logout}
|
||||
/>
|
||||
<HeroBar
|
||||
data={{ report, object: objectInfo }}
|
||||
estimateId={mounted ? currentEstimateId : null}
|
||||
|
|
@ -648,6 +680,7 @@ export default function TradeInV2Page() {
|
|||
isPending={mutation.isPending}
|
||||
error={apiError}
|
||||
initialValues={initialValues}
|
||||
markers={markers}
|
||||
/>
|
||||
{middleContent}
|
||||
{rightContent}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import { API_BASE_URL } from "@/lib/api";
|
||||
|
|
@ -60,6 +60,9 @@ export default function HeroBar({
|
|||
onOpenInfo,
|
||||
}: HeroBarProps) {
|
||||
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 = (
|
||||
<>
|
||||
<svg width="18" height="20" viewBox="0 0 18 20" fill="none">
|
||||
|
|
@ -202,6 +205,7 @@ export default function HeroBar({
|
|||
<button
|
||||
type="button"
|
||||
className="hero-calc-btn"
|
||||
onClick={onOpenInfo}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 50,
|
||||
|
|
@ -239,17 +243,21 @@ export default function HeroBar({
|
|||
boxShadow: "0 6px 26px rgba(40,80,130,.10)",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src="/trade-in-v2/building.png"
|
||||
alt=""
|
||||
fill
|
||||
sizes="560px"
|
||||
style={{
|
||||
objectFit: "contain",
|
||||
objectPosition: "right center",
|
||||
filter: "saturate(.9) brightness(1.04)",
|
||||
}}
|
||||
/>
|
||||
{!imgFailed && (
|
||||
<Image
|
||||
src="/trade-in-v2/building.png"
|
||||
alt=""
|
||||
fill
|
||||
sizes="560px"
|
||||
unoptimized
|
||||
onError={() => setImgFailed(true)}
|
||||
style={{
|
||||
objectFit: "contain",
|
||||
objectPosition: "right center",
|
||||
filter: "saturate(.9) brightness(1.04)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import { tokens } from "./tokens";
|
||||
import { history, dkpRows } from "./fixtures";
|
||||
import { pluralRu } from "./mappers";
|
||||
import type { HistoryData } from "./mappers";
|
||||
|
||||
// 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,
|
||||
}}
|
||||
>
|
||||
{data.houseSales.length} ЛОТА
|
||||
{data.houseSales.length}{" "}
|
||||
{pluralRu(data.houseSales.length, ["ЛОТ", "ЛОТА", "ЛОТОВ"])}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,73 +1,35 @@
|
|||
"use client";
|
||||
|
||||
// "КОЭФФИЦИЕНТ ЛОКАЦИИ" right-side drawer for the /trade-in/v2 "МЕРА Оценка"
|
||||
// design port. Faithful markup port of the design drawer (МЕРА Оценка.dc.html,
|
||||
// lines 618-660): scrim + 452px right slider that slides in via translateX, a
|
||||
// big location coefficient, intro, base×coef formula, the factors that raise /
|
||||
// lower price, and a geo-source footer. Static markup, data from fixtures.
|
||||
// Open/close is driven entirely by props (no internal state, no fetch).
|
||||
// "ПОЯСНЕНИЕ К РАСЧЁТУ" right-side drawer for the /trade-in/v2 "МЕРА Оценка"
|
||||
// design port. Opened from HeroBar (the "?" near "КОЭФ. ЛОКАЦИИ"). This 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 — none of which the backend produces (location-coef is deferred,
|
||||
// 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 type { LocationFactor } from "./types";
|
||||
import { locationFactors } from "./fixtures";
|
||||
|
||||
interface LocationDrawerProps {
|
||||
open: boolean;
|
||||
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) {
|
||||
// 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 (
|
||||
<>
|
||||
<style>{`
|
||||
|
|
@ -182,7 +144,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
|||
color: tokens.muted2,
|
||||
}}
|
||||
>
|
||||
ПОЯСНЕНИЕ К РАСЧЁТУ
|
||||
МЕТОДИКА
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -193,7 +155,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
|||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
КОЭФФИЦИЕНТ ЛОКАЦИИ
|
||||
ПОЯСНЕНИЕ К РАСЧЁТУ
|
||||
</div>
|
||||
</div>
|
||||
<div className="ld-close" onClick={onClose}>
|
||||
|
|
@ -201,81 +163,47 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* big coefficient */}
|
||||
{/* divider under header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
gap: 14,
|
||||
marginTop: 24,
|
||||
paddingBottom: 20,
|
||||
marginTop: 22,
|
||||
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
|
||||
style={{
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.7,
|
||||
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 }}>13%</b> из-за баланса факторов ниже.
|
||||
Агрегируем объявления (Авито, Циан, Яндекс, Домклик) и сделки
|
||||
Росреестра по сопоставимым квартирам. Медиана{" "}
|
||||
<b style={{ color: tokens.ink2 }}>₽/м²</b> → 3 оценки:{" "}
|
||||
<b style={{ color: tokens.ink2 }}>
|
||||
рекомендованная цена в объявлении
|
||||
</b>{" "}
|
||||
(asking),{" "}
|
||||
<b style={{ color: tokens.ink2 }}>ожидаемая цена сделки</b> (asking −
|
||||
торг по ДКП),{" "}
|
||||
<b style={{ color: tokens.ink2 }}>ДКП·Росреестр</b> (фактические
|
||||
сделки). CV — коэффициент вариации выборки (разброс цен).
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
{/* location note section */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9,
|
||||
|
|
@ -285,44 +213,22 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
|||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
ЧТО ПОВЫШАЕТ ЦЕНУ
|
||||
ЛОКАЦИЯ
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
|
||||
{locationFactors.positives.map((f, i) => (
|
||||
<FactorRow key={i} factor={f} variant="pos" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* negatives */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9,
|
||||
letterSpacing: "2px",
|
||||
color: tokens.muted2,
|
||||
marginTop: 24,
|
||||
marginBottom: 12,
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.7,
|
||||
color: tokens.body2,
|
||||
background: tokens.infoSoftBg,
|
||||
border: `1px solid ${tokens.infoSoftBorder}`,
|
||||
borderRadius: 7,
|
||||
padding: "12px 14px",
|
||||
}}
|
||||
>
|
||||
ЧТО СНИЖАЕТ ЦЕНУ
|
||||
</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}
|
||||
Коэффициент локации (POI, транспорт, шум) —{" "}
|
||||
<b style={{ color: tokens.ink2 }}>в разработке</b>; текущая оценка по
|
||||
локации не корректируется.
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
HOUSE_TYPE_RU,
|
||||
REPAIR_FROM_RU,
|
||||
REPAIR_RU,
|
||||
type MapMarker,
|
||||
} from "./mappers";
|
||||
import { useGeocodeSuggest } from "@/lib/trade-in-api";
|
||||
import type {
|
||||
|
|
@ -257,6 +258,21 @@ const errorText: CSSProperties = {
|
|||
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 {
|
||||
/** Called with the validated form payload on «ОЦЕНИТЬ КВАРТИРУ». Optional so
|
||||
* the still-unwired v2 page renders without props (tsc-safe). */
|
||||
|
|
@ -267,6 +283,9 @@ interface ParamsPanelProps {
|
|||
error?: string | null;
|
||||
/** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */
|
||||
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
|
||||
|
|
@ -291,6 +310,7 @@ export default function ParamsPanel({
|
|||
isPending = false,
|
||||
error = null,
|
||||
initialValues,
|
||||
markers = [],
|
||||
}: ParamsPanelProps) {
|
||||
const [openDd, setOpenDd] = useState<DdKey>(null);
|
||||
const [address, setAddress] = useState(initialValues?.address ?? "");
|
||||
|
|
@ -366,7 +386,11 @@ export default function ParamsPanel({
|
|||
const trimmedAddress = address.trim();
|
||||
const areaNum = Number(area.replace(",", "."));
|
||||
if (trimmedAddress.length < 3) {
|
||||
setValidationError("Укажите адрес — минимум 3 символа");
|
||||
setValidationError("Укажите адрес квартиры — минимум 3 символа");
|
||||
return;
|
||||
}
|
||||
if (!area.trim()) {
|
||||
setValidationError("Укажите площадь квартиры");
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(areaNum) || areaNum <= 10) {
|
||||
|
|
@ -581,135 +605,16 @@ export default function ParamsPanel({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* analog price pins */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "5%",
|
||||
top: "13%",
|
||||
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,
|
||||
}}
|
||||
>
|
||||
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}`,
|
||||
}}
|
||||
/>
|
||||
{/* analog price pins — projected from the real estimate via mapMarkers().
|
||||
Empty markers (no estimate yet) → no price pins, just the subject pin +
|
||||
radius rings (Finding #2: never the fabricated fixture prices/dots). */}
|
||||
{markers.map((m, i) => (
|
||||
<div key={i} style={{ ...analogPin, left: m.left, top: m.top }}>
|
||||
{m.label}
|
||||
<br />
|
||||
<span style={{ color: tokens.muted }}>{m.sub}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* map controls */}
|
||||
<div
|
||||
|
|
@ -962,7 +867,7 @@ export default function ParamsPanel({
|
|||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSubmit();
|
||||
}}
|
||||
placeholder="54"
|
||||
placeholder="напр. 54"
|
||||
style={inputField}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,23 +4,46 @@
|
|||
// Faithful markup port of the design header (МЕРА Оценка.dc.html, lines 42-90):
|
||||
// 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
|
||||
// own useState. No data fetching — labels/user come from fixtures, colours from
|
||||
// tokens.
|
||||
// own useState. No data fetching — labels/version come from fixtures, the user
|
||||
// identity is fed in from the page (real useMe), colours from tokens.
|
||||
|
||||
import { useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
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 {
|
||||
active: number;
|
||||
onNavigate: (i: number) => void;
|
||||
// «Мои отчёты» 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;
|
||||
// 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 = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
|
@ -33,12 +56,23 @@ const menuItemStyle: CSSProperties = {
|
|||
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({
|
||||
active,
|
||||
onNavigate,
|
||||
reports = user.reports,
|
||||
reports = 0,
|
||||
user,
|
||||
onLogout,
|
||||
}: TopNavProps) {
|
||||
const [userOpen, setUserOpen] = useState(false);
|
||||
const u = user ?? GUEST_USER;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -222,7 +256,7 @@ export default function TopNav({
|
|||
boxShadow: "0 3px 10px rgba(46,139,255,.3)",
|
||||
}}
|
||||
>
|
||||
{user.initials}
|
||||
{u.initials}
|
||||
</div>
|
||||
<div style={{ lineHeight: 1.25 }}>
|
||||
<div
|
||||
|
|
@ -233,7 +267,7 @@ export default function TopNav({
|
|||
fontFamily: tokens.font.sans,
|
||||
}}
|
||||
>
|
||||
{user.name}
|
||||
{u.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -243,7 +277,7 @@ export default function TopNav({
|
|||
letterSpacing: ".5px",
|
||||
}}
|
||||
>
|
||||
{user.org}
|
||||
{u.org}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ color: tokens.muted2, fontSize: "9px" }}>▾</span>
|
||||
|
|
@ -289,7 +323,7 @@ export default function TopNav({
|
|||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{user.initials}
|
||||
{u.initials}
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
|
|
@ -299,15 +333,15 @@ export default function TopNav({
|
|||
color: tokens.ink2,
|
||||
}}
|
||||
>
|
||||
{user.name}
|
||||
{u.name}
|
||||
</div>
|
||||
<div style={{ fontSize: "10px", color: tokens.muted2 }}>
|
||||
{user.email}
|
||||
{u.email}
|
||||
</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">
|
||||
<circle
|
||||
cx="7.5"
|
||||
|
|
@ -347,7 +381,7 @@ export default function TopNav({
|
|||
</span>
|
||||
</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">
|
||||
<circle
|
||||
cx="7.5"
|
||||
|
|
@ -365,7 +399,7 @@ export default function TopNav({
|
|||
Настройки
|
||||
</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">
|
||||
<circle
|
||||
cx="7.5"
|
||||
|
|
@ -392,7 +426,10 @@ export default function TopNav({
|
|||
/>
|
||||
|
||||
<div
|
||||
onClick={() => setUserOpen(false)}
|
||||
onClick={() => {
|
||||
setUserOpen(false);
|
||||
onLogout?.();
|
||||
}}
|
||||
className="tnav-logout"
|
||||
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. */
|
||||
export function mapResultPanel(
|
||||
e: AggregatedEstimate,
|
||||
|
|
@ -765,13 +842,17 @@ export function mapSummary(
|
|||
const anaBargain = kpi?.median_bargain_pct ?? null;
|
||||
const row4Parts = [
|
||||
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);
|
||||
const row4Value = row4Parts.length > 0 ? row4Parts.join(" · ") : "—";
|
||||
|
||||
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,
|
||||
dot: houseSold && houseSold > 0 ? "accent" : "muted",
|
||||
nav: 1,
|
||||
|
|
@ -868,6 +949,22 @@ function pct1(v: number | null | undefined): string {
|
|||
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 -> "—". */
|
||||
function fmtDist(m: number | null | undefined): string {
|
||||
if (m == null || !Number.isFinite(m)) return "—";
|
||||
|
|
@ -942,6 +1039,19 @@ function dealMeta(l: AnalogLot): string {
|
|||
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) ─────────────────────────────────────────
|
||||
|
||||
/** ДКП deal row + real per-row address/date/listing-source (DkpRow has none). */
|
||||
|
|
@ -1030,7 +1140,7 @@ export function mapHistory(
|
|||
ask,
|
||||
delta: pct1(pr.discount_pct),
|
||||
deltaColor,
|
||||
addr: pr.deal_address || undefined,
|
||||
addr: pr.deal_address ? deglueAddr(pr.deal_address) : undefined,
|
||||
date: fmtDate(pr.deal_date),
|
||||
source: pr.listing_source,
|
||||
};
|
||||
|
|
@ -1056,7 +1166,8 @@ function buildAnalyticsKpis(k: HouseAnalyticsKpi | null): AnalyticsKpi[] {
|
|||
},
|
||||
{
|
||||
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,
|
||||
sub: "от начальной до итоговой цены",
|
||||
},
|
||||
|
|
@ -1398,7 +1509,7 @@ export function mapSources(
|
|||
const e = estimate;
|
||||
|
||||
const adRows: AdRowData[] = (e?.analogs ?? []).map((l) => ({
|
||||
addr: l.address || "—",
|
||||
addr: deglueAddr(l.address || "—"),
|
||||
meta: adMeta(l),
|
||||
ppm: numRu(l.price_per_m2),
|
||||
price: numRu(l.price_rub),
|
||||
|
|
@ -1408,7 +1519,7 @@ export function mapSources(
|
|||
}));
|
||||
|
||||
const dealRows: DealRowData[] = (e?.actual_deals ?? []).map((l) => ({
|
||||
addr: l.address || "—",
|
||||
addr: deglueAddr(l.address || "—"),
|
||||
meta: dealMeta(l),
|
||||
ppm: numRu(l.price_per_m2),
|
||||
price: numRu(l.price_rub),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue