Merge pull request 'feat(tradein/v2) [FE-1]: wire main dashboard to real API (mappers + page shell)' (#2048) from feat/tradein-v2-fe1-data-shell into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Successful in 1m52s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 48s
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Successful in 1m52s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 48s
This commit is contained in:
commit
e2ff8958ca
7 changed files with 1504 additions and 136 deletions
|
|
@ -1,7 +1,18 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
// /trade-in/v2 — "МЕРА Оценка" HUD dashboard, wired to the real trade-in API.
|
||||||
import type { CSSProperties } from "react";
|
// This page OWNS the data: it runs the estimate mutation + restore-by-id query,
|
||||||
|
// spawns the dashboard sub-hooks (street deals, house analytics) and feeds the
|
||||||
|
// pixel-perfect HUD components via the pure mappers (mapReport / mapObject /
|
||||||
|
// mapResultPanel / mapSummary). The artboard frame / brackets / 474-1fr-250 grid
|
||||||
|
// are unchanged — only the result/summary areas swap between honest empty / grey
|
||||||
|
// skeleton / inline-error / real-data states. Sub-hooks resolve independently:
|
||||||
|
// a pending or errored street-deals / analytics call degrades its section to a
|
||||||
|
// fallback inside the mapper instead of blanking the page.
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { tokens } from "@/components/trade-in/v2/tokens";
|
import { tokens } from "@/components/trade-in/v2/tokens";
|
||||||
import TopNav from "@/components/trade-in/v2/TopNav";
|
import TopNav from "@/components/trade-in/v2/TopNav";
|
||||||
|
|
@ -12,6 +23,25 @@ import { ObjectSummary } from "@/components/trade-in/v2/ObjectSummary";
|
||||||
import { Footer } from "@/components/trade-in/v2/Footer";
|
import { Footer } from "@/components/trade-in/v2/Footer";
|
||||||
import SectionOverlay from "@/components/trade-in/v2/SectionOverlay";
|
import SectionOverlay from "@/components/trade-in/v2/SectionOverlay";
|
||||||
import { LocationDrawer } from "@/components/trade-in/v2/LocationDrawer";
|
import { LocationDrawer } from "@/components/trade-in/v2/LocationDrawer";
|
||||||
|
import type { ObjectInfo, Report } from "@/components/trade-in/v2/types";
|
||||||
|
import {
|
||||||
|
mapObject,
|
||||||
|
mapReport,
|
||||||
|
mapResultPanel,
|
||||||
|
mapSummary,
|
||||||
|
} from "@/components/trade-in/v2/mappers";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AggregatedEstimate,
|
||||||
|
TradeInEstimateInput,
|
||||||
|
} from "@/types/trade-in";
|
||||||
|
import { HTTPError } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
useEstimate,
|
||||||
|
useEstimateHouseAnalytics,
|
||||||
|
useEstimateMutation,
|
||||||
|
useStreetDeals,
|
||||||
|
} from "@/lib/trade-in-api";
|
||||||
|
|
||||||
// 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
|
||||||
|
|
@ -64,9 +94,408 @@ const brackets: { key: string; style: CSSProperties }[] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Honest neutral fallbacks for the meta blocks (HeroBar / Footer) before there
|
||||||
|
// is an estimate. Dashes — never the design fixtures (which would read as a fake
|
||||||
|
// real report).
|
||||||
|
const EMPTY_REPORT: Report = { id: "—", date: "—", validUntil: "—" };
|
||||||
|
const EMPTY_OBJECT: ObjectInfo = {
|
||||||
|
address: "—",
|
||||||
|
city: "",
|
||||||
|
area: "—",
|
||||||
|
rooms: "—",
|
||||||
|
floor: "—",
|
||||||
|
totalFloors: "—",
|
||||||
|
year: "—",
|
||||||
|
houseType: "—",
|
||||||
|
repair: "—",
|
||||||
|
balcony: false,
|
||||||
|
locationCoef: "—",
|
||||||
|
streetView: "",
|
||||||
|
compass: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Skeleton pulse (opacity only — NO shimmer sweep, per .claude/rules/ui-*) +
|
||||||
|
// retry-button hover.
|
||||||
|
const HELPER_STYLES = `
|
||||||
|
@keyframes v2-skel-pulse{0%{opacity:.45}50%{opacity:.78}100%{opacity:.45}}
|
||||||
|
.v2-skel{animation:v2-skel-pulse 1.5s ease-in-out infinite}
|
||||||
|
.v2-retry-btn{transition:all .15s;cursor:pointer}
|
||||||
|
.v2-retry-btn:hover{border-color:${tokens.accent};color:${tokens.accent};background:rgba(46,139,255,.07)}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const retryBtnStyle: CSSProperties = {
|
||||||
|
padding: "8px 16px",
|
||||||
|
background: "transparent",
|
||||||
|
border: `1px solid ${tokens.line}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
fontFamily: tokens.font.sans,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
color: tokens.muted,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Result/summary area state panels (kept inside the page — these are layout
|
||||||
|
// states, not reusable design components, so they do not belong in v2/). ───
|
||||||
|
|
||||||
|
function SkeletonBlock({
|
||||||
|
height,
|
||||||
|
flex,
|
||||||
|
}: {
|
||||||
|
height?: number;
|
||||||
|
flex?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="v2-skel"
|
||||||
|
style={{
|
||||||
|
height,
|
||||||
|
flex,
|
||||||
|
minHeight: 0,
|
||||||
|
background: tokens.surface.w50,
|
||||||
|
border: `1px solid ${tokens.line2}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultSkeleton() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 14,
|
||||||
|
minWidth: 0,
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SkeletonBlock height={18} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr 1fr",
|
||||||
|
gap: 14,
|
||||||
|
flex: "0 0 auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SkeletonBlock height={150} />
|
||||||
|
<SkeletonBlock height={150} />
|
||||||
|
<SkeletonBlock height={150} />
|
||||||
|
</div>
|
||||||
|
<SkeletonBlock height={150} />
|
||||||
|
<SkeletonBlock flex={1} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummarySkeleton() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="v2-skel"
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
background: tokens.surface.w50,
|
||||||
|
border: `1px solid ${tokens.line2}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaceholderPanel({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
textAlign: "center",
|
||||||
|
gap: 12,
|
||||||
|
background: tokens.surface.w50,
|
||||||
|
backdropFilter: "blur(6px)",
|
||||||
|
border: `1px solid ${tokens.line2}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "40px 28px",
|
||||||
|
fontFamily: tokens.font.sans,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: 2,
|
||||||
|
color: tokens.ink,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: tokens.muted,
|
||||||
|
maxWidth: 360,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyResultPanel() {
|
||||||
|
return (
|
||||||
|
<PlaceholderPanel
|
||||||
|
title="РЕЗУЛЬТАТ ОЦЕНКИ"
|
||||||
|
body="Заполните параметры квартиры слева и нажмите «ОЦЕНИТЬ КВАРТИРУ» — здесь появится рыночная оценка, диапазоны цен и источники данных."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InsufficientPanel() {
|
||||||
|
return (
|
||||||
|
<PlaceholderPanel
|
||||||
|
title="НЕДОСТАТОЧНО ДАННЫХ"
|
||||||
|
body="По этому адресу не нашлось достаточного числа аналогов и сделок для надёжной оценки. Уточните адрес или параметры квартиры."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RestoreErrorPanel({
|
||||||
|
notFound,
|
||||||
|
onRetry,
|
||||||
|
onNew,
|
||||||
|
}: {
|
||||||
|
notFound: boolean;
|
||||||
|
onRetry: () => void;
|
||||||
|
onNew: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PlaceholderPanel
|
||||||
|
title={notFound ? "ОТЧЁТ НЕ НАЙДЕН" : "ОШИБКА ЗАГРУЗКИ"}
|
||||||
|
body={
|
||||||
|
notFound
|
||||||
|
? "Ссылка устарела, отчёт удалён или у вас нет к нему доступа."
|
||||||
|
: "Не удалось загрузить отчёт. Попробуйте ещё раз."
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", gap: 10, marginTop: 4 }}>
|
||||||
|
{!notFound && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="v2-retry-btn"
|
||||||
|
onClick={onRetry}
|
||||||
|
style={retryBtnStyle}
|
||||||
|
>
|
||||||
|
ПОВТОРИТЬ
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="v2-retry-btn"
|
||||||
|
onClick={onNew}
|
||||||
|
style={retryBtnStyle}
|
||||||
|
>
|
||||||
|
НОВАЯ ОЦЕНКА
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</PlaceholderPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryPlaceholder() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
textAlign: "center",
|
||||||
|
gap: 8,
|
||||||
|
background: tokens.surface.w50,
|
||||||
|
backdropFilter: "blur(6px)",
|
||||||
|
border: `1px solid ${tokens.line2}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "28px 18px",
|
||||||
|
fontFamily: tokens.font.sans,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
color: tokens.ink,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
СВОДКА ОБЪЕКТА
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: tokens.muted, lineHeight: 1.5 }}>
|
||||||
|
Появится после оценки квартиры.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore-by-id: read ?id= from the URL with an SSR guard. Mirrors v1 (app/
|
||||||
|
// page.tsx) deliberately — next/navigation useSearchParams would force a
|
||||||
|
// Suspense boundary and break `next build`.
|
||||||
|
function readUrlId(): string | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return new URLSearchParams(window.location.search).get("id");
|
||||||
|
}
|
||||||
|
|
||||||
export default function TradeInV2Page() {
|
export default function TradeInV2Page() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const [nav, setNav] = useState(0);
|
const [nav, setNav] = useState(0);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
// Locally-held just-computed estimate (so we render instantly after submit
|
||||||
|
// without a round-trip through the restore query).
|
||||||
|
const [freshResult, setFreshResult] = useState<AggregatedEstimate | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const urlId = readUrlId();
|
||||||
|
|
||||||
|
const mutation = useEstimateMutation();
|
||||||
|
// Skip the restore fetch once we already hold a fresh result.
|
||||||
|
const restored = useEstimate(freshResult === null ? urlId : null);
|
||||||
|
|
||||||
|
const estimate: AggregatedEstimate | null = freshResult ?? restored.data ?? null;
|
||||||
|
const currentEstimateId = freshResult?.estimate_id ?? urlId;
|
||||||
|
|
||||||
|
// Dashboard sub-hooks — each resolves independently; failure degrades its
|
||||||
|
// section via the mappers (null input) rather than blanking the page.
|
||||||
|
const streetDeals = useStreetDeals(
|
||||||
|
estimate?.target_address ?? null,
|
||||||
|
estimate?.area_m2 ?? null,
|
||||||
|
estimate?.rooms ?? null,
|
||||||
|
);
|
||||||
|
const analytics = useEstimateHouseAnalytics(currentEstimateId);
|
||||||
|
|
||||||
|
const streetDealsData = streetDeals.data ?? null;
|
||||||
|
const analyticsData = analytics.data ?? null;
|
||||||
|
|
||||||
|
// ── Derived state flags ───────────────────────────────────────────────────
|
||||||
|
const restoreActive = urlId !== null && freshResult === null;
|
||||||
|
const restoreLoading = restoreActive && restored.isPending;
|
||||||
|
const restoreError = restoreActive && restored.isError;
|
||||||
|
const restoreNotFound =
|
||||||
|
restoreError &&
|
||||||
|
restored.error instanceof HTTPError &&
|
||||||
|
restored.error.status === 404;
|
||||||
|
|
||||||
|
const loading = mutation.isPending || restoreLoading;
|
||||||
|
const insufficient =
|
||||||
|
estimate != null && (estimate.insufficient_data || estimate.n_analogs === 0);
|
||||||
|
const apiError = mutation.error?.message ?? null;
|
||||||
|
|
||||||
|
// ── Mapped presentation data (memoised so nav/drawer toggles don't recompute
|
||||||
|
// geometry). ──────────────────────────────────────────────────────────
|
||||||
|
const report = useMemo(
|
||||||
|
() => (estimate ? mapReport(estimate) : EMPTY_REPORT),
|
||||||
|
[estimate],
|
||||||
|
);
|
||||||
|
const objectInfo = useMemo(
|
||||||
|
() => (estimate ? mapObject(estimate) : EMPTY_OBJECT),
|
||||||
|
[estimate],
|
||||||
|
);
|
||||||
|
const resultPanelData = useMemo(
|
||||||
|
() => (estimate ? mapResultPanel(estimate, streetDealsData) : null),
|
||||||
|
[estimate, streetDealsData],
|
||||||
|
);
|
||||||
|
const summaryData = useMemo(
|
||||||
|
() => (estimate ? mapSummary(estimate, analyticsData, streetDealsData) : null),
|
||||||
|
[estimate, analyticsData, streetDealsData],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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>(
|
||||||
|
() =>
|
||||||
|
estimate
|
||||||
|
? {
|
||||||
|
address: estimate.target_address ?? undefined,
|
||||||
|
area_m2: estimate.area_m2 ?? undefined,
|
||||||
|
rooms: estimate.rooms ?? undefined,
|
||||||
|
floor: estimate.floor,
|
||||||
|
total_floors: estimate.total_floors,
|
||||||
|
year_built: estimate.year_built ?? undefined,
|
||||||
|
house_type: estimate.house_type ?? undefined,
|
||||||
|
repair_state: estimate.repair_state ?? undefined,
|
||||||
|
has_balcony: estimate.has_balcony ?? undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
[estimate],
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSubmit(input: TradeInEstimateInput) {
|
||||||
|
mutation.mutate(input, {
|
||||||
|
onSuccess: (est) => {
|
||||||
|
setFreshResult(est);
|
||||||
|
// basePath auto-prefixes → /trade-in/v2?id=...
|
||||||
|
router.replace(`/v2?id=${est.estimate_id}`, { scroll: false });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRetry() {
|
||||||
|
void restored.refetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNew() {
|
||||||
|
setFreshResult(null);
|
||||||
|
router.replace("/v2", { scroll: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
let middleContent: ReactNode;
|
||||||
|
if (loading) {
|
||||||
|
middleContent = <ResultSkeleton />;
|
||||||
|
} else if (restoreError) {
|
||||||
|
middleContent = (
|
||||||
|
<RestoreErrorPanel
|
||||||
|
notFound={restoreNotFound}
|
||||||
|
onRetry={handleRetry}
|
||||||
|
onNew={handleNew}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (estimate && !insufficient && resultPanelData) {
|
||||||
|
middleContent = <ResultPanel data={resultPanelData} onNavigate={setNav} />;
|
||||||
|
} else if (estimate && insufficient) {
|
||||||
|
middleContent = <InsufficientPanel />;
|
||||||
|
} else {
|
||||||
|
middleContent = <EmptyResultPanel />;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rightContent: ReactNode;
|
||||||
|
if (loading) {
|
||||||
|
rightContent = <SummarySkeleton />;
|
||||||
|
} else if (estimate && !insufficient && summaryData) {
|
||||||
|
rightContent = (
|
||||||
|
<ObjectSummary
|
||||||
|
data={{ object: objectInfo, summary: summaryData }}
|
||||||
|
onNavigate={setNav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
rightContent = <SummaryPlaceholder />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -80,6 +509,8 @@ export default function TradeInV2Page() {
|
||||||
color: tokens.ink,
|
color: tokens.ink,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<style>{HELPER_STYLES}</style>
|
||||||
|
|
||||||
{/* OUTER HUD FRAME */}
|
{/* OUTER HUD FRAME */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -116,7 +547,10 @@ export default function TradeInV2Page() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TopNav active={nav} onNavigate={setNav} />
|
<TopNav active={nav} onNavigate={setNav} />
|
||||||
<HeroBar onOpenInfo={() => setDrawerOpen(true)} />
|
<HeroBar
|
||||||
|
data={{ report, object: objectInfo }}
|
||||||
|
onOpenInfo={() => setDrawerOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -127,12 +561,18 @@ export default function TradeInV2Page() {
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ParamsPanel />
|
<ParamsPanel
|
||||||
<ResultPanel onNavigate={setNav} />
|
key={estimate?.estimate_id ?? "new"}
|
||||||
<ObjectSummary onNavigate={setNav} />
|
onSubmit={handleSubmit}
|
||||||
|
isPending={mutation.isPending}
|
||||||
|
error={apiError}
|
||||||
|
initialValues={initialValues}
|
||||||
|
/>
|
||||||
|
{middleContent}
|
||||||
|
{rightContent}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Footer />
|
<Footer data={report} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{nav !== 0 && (
|
{nav !== 0 && (
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,13 @@
|
||||||
|
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { report, version } from "./fixtures";
|
import { report, version } from "./fixtures";
|
||||||
|
import type { Report } from "./types";
|
||||||
|
|
||||||
export function Footer() {
|
interface FooterProps {
|
||||||
|
data?: Report;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Footer({ data = report }: FooterProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -25,14 +30,14 @@ export function Footer() {
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", gap: 22 }}>
|
<div style={{ display: "flex", gap: 22 }}>
|
||||||
<span>
|
<span>
|
||||||
ОТЧЁТ <span style={{ color: tokens.muted }}>{report.id}</span>
|
ОТЧЁТ <span style={{ color: tokens.muted }}>{data.id}</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
ДАТА <span style={{ color: tokens.muted }}>{report.date}</span>
|
ДАТА <span style={{ color: tokens.muted }}>{data.date}</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
ДЕЙСТВИТЕЛЕН ДО{" "}
|
ДЕЙСТВИТЕЛЕН ДО{" "}
|
||||||
<span style={{ color: tokens.muted }}>{report.validUntil}</span>
|
<span style={{ color: tokens.muted }}>{data.validUntil}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,20 @@ import Image from "next/image";
|
||||||
|
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { object, report } from "./fixtures";
|
import { object, report } from "./fixtures";
|
||||||
|
import type { HeroBarData } from "./mappers";
|
||||||
|
|
||||||
|
// Default presentation data (unwired usage): the existing design fixtures.
|
||||||
|
const HERO_FIXTURE: HeroBarData = { report, object };
|
||||||
|
|
||||||
interface HeroBarProps {
|
interface HeroBarProps {
|
||||||
|
data?: HeroBarData;
|
||||||
onOpenInfo: () => void;
|
onOpenInfo: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HeroBar({ onOpenInfo }: HeroBarProps) {
|
export default function HeroBar({
|
||||||
|
data = HERO_FIXTURE,
|
||||||
|
onOpenInfo,
|
||||||
|
}: HeroBarProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -57,7 +65,7 @@ export default function HeroBar({ onOpenInfo }: HeroBarProps) {
|
||||||
color: tokens.ink,
|
color: tokens.ink,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{report.id}
|
{data.report.id}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|
@ -83,7 +91,7 @@ export default function HeroBar({ onOpenInfo }: HeroBarProps) {
|
||||||
color: tokens.ink,
|
color: tokens.ink,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{report.date}
|
{data.report.date}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|
@ -109,7 +117,7 @@ export default function HeroBar({ onOpenInfo }: HeroBarProps) {
|
||||||
color: tokens.ink,
|
color: tokens.ink,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{report.validUntil}
|
{data.report.validUntil}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -252,12 +260,12 @@ export default function HeroBar({ onOpenInfo }: HeroBarProps) {
|
||||||
АДРЕС
|
АДРЕС
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 14, fontWeight: 600, lineHeight: 1.3 }}>
|
<div style={{ fontSize: 14, fontWeight: 600, lineHeight: 1.3 }}>
|
||||||
{object.address}
|
{data.object.address}
|
||||||
<br />
|
<br />
|
||||||
<span
|
<span
|
||||||
style={{ fontSize: 11, fontWeight: 400, color: tokens.muted }}
|
style={{ fontSize: 11, fontWeight: 400, color: tokens.muted }}
|
||||||
>
|
>
|
||||||
{object.city}
|
{data.object.city}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|
@ -271,7 +279,7 @@ export default function HeroBar({ onOpenInfo }: HeroBarProps) {
|
||||||
lineHeight: 1.55,
|
lineHeight: 1.55,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{object.streetView}
|
{data.object.streetView}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{ height: 1, background: tokens.lineSoft, margin: "8px 0" }}
|
style={{ height: 1, background: tokens.lineSoft, margin: "8px 0" }}
|
||||||
|
|
@ -308,7 +316,7 @@ export default function HeroBar({ onOpenInfo }: HeroBarProps) {
|
||||||
color: tokens.accent,
|
color: tokens.accent,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{object.locationCoef}
|
{data.object.locationCoef}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -356,7 +364,7 @@ export default function HeroBar({ onOpenInfo }: HeroBarProps) {
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{object.compass}
|
{data.object.compass}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,20 @@
|
||||||
|
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { object, summary } from "./fixtures";
|
import { object, summary } from "./fixtures";
|
||||||
|
import type { ObjectSummaryData } from "./mappers";
|
||||||
|
|
||||||
|
// Default presentation data (unwired usage): the existing design fixtures.
|
||||||
|
const OBJECT_SUMMARY_FIXTURE: ObjectSummaryData = { object, summary };
|
||||||
|
|
||||||
interface ObjectSummaryProps {
|
interface ObjectSummaryProps {
|
||||||
|
data?: ObjectSummaryData;
|
||||||
onNavigate: (i: number) => void;
|
onNavigate: (i: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ObjectSummary({ onNavigate }: ObjectSummaryProps) {
|
export function ObjectSummary({
|
||||||
|
data = OBJECT_SUMMARY_FIXTURE,
|
||||||
|
onNavigate,
|
||||||
|
}: ObjectSummaryProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -132,9 +140,9 @@ export function ObjectSummary({ onNavigate }: ObjectSummaryProps) {
|
||||||
<circle cx="7.5" cy="6.5" r="2.3" fill={tokens.accent} />
|
<circle cx="7.5" cy="6.5" r="2.3" fill={tokens.accent} />
|
||||||
</svg>
|
</svg>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{object.address}</div>
|
<div style={{ fontSize: 13, fontWeight: 600 }}>{data.object.address}</div>
|
||||||
<div style={{ fontSize: 11, color: tokens.muted, marginTop: 2 }}>
|
<div style={{ fontSize: 11, color: tokens.muted, marginTop: 2 }}>
|
||||||
{object.city}
|
{data.object.city}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
|
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
|
||||||
|
|
@ -148,7 +156,7 @@ export function ObjectSummary({ onNavigate }: ObjectSummaryProps) {
|
||||||
|
|
||||||
{/* Per-section totals (clickable) */}
|
{/* Per-section totals (clickable) */}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
|
||||||
{summary.rows.map((row, i) => (
|
{data.summary.rows.map((row, i) => (
|
||||||
<div
|
<div
|
||||||
key={row.label}
|
key={row.label}
|
||||||
className="os-row"
|
className="os-row"
|
||||||
|
|
@ -159,7 +167,7 @@ export function ObjectSummary({ onNavigate }: ObjectSummaryProps) {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
padding: "9px 6px",
|
padding: "9px 6px",
|
||||||
borderBottom:
|
borderBottom:
|
||||||
i < summary.rows.length - 1
|
i < data.summary.rows.length - 1
|
||||||
? `1px dotted ${tokens.lineGradient}`
|
? `1px dotted ${tokens.lineGradient}`
|
||||||
: undefined,
|
: undefined,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
|
|
@ -262,7 +270,7 @@ export function ObjectSummary({ onNavigate }: ObjectSummaryProps) {
|
||||||
>
|
>
|
||||||
<span>Источников</span>
|
<span>Источников</span>
|
||||||
<span style={{ fontFamily: tokens.font.mono, color: tokens.ink }}>
|
<span style={{ fontFamily: tokens.font.mono, color: tokens.ink }}>
|
||||||
{summary.quality.sources}
|
{data.summary.quality.sources}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|
@ -276,7 +284,7 @@ export function ObjectSummary({ onNavigate }: ObjectSummaryProps) {
|
||||||
>
|
>
|
||||||
<span>Достоверность</span>
|
<span>Достоверность</span>
|
||||||
<span style={{ color: tokens.ink }}>
|
<span style={{ color: tokens.ink }}>
|
||||||
{summary.quality.confidence}
|
{data.summary.quality.confidence}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|
@ -292,7 +300,7 @@ export function ObjectSummary({ onNavigate }: ObjectSummaryProps) {
|
||||||
<span
|
<span
|
||||||
style={{ fontFamily: tokens.font.mono, color: tokens.accent }}
|
style={{ fontFamily: tokens.font.mono, color: tokens.accent }}
|
||||||
>
|
>
|
||||||
{summary.quality.cv}
|
{data.summary.quality.cv}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,31 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// 01 ПАРАМЕТРЫ КВАРТИРЫ — left card of the /trade-in/v2 "МЕРА Оценка" port.
|
// 01 ПАРАМЕТРЫ КВАРТИРЫ — left card of the /trade-in/v2 "МЕРА Оценка" port.
|
||||||
// Faithful markup port (МЕРА Оценка.dc.html lines 146-240). Markup-first: all
|
// Faithful markup port (МЕРА Оценка.dc.html lines 146-240). Wire phase: this is
|
||||||
// values come from fixtures (no API). Dropdowns / balcony toggle drive local UI
|
// now a CONTROLLED form. Each field is local useState producing a
|
||||||
// state only. Hover/active + @keyframes live in a pp-prefixed local <style>.
|
// TradeInEstimateInput that is handed to onSubmit; the visual markup / layout /
|
||||||
|
// styles are UNCHANGED — only the data plumbing differs (display <div>s became
|
||||||
|
// <input>s styled identically, dropdowns now feed real enum values). RU dropdown
|
||||||
|
// labels <-> API enum values go through HOUSE_TYPE_*/REPAIR_* maps in ./mappers.
|
||||||
|
// The РАДИУС and CRM dropdowns have no backend yet → kept visually but disabled
|
||||||
|
// (see TODOs). Hover/active + @keyframes live in a pp-prefixed local <style>.
|
||||||
|
|
||||||
import { useState, type CSSProperties } from "react";
|
import { useState, type CSSProperties } from "react";
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { object, dropdownOptions } from "./fixtures";
|
import { dropdownOptions } from "./fixtures";
|
||||||
|
import {
|
||||||
|
HOUSE_TYPE_FROM_RU,
|
||||||
|
HOUSE_TYPE_RU,
|
||||||
|
REPAIR_FROM_RU,
|
||||||
|
REPAIR_RU,
|
||||||
|
} from "./mappers";
|
||||||
|
import type {
|
||||||
|
HouseType,
|
||||||
|
RepairState,
|
||||||
|
TradeInEstimateInput,
|
||||||
|
} from "@/types/trade-in";
|
||||||
|
|
||||||
type DdKey = "rooms" | "houseType" | "repair" | "radius" | "crm" | null;
|
type DdKey = "rooms" | "houseType" | "repair" | null;
|
||||||
|
|
||||||
interface DdProps {
|
interface DdProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -154,26 +170,115 @@ const fieldRow: CSSProperties = {
|
||||||
gap: 12,
|
gap: 12,
|
||||||
};
|
};
|
||||||
|
|
||||||
const textField: CSSProperties = {
|
// Numeric text input — visually identical to the former display <div> (textField)
|
||||||
|
// but as a real <input>: inputs do NOT inherit font-family, so it is set
|
||||||
|
// explicitly; outline removed to keep the HUD look (focus ring would break it).
|
||||||
|
const inputField: CSSProperties = {
|
||||||
height: 33,
|
height: 33,
|
||||||
|
width: "100%",
|
||||||
|
boxSizing: "border-box",
|
||||||
background: tokens.surface.w62,
|
background: tokens.surface.w62,
|
||||||
border: `1px solid ${tokens.line}`,
|
border: `1px solid ${tokens.line}`,
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: "0 13px",
|
padding: "0 13px",
|
||||||
fontFamily: tokens.font.mono,
|
fontFamily: tokens.font.mono,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
color: tokens.ink,
|
||||||
|
outline: "none",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ParamsPanel() {
|
// Address input (sans, room on the right for the decorative search glyph).
|
||||||
|
const addressField: CSSProperties = {
|
||||||
|
height: 33,
|
||||||
|
width: "100%",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
background: tokens.surface.w62,
|
||||||
|
border: `1px solid ${tokens.line}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "0 38px 0 13px",
|
||||||
|
fontFamily: tokens.font.sans,
|
||||||
|
fontSize: 13,
|
||||||
|
color: tokens.ink,
|
||||||
|
outline: "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inline validation / server error — only rendered when there is an error, so
|
||||||
|
// the happy-path layout is pixel-unchanged.
|
||||||
|
const errorText: CSSProperties = {
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 10.5,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
color: tokens.danger,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ParamsPanelProps {
|
||||||
|
/** Called with the validated form payload on «ОЦЕНИТЬ КВАРТИРУ». Optional so
|
||||||
|
* the still-unwired v2 page renders without props (tsc-safe). */
|
||||||
|
onSubmit?: (input: TradeInEstimateInput) => void;
|
||||||
|
/** Disables the button + shows a pending label while the estimate is running. */
|
||||||
|
isPending?: boolean;
|
||||||
|
/** Server-side error to surface inline (validation errors are handled locally). */
|
||||||
|
error?: string | null;
|
||||||
|
/** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */
|
||||||
|
initialValues?: Partial<TradeInEstimateInput>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// rooms number -> dropdown label. The design has no «Студия» option, so studio
|
||||||
|
// (0) and 1-room both map to "1"; >=5 collapses to "5+". null -> design default.
|
||||||
|
function initRoomsLabel(rooms: number | null | undefined): string {
|
||||||
|
if (rooms == null) return "2";
|
||||||
|
if (rooms >= 5) return "5+";
|
||||||
|
if (rooms <= 1) return "1";
|
||||||
|
return String(rooms);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initHouseTypeLabel(ht: HouseType | undefined): string {
|
||||||
|
return ht ? HOUSE_TYPE_RU[ht] : "Панельный";
|
||||||
|
}
|
||||||
|
|
||||||
|
function initRepairLabel(rs: RepairState | undefined): string {
|
||||||
|
return rs ? REPAIR_RU[rs] : "Хороший";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ParamsPanel({
|
||||||
|
onSubmit,
|
||||||
|
isPending = false,
|
||||||
|
error = null,
|
||||||
|
initialValues,
|
||||||
|
}: ParamsPanelProps) {
|
||||||
const [openDd, setOpenDd] = useState<DdKey>(null);
|
const [openDd, setOpenDd] = useState<DdKey>(null);
|
||||||
const [rooms, setRooms] = useState(object.rooms);
|
const [address, setAddress] = useState(initialValues?.address ?? "");
|
||||||
const [houseType, setHouseType] = useState(object.houseType);
|
const [area, setArea] = useState(
|
||||||
const [repair, setRepair] = useState(object.repair);
|
initialValues?.area_m2 != null ? String(initialValues.area_m2) : "",
|
||||||
const [radius, setRadius] = useState("500 м");
|
);
|
||||||
const [crm, setCrm] = useState(dropdownOptions.crm[0]);
|
const [rooms, setRooms] = useState(initRoomsLabel(initialValues?.rooms));
|
||||||
const [balcony, setBalcony] = useState(object.balcony);
|
const [floor, setFloor] = useState(
|
||||||
|
initialValues?.floor != null ? String(initialValues.floor) : "",
|
||||||
|
);
|
||||||
|
const [totalFloors, setTotalFloors] = useState(
|
||||||
|
initialValues?.total_floors != null
|
||||||
|
? String(initialValues.total_floors)
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
const [year, setYear] = useState(
|
||||||
|
initialValues?.year_built != null ? String(initialValues.year_built) : "",
|
||||||
|
);
|
||||||
|
const [houseType, setHouseType] = useState(
|
||||||
|
initHouseTypeLabel(initialValues?.house_type),
|
||||||
|
);
|
||||||
|
const [repair, setRepair] = useState(
|
||||||
|
initRepairLabel(initialValues?.repair_state),
|
||||||
|
);
|
||||||
|
const [balcony, setBalcony] = useState(initialValues?.has_balcony ?? true);
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// TODO(wire): радиус анализа пока не выбирается (бэкенд не поддерживает) —
|
||||||
|
// дропдаун оставлен визуально, но non-functional.
|
||||||
|
const radius = "500 м";
|
||||||
|
// TODO(#395): CRM-интеграция не подключена — дропдаун присутствует визуально,
|
||||||
|
// но неактивен (выбор ничего не делает).
|
||||||
|
const crm = dropdownOptions.crm[0];
|
||||||
|
|
||||||
const toggle = (k: Exclude<DdKey, null>) =>
|
const toggle = (k: Exclude<DdKey, null>) =>
|
||||||
setOpenDd((cur) => (cur === k ? null : k));
|
setOpenDd((cur) => (cur === k ? null : k));
|
||||||
|
|
@ -182,6 +287,31 @@ export default function ParamsPanel() {
|
||||||
setOpenDd(null);
|
setOpenDd(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const trimmedAddress = address.trim();
|
||||||
|
const areaNum = Number(area.replace(",", "."));
|
||||||
|
if (trimmedAddress.length < 3) {
|
||||||
|
setValidationError("Укажите адрес — минимум 3 символа");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(areaNum) || areaNum <= 10) {
|
||||||
|
setValidationError("Площадь должна быть больше 10 м²");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setValidationError(null);
|
||||||
|
onSubmit?.({
|
||||||
|
address: trimmedAddress,
|
||||||
|
area_m2: areaNum,
|
||||||
|
rooms: rooms === "5+" ? 5 : Number(rooms),
|
||||||
|
floor: floor.trim() ? Number(floor) : null,
|
||||||
|
total_floors: totalFloors.trim() ? Number(totalFloors) : null,
|
||||||
|
year_built: year.trim() ? Number(year) : undefined,
|
||||||
|
house_type: HOUSE_TYPE_FROM_RU[houseType],
|
||||||
|
repair_state: REPAIR_FROM_RU[repair],
|
||||||
|
has_balcony: balcony,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const balOn: CSSProperties = {
|
const balOn: CSSProperties = {
|
||||||
border: `1px solid ${tokens.accent}`,
|
border: `1px solid ${tokens.accent}`,
|
||||||
color: tokens.accent,
|
color: tokens.accent,
|
||||||
|
|
@ -359,7 +489,7 @@ export default function ParamsPanel() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span style={{ fontSize: 11, fontWeight: 600 }}>
|
<span style={{ fontSize: 11, fontWeight: 600 }}>
|
||||||
{object.address}
|
{address.trim() || "Адрес квартиры"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|
@ -602,9 +732,10 @@ export default function ParamsPanel() {
|
||||||
<span style={{ fontSize: 10, letterSpacing: 1.5, color: tokens.muted }}>
|
<span style={{ fontSize: 10, letterSpacing: 1.5, color: tokens.muted }}>
|
||||||
РАДИУС АНАЛИЗА
|
РАДИУС АНАЛИЗА
|
||||||
</span>
|
</span>
|
||||||
|
{/* РАДИУС — non-functional (no backend for radius selection yet). Markup
|
||||||
|
kept; trigger is static (no toggle / dropdown panel). TODO(wire). */}
|
||||||
<div style={{ position: "relative" }}>
|
<div style={{ position: "relative" }}>
|
||||||
<div
|
<div
|
||||||
onClick={() => toggle("radius")}
|
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|
@ -615,48 +746,12 @@ export default function ParamsPanel() {
|
||||||
padding: "6px 10px",
|
padding: "6px 10px",
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: tokens.font.mono,
|
fontFamily: tokens.font.mono,
|
||||||
cursor: "pointer",
|
cursor: "default",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{radius}</span>
|
<span>{radius}</span>
|
||||||
<span style={{ color: tokens.muted2, fontSize: 9 }}>▼</span>
|
<span style={{ color: tokens.muted2, fontSize: 9 }}>▼</span>
|
||||||
</div>
|
</div>
|
||||||
{openDd === "radius" && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: "calc(100% + 4px)",
|
|
||||||
background: tokens.surface.w98,
|
|
||||||
border: `1px solid ${tokens.line}`,
|
|
||||||
borderRadius: 6,
|
|
||||||
boxShadow: "0 10px 26px rgba(40,80,130,.18)",
|
|
||||||
zIndex: 30,
|
|
||||||
padding: 3,
|
|
||||||
minWidth: 78,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{dropdownOptions.radius.map((o) => (
|
|
||||||
<div
|
|
||||||
key={o}
|
|
||||||
className="pp-dd-opt"
|
|
||||||
onClick={() => pick(setRadius)(o)}
|
|
||||||
style={{
|
|
||||||
padding: "7px 10px",
|
|
||||||
fontSize: 13,
|
|
||||||
fontFamily: tokens.font.mono,
|
|
||||||
borderRadius: 4,
|
|
||||||
cursor: "pointer",
|
|
||||||
color: o === radius ? tokens.accent : tokens.ink,
|
|
||||||
fontWeight: o === radius ? 600 : 400,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{o}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -707,21 +802,18 @@ export default function ParamsPanel() {
|
||||||
<div>
|
<div>
|
||||||
<div style={hintLabel}>АДРЕС (YANDEX / NOMINATIM)</div>
|
<div style={hintLabel}>АДРЕС (YANDEX / NOMINATIM)</div>
|
||||||
<div style={{ position: "relative" }}>
|
<div style={{ position: "relative" }}>
|
||||||
<div
|
{/* TODO(FE-5): address autocomplete (Yandex / Nominatim) — out of
|
||||||
style={{
|
scope here; plain controlled input for now. */}
|
||||||
height: 33,
|
<input
|
||||||
background: tokens.surface.w62,
|
type="text"
|
||||||
border: `1px solid ${tokens.line}`,
|
value={address}
|
||||||
borderRadius: 6,
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
display: "flex",
|
onKeyDown={(e) => {
|
||||||
alignItems: "center",
|
if (e.key === "Enter") handleSubmit();
|
||||||
padding: "0 38px 0 13px",
|
|
||||||
fontSize: 13,
|
|
||||||
color: tokens.ink,
|
|
||||||
}}
|
}}
|
||||||
>
|
placeholder="Город, улица, дом"
|
||||||
{object.address} · Куйбышева, 48…
|
style={addressField}
|
||||||
</div>
|
/>
|
||||||
<svg
|
<svg
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|
@ -746,7 +838,19 @@ export default function ParamsPanel() {
|
||||||
<div style={fieldRow}>
|
<div style={fieldRow}>
|
||||||
<div>
|
<div>
|
||||||
<div style={hintLabel}>ПЛОЩАДЬ, М²</div>
|
<div style={hintLabel}>ПЛОЩАДЬ, М²</div>
|
||||||
<div style={textField}>{object.area}</div>
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={area}
|
||||||
|
onChange={(e) =>
|
||||||
|
setArea(e.target.value.replace(/[^\d.,]/g, ""))
|
||||||
|
}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleSubmit();
|
||||||
|
}}
|
||||||
|
placeholder="54"
|
||||||
|
style={inputField}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={hintLabel}>КОМНАТ</div>
|
<div style={hintLabel}>КОМНАТ</div>
|
||||||
|
|
@ -769,14 +873,36 @@ export default function ParamsPanel() {
|
||||||
<span style={hintLabel}>ЭТАЖ</span>
|
<span style={hintLabel}>ЭТАЖ</span>
|
||||||
<span style={optHint}>если знаешь</span>
|
<span style={optHint}>если знаешь</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={textField}>{object.floor}</div>
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={floor}
|
||||||
|
onChange={(e) => setFloor(e.target.value.replace(/\D/g, ""))}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleSubmit();
|
||||||
|
}}
|
||||||
|
placeholder="—"
|
||||||
|
style={inputField}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||||
<span style={hintLabel}>ВСЕГО ЭТАЖЕЙ</span>
|
<span style={hintLabel}>ВСЕГО ЭТАЖЕЙ</span>
|
||||||
<span style={optHint}>если знаешь</span>
|
<span style={optHint}>если знаешь</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={textField}>{object.totalFloors}</div>
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={totalFloors}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTotalFloors(e.target.value.replace(/\D/g, ""))
|
||||||
|
}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleSubmit();
|
||||||
|
}}
|
||||||
|
placeholder="—"
|
||||||
|
style={inputField}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -797,7 +923,17 @@ export default function ParamsPanel() {
|
||||||
<span style={hintLabel}>ГОД ПОСТРОЙКИ</span>
|
<span style={hintLabel}>ГОД ПОСТРОЙКИ</span>
|
||||||
<span style={optHint}>опц.</span>
|
<span style={optHint}>опц.</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={textField}>{object.year}</div>
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={year}
|
||||||
|
onChange={(e) => setYear(e.target.value.replace(/\D/g, ""))}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleSubmit();
|
||||||
|
}}
|
||||||
|
placeholder="—"
|
||||||
|
style={inputField}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||||
|
|
@ -858,12 +994,14 @@ export default function ParamsPanel() {
|
||||||
<span style={hintLabel}>CRM — ДЛЯ МЕНЕДЖЕРА</span>
|
<span style={hintLabel}>CRM — ДЛЯ МЕНЕДЖЕРА</span>
|
||||||
<span style={optHint}>опц.</span>
|
<span style={optHint}>опц.</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* CRM — non-functional (no backend, #395). Markup kept; open is
|
||||||
|
forced false + no-op handlers so it never expands. TODO(wire). */}
|
||||||
<Dd
|
<Dd
|
||||||
open={openDd === "crm"}
|
open={false}
|
||||||
onToggle={() => toggle("crm")}
|
onToggle={() => undefined}
|
||||||
value={crm}
|
value={crm}
|
||||||
options={dropdownOptions.crm}
|
options={dropdownOptions.crm}
|
||||||
onSelect={pick(setCrm)}
|
onSelect={() => undefined}
|
||||||
mono={false}
|
mono={false}
|
||||||
triggerFontSize={12}
|
triggerFontSize={12}
|
||||||
optionFontSize={12}
|
optionFontSize={12}
|
||||||
|
|
@ -881,6 +1019,8 @@ export default function ParamsPanel() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="pp-eval-btn"
|
className="pp-eval-btn"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isPending}
|
||||||
style={{
|
style={{
|
||||||
marginTop: 9,
|
marginTop: 9,
|
||||||
height: 46,
|
height: 46,
|
||||||
|
|
@ -891,14 +1031,15 @@ export default function ParamsPanel() {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
cursor: "pointer",
|
cursor: isPending ? "wait" : "pointer",
|
||||||
|
opacity: isPending ? 0.6 : 1,
|
||||||
fontFamily: tokens.font.sans,
|
fontFamily: tokens.font.sans,
|
||||||
color: tokens.ink,
|
color: tokens.ink,
|
||||||
flex: "0 0 auto",
|
flex: "0 0 auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 14, fontWeight: 600, letterSpacing: 3 }}>
|
<span style={{ fontSize: 14, fontWeight: 600, letterSpacing: 3 }}>
|
||||||
ОЦЕНИТЬ КВАРТИРУ
|
{isPending ? "ОЦЕНИВАЕМ…" : "ОЦЕНИТЬ КВАРТИРУ"}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -918,6 +1059,13 @@ export default function ParamsPanel() {
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* inline error (validation or server) — only when present */}
|
||||||
|
{(validationError || error) && (
|
||||||
|
<div style={errorText} role="alert">
|
||||||
|
{validationError ?? error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* cache status */}
|
{/* cache status */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
scatterMini,
|
scatterMini,
|
||||||
sources,
|
sources,
|
||||||
} from "./fixtures";
|
} from "./fixtures";
|
||||||
|
import type { ResultPanelData } from "./mappers";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
accent,
|
accent,
|
||||||
|
|
@ -35,7 +36,17 @@ const {
|
||||||
font,
|
font,
|
||||||
} = tokens;
|
} = tokens;
|
||||||
|
|
||||||
|
// Default presentation data (unwired usage): the existing design fixtures.
|
||||||
|
const RESULT_FIXTURE: ResultPanelData = {
|
||||||
|
cards: resultCards,
|
||||||
|
meta: resultMeta,
|
||||||
|
ranges,
|
||||||
|
scatterMini,
|
||||||
|
sources,
|
||||||
|
};
|
||||||
|
|
||||||
interface ResultPanelProps {
|
interface ResultPanelProps {
|
||||||
|
data?: ResultPanelData;
|
||||||
onNavigate: (i: number) => void;
|
onNavigate: (i: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,7 +83,10 @@ function sourceName(name: string) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ResultPanel({ onNavigate }: ResultPanelProps) {
|
export default function ResultPanel({
|
||||||
|
data = RESULT_FIXTURE,
|
||||||
|
onNavigate,
|
||||||
|
}: ResultPanelProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ display: "flex", flexDirection: "column", gap: 14, minWidth: 0 }}
|
style={{ display: "flex", flexDirection: "column", gap: 14, minWidth: 0 }}
|
||||||
|
|
@ -111,16 +125,16 @@ export default function ResultPanel({ onNavigate }: ResultPanelProps) {
|
||||||
<span>
|
<span>
|
||||||
ИСТОЧНИКОВ:{" "}
|
ИСТОЧНИКОВ:{" "}
|
||||||
<b style={{ color: ink, fontFamily: font.mono }}>
|
<b style={{ color: ink, fontFamily: font.mono }}>
|
||||||
{resultMeta.sources}
|
{data.meta.sources}
|
||||||
</b>
|
</b>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
ДОСТОВЕРНОСТЬ: <b style={{ color: ink }}>{resultMeta.confidence}</b>
|
ДОСТОВЕРНОСТЬ: <b style={{ color: ink }}>{data.meta.confidence}</b>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
CV{" "}
|
CV{" "}
|
||||||
<b style={{ color: accent, fontFamily: font.mono }}>
|
<b style={{ color: accent, fontFamily: font.mono }}>
|
||||||
{resultMeta.cv}
|
{data.meta.cv}
|
||||||
</b>
|
</b>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -135,7 +149,7 @@ export default function ResultPanel({ onNavigate }: ResultPanelProps) {
|
||||||
flex: "0 0 auto",
|
flex: "0 0 auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{resultCards.map((card, ci) => (
|
{data.cards.map((card, ci) => (
|
||||||
<div
|
<div
|
||||||
key={ci}
|
key={ci}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -391,7 +405,7 @@ export default function ResultPanel({ onNavigate }: ResultPanelProps) {
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{lines(ranges.ads.label)}
|
{lines(data.ranges.ads.label)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -401,7 +415,7 @@ export default function ResultPanel({ onNavigate }: ResultPanelProps) {
|
||||||
margin: "12px 0 16px",
|
margin: "12px 0 16px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
медиана · <b style={{ color: ink }}>{ranges.ads.median}</b>
|
медиана · <b style={{ color: ink }}>{data.ranges.ads.median}</b>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -414,8 +428,8 @@ export default function ResultPanel({ onNavigate }: ResultPanelProps) {
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: ranges.ads.marker.fillLeft,
|
left: data.ranges.ads.marker.fillLeft,
|
||||||
right: ranges.ads.marker.fillRight,
|
right: data.ranges.ads.marker.fillRight,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
background: `linear-gradient(90deg,${barMid},${accent})`,
|
background: `linear-gradient(90deg,${barMid},${accent})`,
|
||||||
|
|
@ -425,7 +439,7 @@ export default function ResultPanel({ onNavigate }: ResultPanelProps) {
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: ranges.ads.marker.dotLeft,
|
left: data.ranges.ads.marker.dotLeft,
|
||||||
top: "50%",
|
top: "50%",
|
||||||
transform: "translate(-50%,-50%)",
|
transform: "translate(-50%,-50%)",
|
||||||
width: 13,
|
width: 13,
|
||||||
|
|
@ -447,9 +461,9 @@ export default function ResultPanel({ onNavigate }: ResultPanelProps) {
|
||||||
marginTop: 10,
|
marginTop: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{ranges.ads.lo}</span>
|
<span>{data.ranges.ads.lo}</span>
|
||||||
<span style={{ color: accent }}>{ranges.ads.median}</span>
|
<span style={{ color: accent }}>{data.ranges.ads.median}</span>
|
||||||
<span>{ranges.ads.hi}</span>
|
<span>{data.ranges.ads.hi}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -499,24 +513,24 @@ export default function ResultPanel({ onNavigate }: ResultPanelProps) {
|
||||||
opacity="0.45"
|
opacity="0.45"
|
||||||
/>
|
/>
|
||||||
<g fill={scatterDeal}>
|
<g fill={scatterDeal}>
|
||||||
{scatterMini.deals.map((p, i) => (
|
{data.scatterMini.deals.map((p, i) => (
|
||||||
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
|
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
|
||||||
))}
|
))}
|
||||||
</g>
|
</g>
|
||||||
<g fill={accent}>
|
<g fill={accent}>
|
||||||
{scatterMini.analogs.map((p, i) => (
|
{data.scatterMini.analogs.map((p, i) => (
|
||||||
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
|
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
|
||||||
))}
|
))}
|
||||||
</g>
|
</g>
|
||||||
<circle
|
<circle
|
||||||
cx={scatterMini.subject.x}
|
cx={data.scatterMini.subject.x}
|
||||||
cy={scatterMini.subject.y}
|
cy={data.scatterMini.subject.y}
|
||||||
r={scatterMini.subject.r}
|
r={data.scatterMini.subject.r}
|
||||||
fill={ink}
|
fill={ink}
|
||||||
/>
|
/>
|
||||||
<circle
|
<circle
|
||||||
cx={scatterMini.subject.x}
|
cx={data.scatterMini.subject.x}
|
||||||
cy={scatterMini.subject.y}
|
cy={data.scatterMini.subject.y}
|
||||||
r="6.5"
|
r="6.5"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={ink}
|
stroke={ink}
|
||||||
|
|
@ -529,7 +543,7 @@ export default function ResultPanel({ onNavigate }: ResultPanelProps) {
|
||||||
fill={muted2}
|
fill={muted2}
|
||||||
textAnchor="end"
|
textAnchor="end"
|
||||||
>
|
>
|
||||||
{scatterMini.yTicks.map((t, i) => (
|
{data.scatterMini.yTicks.map((t, i) => (
|
||||||
<text key={i} x={30} y={t.y}>
|
<text key={i} x={30} y={t.y}>
|
||||||
{t.label}
|
{t.label}
|
||||||
</text>
|
</text>
|
||||||
|
|
@ -541,7 +555,7 @@ export default function ResultPanel({ onNavigate }: ResultPanelProps) {
|
||||||
fill={muted2}
|
fill={muted2}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
>
|
>
|
||||||
{scatterMini.xTicks.map((t, i) => (
|
{data.scatterMini.xTicks.map((t, i) => (
|
||||||
<text key={i} x={t.x} y={120}>
|
<text key={i} x={t.x} y={120}>
|
||||||
{t.label}
|
{t.label}
|
||||||
</text>
|
</text>
|
||||||
|
|
@ -640,7 +654,7 @@ export default function ResultPanel({ onNavigate }: ResultPanelProps) {
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{lines(ranges.deals.label)}
|
{lines(data.ranges.deals.label)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -650,7 +664,7 @@ export default function ResultPanel({ onNavigate }: ResultPanelProps) {
|
||||||
margin: "12px 0 16px",
|
margin: "12px 0 16px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
медиана · <b style={{ color: ink }}>{ranges.deals.median}</b>
|
медиана · <b style={{ color: ink }}>{data.ranges.deals.median}</b>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -663,8 +677,8 @@ export default function ResultPanel({ onNavigate }: ResultPanelProps) {
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: ranges.deals.marker.fillLeft,
|
left: data.ranges.deals.marker.fillLeft,
|
||||||
right: ranges.deals.marker.fillRight,
|
right: data.ranges.deals.marker.fillRight,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
background: `linear-gradient(90deg,${barMid},${accent})`,
|
background: `linear-gradient(90deg,${barMid},${accent})`,
|
||||||
|
|
@ -674,7 +688,7 @@ export default function ResultPanel({ onNavigate }: ResultPanelProps) {
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: ranges.deals.marker.dotLeft,
|
left: data.ranges.deals.marker.dotLeft,
|
||||||
top: "50%",
|
top: "50%",
|
||||||
transform: "translate(-50%,-50%)",
|
transform: "translate(-50%,-50%)",
|
||||||
width: 13,
|
width: 13,
|
||||||
|
|
@ -696,9 +710,9 @@ export default function ResultPanel({ onNavigate }: ResultPanelProps) {
|
||||||
marginTop: 10,
|
marginTop: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{ranges.deals.lo}</span>
|
<span>{data.ranges.deals.lo}</span>
|
||||||
<span style={{ color: accent }}>{ranges.deals.median}</span>
|
<span style={{ color: accent }}>{data.ranges.deals.median}</span>
|
||||||
<span>{ranges.deals.hi}</span>
|
<span>{data.ranges.deals.hi}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -762,7 +776,7 @@ export default function ResultPanel({ onNavigate }: ResultPanelProps) {
|
||||||
alignContent: "stretch",
|
alignContent: "stretch",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{sources.map((s, si) =>
|
{data.sources.map((s, si) =>
|
||||||
s.active ? (
|
s.active ? (
|
||||||
<div
|
<div
|
||||||
key={si}
|
key={si}
|
||||||
|
|
|
||||||
745
tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts
Normal file
745
tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts
Normal file
|
|
@ -0,0 +1,745 @@
|
||||||
|
// Pure API -> presentation mappers for the /trade-in/v2 "МЕРА Оценка" HUD port.
|
||||||
|
//
|
||||||
|
// These functions take the real trade-in API objects (AggregatedEstimate,
|
||||||
|
// StreetDealsResponse, HouseAnalyticsResponse) and produce the EXACT fixture
|
||||||
|
// shapes the v2 components already consume (see ./types + ./fixtures). The Wire
|
||||||
|
// phase swaps each component's hardcoded fixture for a `data` prop fed by one of
|
||||||
|
// these mappers; the fixtures stay as the prop DEFAULT so unwired usage still
|
||||||
|
// renders. ALL money/format/geometry lives here — never copy fixture literals.
|
||||||
|
//
|
||||||
|
// Reconciliations / known FE-approximations (look for "TODO BE-N"):
|
||||||
|
// BE-1 created_at, cv, per-source lot counts are NOT in the API response.
|
||||||
|
// Report date is derived as expires_at − 24h; cv is the FE coefficient
|
||||||
|
// of variation of analog ₽/м²; source counts are an FE groupBy of
|
||||||
|
// analogs + actual_deals by .source.
|
||||||
|
// BE-2 target_address is a single string; street/city are split heuristically
|
||||||
|
// (parseAddress). Backend should return structured address components.
|
||||||
|
// BE-3 location coefficient / street-view caption / compass bearing are not in
|
||||||
|
// the API → surfaced as placeholders.
|
||||||
|
//
|
||||||
|
// Enum <-> RU reconciliation (design dropdowns have options with no enum value):
|
||||||
|
// house type: 'Блочный' ⇄ enum 'other' (enum has no dedicated block type)
|
||||||
|
// repair : 'Без отделки' → enum 'needs_repair' (no-finish ≈ needs repair)
|
||||||
|
// See HOUSE_TYPE_RU / HOUSE_TYPE_FROM_RU / REPAIR_RU / REPAIR_FROM_RU below.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AggregatedEstimate,
|
||||||
|
AnalogLot,
|
||||||
|
ConfidenceLevel,
|
||||||
|
HouseAnalyticsResponse,
|
||||||
|
HouseType,
|
||||||
|
RepairState,
|
||||||
|
StreetDealsResponse,
|
||||||
|
} from "@/types/trade-in";
|
||||||
|
import type {
|
||||||
|
AxisTickX,
|
||||||
|
AxisTickY,
|
||||||
|
ObjectInfo,
|
||||||
|
RangeBar,
|
||||||
|
RangeMarker,
|
||||||
|
Ranges,
|
||||||
|
Report,
|
||||||
|
ResultCard,
|
||||||
|
ResultMeta,
|
||||||
|
ScatterMini,
|
||||||
|
ScatterPoint,
|
||||||
|
SourceCard,
|
||||||
|
Summary,
|
||||||
|
SummaryRow,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
// Total number of source slots in the design (ЦИАН … N1.RU) — meta "N / 7".
|
||||||
|
const TOTAL_SOURCES = 7;
|
||||||
|
|
||||||
|
// ── Composite presentation shapes consumed by the wired components ──────────
|
||||||
|
// One `data` prop per component; each field keeps its exact ./types shape.
|
||||||
|
|
||||||
|
export interface ResultPanelData {
|
||||||
|
cards: ResultCard[];
|
||||||
|
meta: ResultMeta;
|
||||||
|
ranges: Ranges;
|
||||||
|
scatterMini: ScatterMini;
|
||||||
|
sources: SourceCard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeroBarData {
|
||||||
|
report: Report;
|
||||||
|
object: ObjectInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectSummaryData {
|
||||||
|
object: ObjectInfo;
|
||||||
|
summary: Summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Enum <-> RU maps ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// enum -> display label (mapObject). 'other' surfaces as "Блочный" so it matches
|
||||||
|
// a real dropdown option and round-trips via HOUSE_TYPE_FROM_RU.
|
||||||
|
export const HOUSE_TYPE_RU: Record<HouseType, string> = {
|
||||||
|
panel: "Панельный",
|
||||||
|
brick: "Кирпичный",
|
||||||
|
monolith: "Монолитный",
|
||||||
|
monolith_brick: "Монолитно-кирпичный",
|
||||||
|
other: "Блочный",
|
||||||
|
};
|
||||||
|
|
||||||
|
// enum -> display label (mapObject).
|
||||||
|
export const REPAIR_RU: Record<RepairState, string> = {
|
||||||
|
needs_repair: "Требует ремонта",
|
||||||
|
standard: "Удовлетворительный",
|
||||||
|
good: "Хороший",
|
||||||
|
excellent: "Отличный",
|
||||||
|
};
|
||||||
|
|
||||||
|
// enum -> RU lower-case (summary quality block; meta uses .toUpperCase()).
|
||||||
|
export const CONFIDENCE_RU: Record<ConfidenceLevel, string> = {
|
||||||
|
low: "низкая",
|
||||||
|
medium: "средняя",
|
||||||
|
high: "высокая",
|
||||||
|
};
|
||||||
|
|
||||||
|
// RU dropdown label -> enum (Wire-phase form submit). Reconciles the design's
|
||||||
|
// extra options that have no enum value (see header comment).
|
||||||
|
export const HOUSE_TYPE_FROM_RU: Record<string, HouseType> = {
|
||||||
|
Панельный: "panel",
|
||||||
|
Кирпичный: "brick",
|
||||||
|
Монолитный: "monolith",
|
||||||
|
"Монолитно-кирпичный": "monolith_brick",
|
||||||
|
Блочный: "other",
|
||||||
|
Другой: "other",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REPAIR_FROM_RU: Record<string, RepairState> = {
|
||||||
|
"Без отделки": "needs_repair",
|
||||||
|
"Требует ремонта": "needs_repair",
|
||||||
|
Удовлетворительный: "standard",
|
||||||
|
Хороший: "good",
|
||||||
|
Отличный: "excellent",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Format helpers (ru) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** rub -> млн with ru grouping, 2dp. 6557500 -> "6,56". null/NaN -> "—". */
|
||||||
|
export function fmtMln(rub: number | null | undefined): string {
|
||||||
|
if (rub == null || !Number.isFinite(rub)) return "—";
|
||||||
|
return (rub / 1e6).toLocaleString("ru-RU", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ₽/м² -> "225 801 ₽/м²" (rounded, ru grouping). null/NaN -> "—". */
|
||||||
|
export function fmtPpm(ppm: number | null | undefined): string {
|
||||||
|
if (ppm == null || !Number.isFinite(ppm)) return "—";
|
||||||
|
return `${Math.round(ppm).toLocaleString("ru-RU")} ₽/м²`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO datetime -> "DD.MM.YYYY" (ru). Invalid/empty -> "—". */
|
||||||
|
export function fmtDate(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
|
return d.toLocaleDateString("ru-RU", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same as fmtDate but shifted by `hours` (TODO BE-1 created_at = expires_at−24h). */
|
||||||
|
function fmtDateShift(iso: string | null | undefined, hours: number): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
|
d.setHours(d.getHours() + hours);
|
||||||
|
return d.toLocaleDateString("ru-RU", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signed percent with proper ru minus. -3 -> "−3%", 5 -> "+5%", 0 -> "0%". */
|
||||||
|
export function fmtPct(v: number | null | undefined, digits = 0): string {
|
||||||
|
if (v == null || !Number.isFinite(v)) return "—";
|
||||||
|
const rounded = digits > 0 ? Number(v.toFixed(digits)) : Math.round(v);
|
||||||
|
const sign = rounded < 0 ? "−" : rounded > 0 ? "+" : "";
|
||||||
|
return `${sign}${Math.abs(rounded)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Russian plural picker. forms = [one, few, many]. */
|
||||||
|
export function pluralRu(n: number, forms: [string, string, string]): string {
|
||||||
|
const a = Math.abs(n) % 100;
|
||||||
|
const b = a % 10;
|
||||||
|
if (a > 10 && a < 20) return forms[2];
|
||||||
|
if (b > 1 && b < 5) return forms[1];
|
||||||
|
if (b === 1) return forms[0];
|
||||||
|
return forms[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Numeric helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function clamp(v: number, lo: number, hi: number): number {
|
||||||
|
return Math.max(lo, Math.min(hi, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
function round1(v: number): number {
|
||||||
|
return Math.round(v * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function median(values: number[]): number | null {
|
||||||
|
const clean = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b);
|
||||||
|
if (clean.length === 0) return null;
|
||||||
|
const mid = Math.floor(clean.length / 2);
|
||||||
|
return clean.length % 2 ? clean[mid] : (clean[mid - 1] + clean[mid]) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coefficient of variation (%) of positive values; null if <2 samples. */
|
||||||
|
function coeffVar(values: number[]): number | null {
|
||||||
|
const clean = values.filter((v) => Number.isFinite(v) && v > 0);
|
||||||
|
if (clean.length < 2) return null;
|
||||||
|
const mean = clean.reduce((a, b) => a + b, 0) / clean.length;
|
||||||
|
if (mean === 0) return null;
|
||||||
|
const variance =
|
||||||
|
clean.reduce((a, b) => a + (b - mean) ** 2, 0) / clean.length;
|
||||||
|
return (Math.sqrt(variance) / mean) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cvStr(values: number[]): string {
|
||||||
|
const cv = coeffVar(values);
|
||||||
|
return cv != null ? `${cv.toFixed(1)}%` : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bin `values` into 8 buckets across [min,max]; return per-bucket height as
|
||||||
|
* count/maxCount*100 (the mini-histogram bar heights). Empty input -> [].
|
||||||
|
*/
|
||||||
|
export function bins8(values: number[]): number[] {
|
||||||
|
const clean = values.filter((v) => Number.isFinite(v) && v > 0);
|
||||||
|
if (clean.length === 0) return [];
|
||||||
|
const min = Math.min(...clean);
|
||||||
|
const max = Math.max(...clean);
|
||||||
|
if (max === min) {
|
||||||
|
// Degenerate: all equal -> single central bar.
|
||||||
|
const bars = new Array<number>(8).fill(0);
|
||||||
|
bars[3] = 100;
|
||||||
|
return bars;
|
||||||
|
}
|
||||||
|
const span = max - min;
|
||||||
|
const counts = new Array<number>(8).fill(0);
|
||||||
|
for (const v of clean) {
|
||||||
|
const idx = clamp(Math.floor(((v - min) / span) * 8), 0, 7);
|
||||||
|
counts[idx] += 1;
|
||||||
|
}
|
||||||
|
const maxCount = Math.max(...counts);
|
||||||
|
if (maxCount === 0) return [];
|
||||||
|
return counts.map((c) => Math.round((c / maxCount) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScatterDomain {
|
||||||
|
xMin: number;
|
||||||
|
xMax: number;
|
||||||
|
yMin: number;
|
||||||
|
yMax: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScatterBox {
|
||||||
|
left: number;
|
||||||
|
right: number;
|
||||||
|
top: number; // smaller pixel y (high value)
|
||||||
|
bottom: number; // larger pixel y (low value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project data points {x,y} into the SVG pixel box. Y is inverted (high value ->
|
||||||
|
* top). Out-of-domain points are clamped into the box. Coords rounded to 1dp.
|
||||||
|
*/
|
||||||
|
export function scatterProject(
|
||||||
|
pts: ReadonlyArray<{ x: number; y: number }>,
|
||||||
|
domain: ScatterDomain,
|
||||||
|
box: ScatterBox,
|
||||||
|
r: number,
|
||||||
|
): ScatterPoint[] {
|
||||||
|
const xSpan = domain.xMax - domain.xMin || 1;
|
||||||
|
const ySpan = domain.yMax - domain.yMin || 1;
|
||||||
|
return pts.map((p) => {
|
||||||
|
const fx = clamp((p.x - domain.xMin) / xSpan, 0, 1);
|
||||||
|
const fy = clamp((p.y - domain.yMin) / ySpan, 0, 1);
|
||||||
|
return {
|
||||||
|
x: round1(box.left + fx * (box.right - box.left)),
|
||||||
|
y: round1(box.bottom - fy * (box.bottom - box.top)),
|
||||||
|
r,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Range marker (CSS % offsets) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function pctStr(n: number): string {
|
||||||
|
return `${round1(n)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filled-segment offsets + median dot position for a price range bar.
|
||||||
|
* Domain is [lo,hi] padded by `padFrac` on each side so the dot/segment have
|
||||||
|
* breathing room. Degenerate ranges fall back to a centred dot.
|
||||||
|
*/
|
||||||
|
function rangeMarker(
|
||||||
|
lo: number | null | undefined,
|
||||||
|
med: number | null | undefined,
|
||||||
|
hi: number | null | undefined,
|
||||||
|
padFrac = 0.08,
|
||||||
|
): RangeMarker {
|
||||||
|
if (
|
||||||
|
lo == null ||
|
||||||
|
med == null ||
|
||||||
|
hi == null ||
|
||||||
|
!Number.isFinite(lo) ||
|
||||||
|
!Number.isFinite(med) ||
|
||||||
|
!Number.isFinite(hi) ||
|
||||||
|
hi <= lo
|
||||||
|
) {
|
||||||
|
return { fillLeft: "6%", fillRight: "6%", dotLeft: "50%" };
|
||||||
|
}
|
||||||
|
const span = hi - lo;
|
||||||
|
const pad = span * padFrac;
|
||||||
|
const dMin = lo - pad;
|
||||||
|
const dMax = hi + pad;
|
||||||
|
const w = dMax - dMin;
|
||||||
|
return {
|
||||||
|
fillLeft: pctStr(((lo - dMin) / w) * 100),
|
||||||
|
fillRight: pctStr(((dMax - hi) / w) * 100),
|
||||||
|
dotLeft: pctStr(clamp(((med - dMin) / w) * 100, 0, 100)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "{lo} — {hi} млн ₽" range line for a result card. Missing -> "—". */
|
||||||
|
function rangeLine(
|
||||||
|
lo: number | null | undefined,
|
||||||
|
hi: number | null | undefined,
|
||||||
|
): string {
|
||||||
|
if (lo == null || hi == null || !Number.isFinite(lo) || !Number.isFinite(hi)) {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
return `${fmtMln(lo)} — ${fmtMln(hi)} млн ₽`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Address parsing (TODO BE-2) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const STREET_RE =
|
||||||
|
/\b(ул|улица|пр|пр-?кт|проспект|пер|переулок|б-?р|бул|бульвар|ш|шоссе|наб|набережная|пл|площадь|проезд|тракт|мкр|микрорайон)\b/i;
|
||||||
|
const REGION_RE =
|
||||||
|
/(обл|область|край|респ|республика|р-н|район|округ|\bАО\b)/i;
|
||||||
|
const CITY_PREFIX_RE = /^(г|город|пгт|с|село|д|деревня|рп)\.?\s+/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort split of a single address string into {street+house, city}.
|
||||||
|
* Drops postal index + region parts, finds the street token and treats the
|
||||||
|
* preceding city-looking token as the city. Falls back to the full string.
|
||||||
|
*/
|
||||||
|
function parseAddress(full: string | null): { address: string; city: string } {
|
||||||
|
if (!full || !full.trim()) return { address: "—", city: "" };
|
||||||
|
const parts = full
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((p) => p.length > 0 && !/^\d{6}$/.test(p) && !REGION_RE.test(p));
|
||||||
|
if (parts.length === 0) return { address: full.trim(), city: "" };
|
||||||
|
|
||||||
|
let streetStart = parts.findIndex((p) => STREET_RE.test(p));
|
||||||
|
let cityName = "";
|
||||||
|
if (streetStart > 0) {
|
||||||
|
for (let i = streetStart - 1; i >= 0; i--) {
|
||||||
|
if (CITY_PREFIX_RE.test(parts[i]) || i === 0) {
|
||||||
|
cityName = parts[i].replace(CITY_PREFIX_RE, "").trim();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (streetStart === -1) {
|
||||||
|
// No street token recognised — assume "City, rest…".
|
||||||
|
cityName = (parts[0] ?? "").replace(CITY_PREFIX_RE, "").trim();
|
||||||
|
streetStart = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = parts.slice(streetStart).join(", ") || full.trim();
|
||||||
|
const city = cityName ? `${cityName}, Россия` : "";
|
||||||
|
return { address, city };
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomsLabel(rooms: number | null): string {
|
||||||
|
if (rooms == null) return "—";
|
||||||
|
if (rooms <= 0) return "Студия";
|
||||||
|
if (rooms >= 5) return "5+";
|
||||||
|
return String(rooms);
|
||||||
|
}
|
||||||
|
|
||||||
|
function numLabel(v: number | null): string {
|
||||||
|
return v == null || !Number.isFinite(v) ? "—" : String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Source slots (FE groupBy — TODO BE-1) ───────────────────────────────────
|
||||||
|
// Each design slot maps to one or more API source keys. count = number of
|
||||||
|
// analogs + actual_deals whose .source matches; active = key present in
|
||||||
|
// sources_used (or any lots found). 'АВИТО ОЦЕНКА' also covers the avito_imv
|
||||||
|
// anchor key (no lots of its own).
|
||||||
|
const SOURCE_SLOTS: ReadonlyArray<{ name: string; keys: string[] }> = [
|
||||||
|
{ name: "ЦИАН", keys: ["cian"] },
|
||||||
|
{ name: "Я.НЕДВИЖИМОСТЬ", keys: ["yandex"] },
|
||||||
|
{ name: "РОСРЕЕСТР (ВНУТР.)", keys: ["rosreestr"] },
|
||||||
|
{ name: "АВИТО ОЦЕНКА", keys: ["avito", "avito_imv"] },
|
||||||
|
{ name: "ДОМКЛИК ПРАЙС", keys: ["domklik", "domclick"] },
|
||||||
|
{ name: "RESTATE", keys: ["restate"] },
|
||||||
|
{ name: "N1.RU", keys: ["n1", "n1ru"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildSources(e: AggregatedEstimate): SourceCard[] {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const lot of [...e.analogs, ...e.actual_deals]) {
|
||||||
|
const s = (lot.source ?? "").toLowerCase();
|
||||||
|
if (!s) continue;
|
||||||
|
counts.set(s, (counts.get(s) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const used = new Set(e.sources_used.map((s) => s.toLowerCase()));
|
||||||
|
return SOURCE_SLOTS.map((slot) => {
|
||||||
|
const count = slot.keys.reduce((sum, k) => sum + (counts.get(k) ?? 0), 0);
|
||||||
|
const active = slot.keys.some((k) => used.has(k)) || count > 0;
|
||||||
|
if (!active) {
|
||||||
|
return { name: slot.name, count: "—", label: "нет данных", active: false };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: slot.name,
|
||||||
|
count: count > 0 ? String(count) : "—",
|
||||||
|
label: count > 0 ? pluralRu(count, ["лот", "лота", "лотов"]) : "нет данных",
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Deal tier resolution (DKP / Росреестр) ──────────────────────────────────
|
||||||
|
|
||||||
|
interface DealTier {
|
||||||
|
medianRub: number;
|
||||||
|
loRub: number;
|
||||||
|
hiRub: number;
|
||||||
|
medianPpm: number;
|
||||||
|
count: number;
|
||||||
|
bars: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the "ФАКТИЧЕСКИЕ СДЕЛКИ" tier, preferring (1) street DKP deals with
|
||||||
|
* real ₽ totals, then (2) the dkp_corridor ₽/м² × area, then (3) the estimate's
|
||||||
|
* own actual_deals. null when no deal data at all.
|
||||||
|
*/
|
||||||
|
function resolveDealTier(
|
||||||
|
e: AggregatedEstimate,
|
||||||
|
sd?: StreetDealsResponse | null,
|
||||||
|
): DealTier | null {
|
||||||
|
if (sd && sd.count > 0 && Number.isFinite(sd.median_price_rub)) {
|
||||||
|
return {
|
||||||
|
medianRub: sd.median_price_rub,
|
||||||
|
loRub: sd.range_low_rub,
|
||||||
|
hiRub: sd.range_high_rub,
|
||||||
|
medianPpm: sd.median_price_per_m2,
|
||||||
|
count: sd.count,
|
||||||
|
bars: bins8(sd.deals.map((d) => d.price_per_m2)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const area = e.area_m2;
|
||||||
|
if (e.dkp_corridor && e.dkp_corridor.count > 0 && area && area > 0) {
|
||||||
|
const c = e.dkp_corridor;
|
||||||
|
return {
|
||||||
|
medianRub: c.median_ppm2 * area,
|
||||||
|
loRub: c.low_ppm2 * area,
|
||||||
|
hiRub: c.high_ppm2 * area,
|
||||||
|
medianPpm: c.median_ppm2,
|
||||||
|
count: c.count,
|
||||||
|
bars: bins8(e.actual_deals.map((d) => d.price_per_m2)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (e.actual_deals.length > 0) {
|
||||||
|
const prices = e.actual_deals.map((d) => d.price_rub);
|
||||||
|
const ppms = e.actual_deals.map((d) => d.price_per_m2);
|
||||||
|
const medRub = median(prices);
|
||||||
|
const medPpm = median(ppms);
|
||||||
|
if (medRub == null || medPpm == null) return null;
|
||||||
|
return {
|
||||||
|
medianRub: medRub,
|
||||||
|
loRub: Math.min(...prices),
|
||||||
|
hiRub: Math.max(...prices),
|
||||||
|
medianPpm: medPpm,
|
||||||
|
count: e.actual_deals.length,
|
||||||
|
bars: bins8(ppms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scatter mini (ЦЕНА × СРОК ПРОДАЖИ) ──────────────────────────────────────
|
||||||
|
// Pixel box + x-tick positions taken from the design viewBox (168×140). X domain
|
||||||
|
// is a fixed 0–180 days so the x-ticks stay valid; Y domain is data-driven and
|
||||||
|
// the y-tick LABELS are recomputed for the chosen pixel positions.
|
||||||
|
const SCATTER_BOX: ScatterBox = { left: 34, right: 158, top: 13, bottom: 112 };
|
||||||
|
const SCATTER_X_DOMAIN = { min: 0, max: 180 };
|
||||||
|
const SCATTER_X_TICKS: AxisTickX[] = [
|
||||||
|
{ label: "0", x: 34 },
|
||||||
|
{ label: "90", x: 96 },
|
||||||
|
{ label: "180", x: 158 },
|
||||||
|
];
|
||||||
|
const SCATTER_Y_TICK_PX = [13, 77, 112]; // top, mid, bottom
|
||||||
|
|
||||||
|
function mlnTick(rub: number): string {
|
||||||
|
return `${Math.round(rub / 1e6)}М`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildScatterMini(
|
||||||
|
e: AggregatedEstimate,
|
||||||
|
sd?: StreetDealsResponse | null,
|
||||||
|
): ScatterMini {
|
||||||
|
const withDom = (lots: AnalogLot[]) =>
|
||||||
|
lots
|
||||||
|
.filter((l) => l.days_on_market != null && Number.isFinite(l.price_rub))
|
||||||
|
.map((l) => ({ x: l.days_on_market as number, y: l.price_rub }));
|
||||||
|
|
||||||
|
const dealsRaw = withDom(sd && sd.deals.length > 0 ? sd.deals : e.actual_deals);
|
||||||
|
const analogsRaw = withDom(e.analogs);
|
||||||
|
const subjectPrice = e.expected_sold_price_rub ?? e.median_price_rub;
|
||||||
|
const subjectDays = e.est_days_on_market;
|
||||||
|
|
||||||
|
const allY = [...dealsRaw, ...analogsRaw]
|
||||||
|
.map((p) => p.y)
|
||||||
|
.filter((y) => Number.isFinite(y));
|
||||||
|
if (Number.isFinite(subjectPrice)) allY.push(subjectPrice);
|
||||||
|
|
||||||
|
const fallbackTicks: AxisTickY[] = SCATTER_Y_TICK_PX.map((y) => ({
|
||||||
|
label: "—",
|
||||||
|
y,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (allY.length === 0) {
|
||||||
|
return {
|
||||||
|
deals: [],
|
||||||
|
analogs: [],
|
||||||
|
subject: { x: 96, y: 62, r: 3.6 },
|
||||||
|
yTicks: fallbackTicks,
|
||||||
|
xTicks: SCATTER_X_TICKS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let yMin = Math.min(...allY);
|
||||||
|
let yMax = Math.max(...allY);
|
||||||
|
if (yMin === yMax) {
|
||||||
|
yMin *= 0.95;
|
||||||
|
yMax *= 1.05;
|
||||||
|
}
|
||||||
|
const yPad = (yMax - yMin) * 0.08;
|
||||||
|
const domain: ScatterDomain = {
|
||||||
|
xMin: SCATTER_X_DOMAIN.min,
|
||||||
|
xMax: SCATTER_X_DOMAIN.max,
|
||||||
|
yMin: yMin - yPad,
|
||||||
|
yMax: yMax + yPad,
|
||||||
|
};
|
||||||
|
|
||||||
|
const deals = scatterProject(dealsRaw, domain, SCATTER_BOX, 2.4);
|
||||||
|
const analogs = scatterProject(analogsRaw, domain, SCATTER_BOX, 3);
|
||||||
|
// Subject: real est_days if known, else mid-window (90 days).
|
||||||
|
const subject = Number.isFinite(subjectPrice)
|
||||||
|
? scatterProject(
|
||||||
|
[{ x: subjectDays ?? 90, y: subjectPrice }],
|
||||||
|
domain,
|
||||||
|
SCATTER_BOX,
|
||||||
|
3.6,
|
||||||
|
)[0]
|
||||||
|
: { x: 96, y: 62, r: 3.6 };
|
||||||
|
|
||||||
|
const yTicks: AxisTickY[] = SCATTER_Y_TICK_PX.map((py) => {
|
||||||
|
const frac =
|
||||||
|
(SCATTER_BOX.bottom - py) / (SCATTER_BOX.bottom - SCATTER_BOX.top);
|
||||||
|
const price = domain.yMin + frac * (domain.yMax - domain.yMin);
|
||||||
|
return { label: mlnTick(price), y: py };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { deals, analogs, subject, yTicks, xTicks: SCATTER_X_TICKS };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public mappers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Report header (HeroBar / Footer). id = first 8 of UUID; date = expires−24h. */
|
||||||
|
export function mapReport(e: AggregatedEstimate): Report {
|
||||||
|
return {
|
||||||
|
id: e.estimate_id.slice(0, 8),
|
||||||
|
date: fmtDateShift(e.expires_at, -24), // TODO BE-1: real created_at
|
||||||
|
validUntil: fmtDate(e.expires_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Object snapshot (ParamsPanel inputs + HeroBar/ObjectSummary address block). */
|
||||||
|
export function mapObject(e: AggregatedEstimate): ObjectInfo {
|
||||||
|
const { address, city } = parseAddress(e.target_address);
|
||||||
|
return {
|
||||||
|
address,
|
||||||
|
city,
|
||||||
|
area: numLabel(e.area_m2),
|
||||||
|
rooms: roomsLabel(e.rooms),
|
||||||
|
floor: numLabel(e.floor),
|
||||||
|
totalFloors: numLabel(e.total_floors),
|
||||||
|
year: numLabel(e.year_built),
|
||||||
|
houseType: e.house_type ? HOUSE_TYPE_RU[e.house_type] : "—",
|
||||||
|
repair: e.repair_state ? REPAIR_RU[e.repair_state] : "—",
|
||||||
|
balcony: e.has_balcony ?? false,
|
||||||
|
locationCoef: "—", // TODO BE-3
|
||||||
|
streetView: "", // TODO BE-3
|
||||||
|
compass: "", // TODO BE-3
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full 02 РЕЗУЛЬТАТ block: 3 cards + meta + ranges + scatter + sources. */
|
||||||
|
export function mapResultPanel(
|
||||||
|
e: AggregatedEstimate,
|
||||||
|
streetDeals?: StreetDealsResponse | null,
|
||||||
|
): ResultPanelData {
|
||||||
|
const dealTier = resolveDealTier(e, streetDeals);
|
||||||
|
|
||||||
|
const cards: ResultCard[] = [
|
||||||
|
{
|
||||||
|
title: ["РЕКОМЕНДОВАННАЯ ЦЕНА", "В ОБЪЯВЛЕНИИ"],
|
||||||
|
value: fmtMln(e.median_price_rub),
|
||||||
|
unit: "млн ₽",
|
||||||
|
range: rangeLine(e.range_low_rub, e.range_high_rub),
|
||||||
|
ppm: `${fmtPpm(e.median_price_per_m2)} · по объявлениям`,
|
||||||
|
bars: bins8(e.analogs.map((a) => a.price_per_m2)),
|
||||||
|
nav: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: ["ОЖИДАЕМАЯ СДЕЛКА", "(ПОЛУЧИТЕ)"],
|
||||||
|
value: fmtMln(e.expected_sold_price_rub),
|
||||||
|
unit: "млн ₽",
|
||||||
|
range: rangeLine(e.expected_sold_range_low_rub, e.expected_sold_range_high_rub),
|
||||||
|
ppm: fmtPpm(e.expected_sold_per_m2),
|
||||||
|
delta:
|
||||||
|
e.asking_to_sold_ratio != null
|
||||||
|
? fmtPct((e.asking_to_sold_ratio - 1) * 100)
|
||||||
|
: "—",
|
||||||
|
deltaLabel: "К РЫНКУ",
|
||||||
|
nav: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: ["ДКП · РОСРЕЕСТР", "(ФАКТИЧЕСКИЕ СДЕЛКИ)"],
|
||||||
|
value: dealTier ? fmtMln(dealTier.medianRub) : "—",
|
||||||
|
unit: "млн ₽",
|
||||||
|
range: dealTier ? rangeLine(dealTier.loRub, dealTier.hiRub) : "нет данных",
|
||||||
|
ppm: dealTier
|
||||||
|
? `${fmtPpm(dealTier.medianPpm)} · ${dealTier.count} ${pluralRu(
|
||||||
|
dealTier.count,
|
||||||
|
["сделка", "сделки", "сделок"],
|
||||||
|
)}`
|
||||||
|
: "—",
|
||||||
|
bars: dealTier ? dealTier.bars : [],
|
||||||
|
nav: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const meta: ResultMeta = {
|
||||||
|
sources: `${e.sources_used.length} / ${TOTAL_SOURCES}`,
|
||||||
|
confidence: CONFIDENCE_RU[e.confidence].toUpperCase(),
|
||||||
|
cv: cvStr(e.analogs.map((a) => a.price_per_m2)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const adsBar: RangeBar = {
|
||||||
|
label: ["ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ", "(БЕЗ УЧЁТА РЕМОНТА)"],
|
||||||
|
median: `${fmtMln(e.median_price_rub)} млн ₽`,
|
||||||
|
lo: `${fmtMln(e.range_low_rub)} млн`,
|
||||||
|
hi: `${fmtMln(e.range_high_rub)} млн`,
|
||||||
|
marker: rangeMarker(e.range_low_rub, e.median_price_rub, e.range_high_rub),
|
||||||
|
};
|
||||||
|
|
||||||
|
const dealsBar: RangeBar = {
|
||||||
|
label: ["ДИАПАЗОН ЦЕН ПО", "ФАКТИЧЕСКИМ СДЕЛКАМ"],
|
||||||
|
median: dealTier ? `${fmtMln(dealTier.medianRub)} млн ₽` : "—",
|
||||||
|
lo: dealTier ? `${fmtMln(dealTier.loRub)} млн` : "—",
|
||||||
|
hi: dealTier ? `${fmtMln(dealTier.hiRub)} млн` : "—",
|
||||||
|
marker: dealTier
|
||||||
|
? rangeMarker(dealTier.loRub, dealTier.medianRub, dealTier.hiRub)
|
||||||
|
: { fillLeft: "6%", fillRight: "6%", dotLeft: "50%" },
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
cards,
|
||||||
|
meta,
|
||||||
|
ranges: { ads: adsBar, deals: dealsBar },
|
||||||
|
scatterMini: buildScatterMini(e, streetDeals),
|
||||||
|
sources: buildSources(e),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 03 СВОДКА ОБЪЕКТА: per-section totals + data-quality block. */
|
||||||
|
export function mapSummary(
|
||||||
|
e: AggregatedEstimate,
|
||||||
|
analytics?: HouseAnalyticsResponse | null,
|
||||||
|
streetDeals?: StreetDealsResponse | null,
|
||||||
|
): Summary {
|
||||||
|
const kpi = analytics?.kpi;
|
||||||
|
|
||||||
|
const houseSold = kpi?.sold_count ?? null;
|
||||||
|
const houseExp = kpi?.median_exposure_days ?? null;
|
||||||
|
const row1Value =
|
||||||
|
houseSold != null
|
||||||
|
? `${houseSold}${houseExp != null ? ` · ${houseExp} дн` : ""}`
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
const analogMedian = median(e.analogs.map((a) => a.price_rub));
|
||||||
|
const row2Value =
|
||||||
|
e.n_analogs > 0
|
||||||
|
? `${e.n_analogs}${
|
||||||
|
analogMedian != null ? ` · ${fmtMln(analogMedian)} млн` : ""
|
||||||
|
}`
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
const dealCount = streetDeals?.count ?? e.actual_deals.length;
|
||||||
|
const dealMedian =
|
||||||
|
streetDeals?.median_price_rub ?? median(e.actual_deals.map((d) => d.price_rub));
|
||||||
|
const row3Value =
|
||||||
|
dealCount > 0
|
||||||
|
? `${dealCount}${dealMedian != null ? ` · ${fmtMln(dealMedian)} млн` : ""}`
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
const anaExp = kpi?.median_exposure_days ?? null;
|
||||||
|
const anaBargain = kpi?.median_bargain_pct ?? null;
|
||||||
|
const row4Parts = [
|
||||||
|
anaExp != null ? `${anaExp} дн` : null,
|
||||||
|
anaBargain != null ? fmtPct(anaBargain) : null,
|
||||||
|
].filter((p): p is string => p != null);
|
||||||
|
const row4Value = row4Parts.length > 0 ? row4Parts.join(" · ") : "—";
|
||||||
|
|
||||||
|
const rows: SummaryRow[] = [
|
||||||
|
{
|
||||||
|
label: "Продажи в доме",
|
||||||
|
value: row1Value,
|
||||||
|
dot: houseSold && houseSold > 0 ? "accent" : "muted",
|
||||||
|
nav: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Аналоги",
|
||||||
|
value: row2Value,
|
||||||
|
dot: e.n_analogs > 0 ? "accent" : "muted",
|
||||||
|
nav: 2,
|
||||||
|
},
|
||||||
|
// 'Сделки' is the design's secondary (muted) row regardless of count.
|
||||||
|
{ label: "Сделки", value: row3Value, dot: "muted", nav: 2 },
|
||||||
|
{
|
||||||
|
label: "Аналитика",
|
||||||
|
value: row4Value,
|
||||||
|
dot: row4Parts.length > 0 ? "accent" : "muted",
|
||||||
|
nav: 3,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
quality: {
|
||||||
|
sources: `${e.sources_used.length} / ${TOTAL_SOURCES}`,
|
||||||
|
confidence: CONFIDENCE_RU[e.confidence],
|
||||||
|
cv: cvStr(e.analogs.map((a) => a.price_per_m2)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue