feat(tradein/v2): HUD-редизайн страницы оценки (Макс) как /trade-in/v2
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 58s
CI / openapi-codegen-check (pull_request) Successful in 1m52s

Порт пиксель-дизайна МЕРА Оценка (HUD/blueprint) в новую страницу
/trade-in/v2 — v1 (/trade-in) не тронут.

- markup-first: данные из fixtures.ts (sample-данные дизайна), без API
- tokens.ts (палитра HUD), types.ts, fixtures.ts (foundation)
- Manrope + IBM Plex Mono через next/font (self-hosted)
- NAV + HERO (фото) + 01 Параметры + 02 Результат + 03 Сводка + FOOTER
- overlay-секции: Продажи в доме / Аналогичные объявления / Аналитика / Предыдущие оценки
- drawer Коэффициент локации
- интерактив (вкладки→overlay, drawer, дропдауны) на локальном useState

Wiring к боевым ручкам — отдельной задачей (см. issue).
This commit is contained in:
bot-backend 2026-06-28 12:48:42 +03:00
parent ca4637e5c8
commit b9e2561139
18 changed files with 6350 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

View file

@ -0,0 +1,46 @@
import type { Metadata } from "next";
import { IBM_Plex_Mono, Manrope } from "next/font/google";
import { pageBg } from "@/components/trade-in/v2/tokens";
// Manrope — primary sans typeface of the МЕРА HUD. next/font is bundled
// (no package.json change). Cyrillic + latin so RU labels render correctly.
const manrope = Manrope({
subsets: ["latin", "cyrillic"],
weight: ["200", "300", "400", "500", "600", "700"],
variable: "--font-manrope",
display: "swap",
});
// IBM Plex Mono — cockpit numerals / mono labels.
const plexMono = IBM_Plex_Mono({
subsets: ["latin", "cyrillic"],
weight: ["300", "400", "500"],
variable: "--font-plex-mono",
display: "swap",
});
export const metadata: Metadata = {
title: "МЕРА · Оценка v2",
};
export default function TradeInV2Layout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div
className={`${manrope.variable} ${plexMono.variable}`}
style={{
minHeight: "100vh",
background: pageBg,
display: "flex",
justifyContent: "center",
alignItems: "flex-start",
}}
>
{children}
</div>
);
}

View file

@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import type { CSSProperties } from "react";
import { tokens } from "@/components/trade-in/v2/tokens";
import TopNav from "@/components/trade-in/v2/TopNav";
import HeroBar from "@/components/trade-in/v2/HeroBar";
import ParamsPanel from "@/components/trade-in/v2/ParamsPanel";
import ResultPanel from "@/components/trade-in/v2/ResultPanel";
import { ObjectSummary } from "@/components/trade-in/v2/ObjectSummary";
import { Footer } from "@/components/trade-in/v2/Footer";
import SectionOverlay from "@/components/trade-in/v2/SectionOverlay";
import { LocationDrawer } from "@/components/trade-in/v2/LocationDrawer";
// OUTER HUD FRAME + 4 corner brackets (design lines 31-37). Decorative,
// non-interactive overlay drawn over the artboard gradient. The frame has
// chamfered octagonal corners (clip-path polygon) + 4px radius; the brackets
// sit just outside it at the artboard corners.
const FRAME_INSET = 14;
const BRACKET_INSET = 6;
const BRACKET_ARM = 22;
const BRACKET_W = 1.5;
const FRAME_CLIP =
"polygon(18px 0,calc(100% - 18px) 0,100% 18px,100% calc(100% - 18px),calc(100% - 18px) 100%,18px 100%,0 calc(100% - 18px),0 18px)";
const brackets: { key: string; style: CSSProperties }[] = [
{
key: "tl",
style: {
top: BRACKET_INSET,
left: BRACKET_INSET,
borderTop: `${BRACKET_W}px solid ${tokens.bracket2}`,
borderLeft: `${BRACKET_W}px solid ${tokens.bracket2}`,
},
},
{
key: "tr",
style: {
top: BRACKET_INSET,
right: BRACKET_INSET,
borderTop: `${BRACKET_W}px solid ${tokens.bracket2}`,
borderRight: `${BRACKET_W}px solid ${tokens.bracket2}`,
},
},
{
key: "bl",
style: {
bottom: BRACKET_INSET,
left: BRACKET_INSET,
borderBottom: `${BRACKET_W}px solid ${tokens.bracket2}`,
borderLeft: `${BRACKET_W}px solid ${tokens.bracket2}`,
},
},
{
key: "br",
style: {
bottom: BRACKET_INSET,
right: BRACKET_INSET,
borderBottom: `${BRACKET_W}px solid ${tokens.bracket2}`,
borderRight: `${BRACKET_W}px solid ${tokens.bracket2}`,
},
},
];
export default function TradeInV2Page() {
const [nav, setNav] = useState(0);
const [drawerOpen, setDrawerOpen] = useState(false);
return (
<div
style={{
position: "relative",
width: 1536,
height: 1024,
overflow: "hidden",
background: tokens.gradientBg,
fontFamily: tokens.font.sans,
color: tokens.ink,
}}
>
{/* OUTER HUD FRAME */}
<div
style={{
position: "absolute",
inset: FRAME_INSET,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
clipPath: FRAME_CLIP,
pointerEvents: "none",
}}
/>
{/* 4 corner brackets */}
{brackets.map((b) => (
<div
key={b.key}
style={{
position: "absolute",
width: BRACKET_ARM,
height: BRACKET_ARM,
pointerEvents: "none",
...b.style,
}}
/>
))}
{/* CONTENT WRAP */}
<div
style={{
position: "absolute",
inset: 14,
padding: "16px 28px",
display: "flex",
flexDirection: "column",
}}
>
<TopNav active={nav} onNavigate={setNav} />
<HeroBar onOpenInfo={() => setDrawerOpen(true)} />
<div
style={{
display: "grid",
gridTemplateColumns: "474px 1fr 250px",
gap: 18,
flex: 1,
minHeight: 0,
}}
>
<ParamsPanel />
<ResultPanel onNavigate={setNav} />
<ObjectSummary onNavigate={setNav} />
</div>
<Footer />
</div>
{nav !== 0 && (
<SectionOverlay
active={nav}
onClose={() => setNav(0)}
onNavigate={setNav}
/>
)}
<LocationDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
</div>
);
}

View file

@ -0,0 +1,551 @@
"use client";
// Overlay 06 "АНАЛИТИКА ДОМА" — markup port from the МЕРА Оценка pixel design.
// Data comes from fixtures (no API); the price-history dots navigate to the
// "Аналогичные объявления" overlay via onNavigate?.(2).
import { useState } from "react";
import { tokens } from "./tokens";
import { analytics } from "./fixtures";
import type { SellTimeTier } from "./types";
interface AnalyticsViewProps {
onNavigate?: (i: number) => void;
}
// ---- price-history geometry -----------------------------------------------
// Parse the SVG polyline point strings into coordinates so the markers and
// the transparent hover targets can be derived from the same source.
interface Pt {
x: number;
y: number;
}
function parsePoints(s: string): Pt[] {
return s
.trim()
.split(/\s+/)
.map((pair) => {
const [x, y] = pair.split(",").map(Number);
return { x, y };
});
}
const avitoPts = parsePoints(analytics.priceHistory.avito);
const yandexPts = parsePoints(analytics.priceHistory.yandex);
// Яндекс only draws dots where its series diverges from Avito (последние 3 года).
const yandexDots = yandexPts.filter(
(p, i) => !avitoPts[i] || p.x !== avitoPts[i].x || p.y !== avitoPts[i].y,
);
const hoverPts: Pt[] = [...avitoPts, ...yandexDots];
// X positions of the year axis labels (chart scaffolding, not in fixtures).
const histYearX = [50, 250, 460, 670, 860];
// ---- sell-time tier tints --------------------------------------------------
// One-off decorative tints per variant (design lines 553-556). The soft border
// hex (#bfe3d2 / #e3d2ad / #e6c3c3) and the rgba background fills are documented
// one-off design tints with no token equivalent; the label text colour reuses
// semantic tokens (success / accent / warn / danger).
const tierStyles: Record<
SellTimeTier["variant"],
{ bg: string; border: string; label: string }
> = {
success: {
bg: "rgba(27,170,107,.07)",
border: "1px solid #bfe3d2",
label: tokens.success,
},
accent: {
bg: "rgba(46,139,255,.08)",
border: `1.5px solid ${tokens.accent}`,
label: tokens.accent,
},
gold: {
bg: "rgba(201,154,62,.09)",
border: "1px solid #e3d2ad",
label: tokens.warn,
},
danger: {
bg: "rgba(214,90,90,.07)",
border: "1px solid #e6c3c3",
label: tokens.danger,
},
};
const cardStyle = {
background: tokens.surface.w55,
border: `1px solid ${tokens.line2}`,
borderRadius: 8,
padding: "16px 18px",
} as const;
export default function AnalyticsView({ onNavigate }: AnalyticsViewProps) {
const [hoverHist, setHoverHist] = useState(false);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
{/* header */}
<div
style={{
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
}}
>
<div style={{ fontSize: 13, fontWeight: 600, color: tokens.ink2 }}>
Аналитика дома
</div>
<div style={{ fontSize: 10, color: tokens.muted2 }}>
{analytics.header.ads} · {analytics.header.radius}
</div>
</div>
{/* KPI row */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3,1fr)",
gap: 16,
}}
>
{analytics.kpis.map((kpi, i) => (
<div key={i} style={cardStyle}>
<div
style={{
fontSize: 9.5,
letterSpacing: 1.5,
color: tokens.muted2,
}}
>
{kpi.label}
</div>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 30,
fontWeight: 300,
color: kpi.color ?? tokens.ink2,
margin: "8px 0 5px",
}}
>
{kpi.value}
{kpi.unit ? (
<>
{" "}
<span style={{ fontSize: 14, color: tokens.muted }}>
{kpi.unit}
</span>
</>
) : null}
</div>
<div style={{ fontSize: 10, color: tokens.hint }}>{kpi.sub}</div>
</div>
))}
</div>
{/* срок продажи в зависимости от цены */}
<div style={cardStyle}>
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
Срок продажи в зависимости от цены
</div>
<div
style={{ fontSize: 10, color: tokens.muted2, margin: "4px 0 14px" }}
>
{analytics.sellTimeNote}
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4,1fr)",
gap: 12,
}}
>
{analytics.sellTime.map((tier, i) => {
const v = tierStyles[tier.variant];
return (
<div
key={i}
style={{
background: v.bg,
border: v.border,
borderRadius: 7,
padding: "13px 15px",
}}
>
<div
style={{ fontSize: 9.5, color: v.label, letterSpacing: 0.5 }}
>
{tier.tier}
</div>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 26,
fontWeight: 300,
color: tokens.ink2,
margin: "6px 0 4px",
}}
>
{tier.days}
</div>
<div
style={{ fontSize: 9.5, color: tokens.hint, lineHeight: 1.5 }}
>
{tier.range}
<br />
{tier.count}
</div>
</div>
);
})}
</div>
</div>
{/* история цен */}
<div style={{ position: "relative", ...cardStyle }}>
<div
style={{
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
}}
>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
История цен в этом доме
</div>
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
{analytics.priceHistory.note}
</div>
</div>
<div style={{ display: "flex", gap: 16, fontSize: 10 }}>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span
style={{ width: 14, height: 2, background: tokens.accent }}
/>
Avito
</span>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span style={{ width: 14, height: 2, background: tokens.gold }} />
Яндекс
</span>
</div>
</div>
<svg
viewBox="0 0 900 220"
style={{ width: "100%", height: 200, marginTop: 12 }}
preserveAspectRatio="none"
>
<g stroke={tokens.lineSoft2} strokeWidth={1}>
<line x1={40} y1={20} x2={900} y2={20} />
<line x1={40} y1={65} x2={900} y2={65} />
<line x1={40} y1={110} x2={900} y2={110} />
<line x1={40} y1={155} x2={900} y2={155} />
<line x1={40} y1={195} x2={900} y2={195} />
</g>
<g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}>
<text x={0} y={24}>
200k
</text>
<text x={0} y={69}>
150k
</text>
<text x={0} y={114}>
100k
</text>
<text x={0} y={159}>
50k
</text>
<text x={8} y={199}>
0k
</text>
</g>
<g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}>
{analytics.priceHistory.years.map((yr, i) => (
<text key={yr} x={histYearX[i]} y={214}>
{yr}
</text>
))}
</g>
<polyline
points={analytics.priceHistory.avito}
fill="none"
stroke={tokens.accent}
strokeWidth={2.5}
/>
<polyline
points={analytics.priceHistory.yandex}
fill="none"
stroke={tokens.gold}
strokeWidth={2.5}
/>
{/* white dot fill on an accent ring — documented one-off, no token (design l.568) */}
<g
fill="#fff"
stroke={tokens.accent}
strokeWidth={2}
style={{ cursor: "pointer" }}
>
<title>Архивные объявления Avito · нажмите, чтобы открыть</title>
{avitoPts.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={3.5} />
))}
</g>
<g
fill={tokens.gold}
stroke={tokens.gold}
strokeWidth={2}
style={{ cursor: "pointer" }}
>
{yandexDots.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={3.5} />
))}
</g>
<g
fill="transparent"
style={{ cursor: "pointer" }}
onMouseEnter={() => setHoverHist(true)}
onMouseLeave={() => setHoverHist(false)}
onClick={() => onNavigate?.(2)}
>
{hoverPts.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={13} />
))}
</g>
</svg>
{hoverHist ? (
// Dark navy tooltip surface + white text — documented one-off literals
// matching the design (l.572); no token equivalent.
<div
style={{
position: "absolute",
left: 64,
top: 54,
background: "rgba(23,38,58,.96)",
color: "#fff",
borderRadius: 7,
padding: "9px 13px",
fontSize: 11,
lineHeight: 1.5,
zIndex: 6,
pointerEvents: "none",
boxShadow: "0 10px 26px rgba(20,40,70,.28)",
}}
>
<div style={{ fontWeight: 600 }}>
Архивные объявления этого дома
</div>
<div style={{ color: tokens.barMid, fontSize: 10, marginTop: 2 }}>
Нажмите, чтобы открыть список
</div>
</div>
) : null}
</div>
{/* цена × срок продажи (детально) */}
<div style={cardStyle}>
<div
style={{
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
}}
>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
Цена × срок продажи
</div>
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
Зависимость цены от срока экспозиции ·{" "}
{analytics.scatterDetail.note}
</div>
</div>
<div style={{ display: "flex", gap: 14, fontSize: 10 }}>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.accent,
}}
/>
аналоги
</span>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.scatterDeal,
}}
/>
сделки
</span>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.ink,
}}
/>
объект
</span>
</div>
</div>
<svg
viewBox="0 0 900 300"
style={{ width: "100%", height: 280, marginTop: 12 }}
>
<g stroke={tokens.lineSoft2} strokeWidth={1}>
<line x1={60} y1={20} x2={880} y2={20} />
<line x1={60} y1={77} x2={880} y2={77} />
<line x1={60} y1={134} x2={880} y2={134} />
<line x1={60} y1={191} x2={880} y2={191} />
<line x1={60} y1={250} x2={880} y2={250} />
<line x1={265} y1={20} x2={265} y2={250} />
<line x1={470} y1={20} x2={470} y2={250} />
<line x1={675} y1={20} x2={675} y2={250} />
</g>
<line
x1={60}
y1={20}
x2={60}
y2={250}
stroke={tokens.line3}
strokeWidth={1.5}
/>
<line
x1={60}
y1={250}
x2={880}
y2={250}
stroke={tokens.line3}
strokeWidth={1.5}
/>
<line
x1={100}
y1={226}
x2={820}
y2={55}
stroke={tokens.accent}
strokeWidth={1.5}
strokeDasharray="5 5"
opacity={0.45}
/>
<g fill={tokens.scatterDeal}>
{analytics.scatterDetail.deals.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
))}
</g>
<g fill={tokens.accent}>
{analytics.scatterDetail.analogs.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
))}
</g>
<circle
cx={analytics.scatterDetail.subject.x}
cy={analytics.scatterDetail.subject.y}
r={analytics.scatterDetail.subject.r}
fill={tokens.ink}
/>
<circle
cx={analytics.scatterDetail.subject.x}
cy={analytics.scatterDetail.subject.y}
r={11}
fill="none"
stroke={tokens.ink}
strokeWidth={1.2}
opacity={0.4}
/>
<g
fontFamily={tokens.font.mono}
fontSize={11}
fill={tokens.muted2}
textAnchor="end"
>
{analytics.scatterDetail.yTicks.map((t) => (
<text key={t.label} x={52} y={t.y}>
{t.label}
</text>
))}
</g>
<g
fontFamily={tokens.font.mono}
fontSize={11}
fill={tokens.muted2}
textAnchor="middle"
>
{analytics.scatterDetail.xTicks.map((t) => (
<text key={t.label} x={t.x} y={268}>
{t.label}
</text>
))}
</g>
<text
x={470}
y={288}
fontSize={10}
letterSpacing={1}
fill={tokens.muted2}
textAnchor="middle"
>
СРОК ПРОДАЖИ, ДН
</text>
<text
x={18}
y={135}
fontSize={10}
letterSpacing={1}
fill={tokens.muted2}
textAnchor="middle"
transform="rotate(-90 18 135)"
>
ЦЕНА ПРОДАЖИ,
</text>
</svg>
</div>
</div>
);
}

View file

@ -0,0 +1,147 @@
// OVERLAY 07: ПРЕДЫДУЩИЕ ОЦЕНКИ (cache view).
// Faithful markup port of МЕРА Оценка.dc.html lines 593-613.
// Data from fixtures (cacheKpi + cacheRows); no API, no local state.
import { tokens } from "./tokens";
import { cacheKpi, cacheRows } from "./fixtures";
const GRID_COLUMNS = "2.4fr 1fr 1fr 1fr";
export function CacheView() {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
{/* 3 KPI cards */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3,1fr)",
gap: 16,
}}
>
{cacheKpi.map((kpi) => (
<div
key={kpi.label}
style={{
background: tokens.surface.w55,
border: `1px solid ${tokens.line2}`,
borderRadius: 8,
padding: "16px 18px",
}}
>
<div
style={{
fontSize: 9.5,
letterSpacing: 1.5,
color: tokens.muted2,
}}
>
{kpi.label}
</div>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 30,
fontWeight: 300,
color: tokens.ink2,
margin: "8px 0 5px",
}}
>
{kpi.value}
{kpi.unit && (
<>
{kpi.unit !== "%" && " "}
<span style={{ fontSize: 14, color: tokens.muted }}>
{kpi.unit}
</span>
</>
)}
</div>
<div style={{ fontSize: 10, color: tokens.hint }}>{kpi.sub}</div>
</div>
))}
</div>
{/* Последние оценки table */}
<div
style={{
background: tokens.surface.w55,
border: `1px solid ${tokens.line2}`,
borderRadius: 8,
overflow: "hidden",
}}
>
<div
style={{
padding: "14px 18px",
borderBottom: `1px solid ${tokens.lineSoft}`,
fontSize: 12,
fontWeight: 600,
color: tokens.ink2,
}}
>
Последние оценки
</div>
<div
style={{
display: "grid",
gridTemplateColumns: GRID_COLUMNS,
gap: 10,
padding: "9px 18px",
fontSize: 9,
letterSpacing: 1,
color: tokens.muted2,
borderBottom: `1px solid ${tokens.lineSoft2}`,
}}
>
<span>АДРЕС</span>
<span>ВРЕМЯ</span>
<span>ИСТОЧНИКОВ</span>
<span>СТАТУС</span>
</div>
{cacheRows.map((r, i) => (
<div
key={`${r.addr}-${i}`}
style={{
display: "grid",
gridTemplateColumns: GRID_COLUMNS,
gap: 10,
padding: "12px 18px",
fontSize: 11,
borderBottom: `1px solid ${tokens.lineSoft3}`,
alignItems: "center",
}}
>
<span style={{ color: tokens.body }}>{r.addr}</span>
<span
style={{ fontFamily: tokens.font.mono, color: tokens.muted2 }}
>
{r.time}
</span>
<span style={{ fontFamily: tokens.font.mono, color: tokens.muted }}>
{r.src}
</span>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 10,
color: r.statusColor,
}}
>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: r.statusColor,
}}
/>
{r.status}
</span>
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,123 @@
// Report footer for the /trade-in/v2 "МЕРА Оценка" design port.
// Faithful markup port of the design footer (МЕРА Оценка.dc.html, lines 426-439):
// report id / date / valid-until on the left, a decorative centre line, and the
// МЕРА v2.0.6 wordmark on the right. Static markup, data from fixtures.
import { tokens } from "./tokens";
import { report, version } from "./fixtures";
export function Footer() {
return (
<div
style={{
height: 36,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderTop: "1px solid rgba(150,175,200,.4)",
marginTop: 14,
flex: "0 0 auto",
fontFamily: tokens.font.mono,
fontSize: 10,
letterSpacing: ".5px",
color: tokens.muted2,
}}
>
<div style={{ display: "flex", gap: 22 }}>
<span>
ОТЧЁТ <span style={{ color: tokens.muted }}>{report.id}</span>
</span>
<span>
ДАТА <span style={{ color: tokens.muted }}>{report.date}</span>
</span>
<span>
ДЕЙСТВИТЕЛЕН ДО{" "}
<span style={{ color: tokens.muted }}>{report.validUntil}</span>
</span>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
flex: 1,
justifyContent: "center",
maxWidth: 420,
}}
>
<span
style={{
width: 3,
height: 3,
borderRadius: "50%",
background: tokens.muted4,
}}
/>
<span
style={{
width: 3,
height: 3,
borderRadius: "50%",
background: tokens.muted4,
}}
/>
<div
style={{
flex: 1,
height: 1,
background: `linear-gradient(90deg,transparent,${tokens.lineGradient} 30%,${tokens.accent} 50%,${tokens.lineGradient} 70%,transparent)`,
}}
/>
<span
style={{
width: 3,
height: 3,
borderRadius: "50%",
background: tokens.muted4,
}}
/>
<span
style={{
width: 3,
height: 3,
borderRadius: "50%",
background: tokens.muted4,
}}
/>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
<span
style={{
width: 5,
height: 5,
borderRadius: "50%",
background: tokens.accent,
}}
/>
<span
style={{
fontFamily: tokens.font.sans,
fontSize: 12,
fontWeight: 600,
letterSpacing: "4px",
color: tokens.muted,
}}
>
МЕРА
</span>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 9,
letterSpacing: "1px",
color: tokens.muted4,
}}
>
{version}
</span>
</div>
</div>
);
}

View file

@ -0,0 +1,484 @@
"use client";
import { tokens } from "./tokens";
import { object, report } from "./fixtures";
interface HeroBarProps {
onOpenInfo: () => void;
}
export default function HeroBar({ onOpenInfo }: HeroBarProps) {
return (
<div
style={{
display: "flex",
gap: 30,
padding: "14px 2px 12px",
flex: "0 0 auto",
}}
>
<style>{`
.hero-pdf-btn:hover { border-color: ${tokens.accent}; background: rgba(46,139,255,.07); }
.hero-pdf-btn:active { transform: translateY(1px); }
.hero-calc-btn:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
.hero-coef-row:hover { background: rgba(46,139,255,.09); }
@keyframes hero-scanv { 0% { transform: translateY(-100%); } 100% { transform: translateY(900%); } }
`}</style>
{/* LEFT: meta + buttons */}
<div
style={{
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "column",
justifyContent: "center",
gap: 22,
}}
>
<div style={{ display: "flex", gap: 26, alignItems: "flex-start" }}>
<div>
<div
style={{
fontSize: 9,
letterSpacing: "2px",
color: tokens.muted2,
marginBottom: 6,
}}
>
ОТЧЁТ
</div>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 14,
color: tokens.ink,
}}
>
{report.id}
</div>
</div>
<div
style={{
borderLeft: `1px solid ${tokens.lineSoft}`,
paddingLeft: 26,
}}
>
<div
style={{
fontSize: 9,
letterSpacing: "2px",
color: tokens.muted2,
marginBottom: 6,
}}
>
ДАТА
</div>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 14,
color: tokens.ink,
}}
>
{report.date}
</div>
</div>
<div
style={{
borderLeft: `1px solid ${tokens.lineSoft}`,
paddingLeft: 26,
}}
>
<div
style={{
fontSize: 9,
letterSpacing: "2px",
color: tokens.muted2,
marginBottom: 6,
}}
>
ДЕЙСТВИТЕЛЕН ДО
</div>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 14,
color: tokens.ink,
}}
>
{report.validUntil}
</div>
</div>
</div>
<div style={{ display: "flex", gap: 12, width: "100%", maxWidth: 440 }}>
<button
type="button"
className="hero-pdf-btn"
style={{
flex: 1,
height: 50,
background: tokens.surface.w55,
border: `1px solid ${tokens.line}`,
borderRadius: 7,
display: "flex",
alignItems: "center",
gap: 11,
padding: "0 16px",
cursor: "pointer",
fontFamily: tokens.font.sans,
color: tokens.ink,
transition: "all .15s",
}}
>
<svg width="18" height="20" viewBox="0 0 18 20" fill="none">
<path d="M2 1h9l5 5v13H2z" stroke="#2e8bff" strokeWidth="1.2" />
<path d="M11 1v5h5" stroke="#2e8bff" strokeWidth="1.2" />
<line x1="5" y1="11" x2="13" y2="11" stroke="#6f8195" />
<line x1="5" y1="14" x2="13" y2="14" stroke="#6f8195" />
</svg>
<span
style={{ fontSize: 11, fontWeight: 600, letterSpacing: "1px" }}
>
СКАЧАТЬ PDF-ОТЧЁТ
</span>
</button>
<button
type="button"
className="hero-calc-btn"
style={{
flex: 1,
height: 50,
background: tokens.surface.w40,
border: `1px solid ${tokens.line}`,
borderRadius: 7,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
fontFamily: tokens.font.sans,
color: tokens.muted,
fontSize: 11,
fontWeight: 600,
letterSpacing: "1.5px",
transition: "all .15s",
}}
>
КАК РАССЧИТАНО
</button>
</div>
</div>
{/* RIGHT: photo + overlays */}
<div
style={{
position: "relative",
width: 560,
height: 152,
flex: "0 0 auto",
border: `1px solid ${tokens.line3}`,
borderRadius: 6,
overflow: "hidden",
background: tokens.photoBg,
boxShadow: "0 6px 26px rgba(40,80,130,.10)",
}}
>
<img
src="/trade-in-v2/building.png"
alt=""
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "contain",
objectPosition: "right center",
filter: "saturate(.9) brightness(1.04)",
}}
/>
<div
style={{
position: "absolute",
inset: 0,
background:
"linear-gradient(90deg,rgba(238,244,250,.96) 0%,rgba(238,244,250,.5) 22%,transparent 40%)",
}}
/>
<div
style={{
position: "absolute",
inset: 0,
background:
"linear-gradient(0deg,rgba(230,240,250,.45),transparent 40%)",
}}
/>
<div
style={{
position: "absolute",
left: 0,
right: 0,
top: "34%",
height: 1,
background:
"linear-gradient(90deg,transparent,rgba(46,139,255,.5),transparent)",
animation: "hero-scanv 6s linear infinite",
}}
/>
{/* address card */}
<div
style={{
position: "absolute",
left: 16,
top: "50%",
transform: "translateY(-50%)",
width: 182,
background: tokens.surface.w72,
backdropFilter: "blur(5px)",
border: `1px solid ${tokens.line}`,
borderRadius: 5,
padding: "11px 13px",
}}
>
<div
style={{
fontSize: 9,
letterSpacing: "2.5px",
color: tokens.muted2,
marginBottom: 3,
}}
>
АДРЕС
</div>
<div style={{ fontSize: 14, fontWeight: 600, lineHeight: 1.3 }}>
{object.address}
<br />
<span
style={{ fontSize: 11, fontWeight: 400, color: tokens.muted }}
>
{object.city}
</span>
</div>
<div
style={{ height: 1, background: tokens.lineSoft, margin: "8px 0" }}
/>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 9,
color: tokens.muted,
lineHeight: 1.55,
}}
>
{object.streetView}
</div>
<div
style={{ height: 1, background: tokens.lineSoft, margin: "8px 0" }}
/>
<div
className="hero-coef-row"
onClick={onOpenInfo}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
cursor: "pointer",
borderRadius: 4,
margin: "-3px -5px",
padding: "3px 5px",
transition: "background .15s",
}}
>
<span
style={{
fontSize: "8.5px",
letterSpacing: "1.5px",
color: tokens.muted2,
}}
>
КОЭФ. ЛОКАЦИИ
</span>
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 15,
fontWeight: 500,
color: tokens.accent,
}}
>
{object.locationCoef}
</span>
<span
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 14,
height: 14,
border: `1px solid ${tokens.accent}`,
borderRadius: "50%",
fontSize: "8.5px",
color: tokens.accent,
fontWeight: 600,
}}
>
?
</span>
</span>
</div>
</div>
{/* compass */}
<div
style={{
position: "absolute",
right: 16,
top: 14,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 2,
color: tokens.accent,
}}
>
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
<circle cx="11" cy="11" r="10" stroke="#2e8bff" strokeWidth="1" />
<path d="M11 3 L13 11 L11 9 L9 11 Z" fill="#2e8bff" />
</svg>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 8,
letterSpacing: ".5px",
color: tokens.muted,
whiteSpace: "nowrap",
}}
>
{object.compass}
</span>
</div>
{/* distance scale */}
<div
style={{
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
bottom: 16,
width: 170,
fontFamily: tokens.font.mono,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-end",
height: 8,
borderBottom: "1px solid rgba(46,139,255,.55)",
}}
>
<span
style={{
width: 1,
height: 8,
background: "rgba(46,139,255,.55)",
}}
/>
<span
style={{
width: 1,
height: 5,
background: "rgba(46,139,255,.45)",
}}
/>
<span
style={{
width: 1,
height: 5,
background: "rgba(46,139,255,.45)",
}}
/>
<span
style={{
width: 1,
height: 5,
background: "rgba(46,139,255,.45)",
}}
/>
<span
style={{
width: 1,
height: 8,
background: "rgba(46,139,255,.55)",
}}
/>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: "7.5px",
color: tokens.muted,
marginTop: 3,
}}
>
<span>0</span>
<span>25</span>
<span>50</span>
<span>75</span>
<span>100м</span>
</div>
</div>
{/* corner brackets */}
<div
style={{
position: "absolute",
left: 8,
top: 8,
width: 14,
height: 14,
borderLeft: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`,
}}
/>
<div
style={{
position: "absolute",
right: 8,
top: 8,
width: 14,
height: 14,
borderRight: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`,
}}
/>
<div
style={{
position: "absolute",
left: 8,
bottom: 8,
width: 14,
height: 14,
borderLeft: `1.5px solid ${tokens.accent}`,
borderBottom: `1.5px solid ${tokens.accent}`,
}}
/>
<div
style={{
position: "absolute",
right: 8,
bottom: 8,
width: 14,
height: 14,
borderRight: `1.5px solid ${tokens.accent}`,
borderBottom: `1.5px solid ${tokens.accent}`,
}}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,359 @@
// Overlay 04 "ПРОДАЖИ В ДОМЕ" body for the /trade-in/v2 "МЕРА Оценка" port.
// Faithful markup port of the design's showHistory block (МЕРА Оценка.dc.html,
// lines 457-489): "История продаж в этом доме" card (house sales) +
// "ДКП-сделки на улице" card (3 KPI + dealine table + footer count).
// Static markup — data comes from fixtures, no fetch / no local state.
import { tokens } from "./tokens";
import { history, dkpRows } from "./fixtures";
// Shared grid templates (kept verbatim from the design column tracks).
const houseGrid = "2fr 1.4fr 1fr 1fr";
const dkpGrid = "2.2fr 1fr 1.3fr 1fr .9fr 2fr";
export default function HistoryView() {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "22px" }}>
{/* ── История продаж в этом доме ───────────────────────────── */}
<div
style={{
background: tokens.surface.w55,
border: `1px solid ${tokens.line2}`,
borderRadius: "8px",
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "14px 18px",
borderBottom: `1px solid ${tokens.lineSoft}`,
}}
>
<span
style={{
fontSize: "12px",
fontWeight: 600,
letterSpacing: "1px",
color: tokens.ink2,
}}
>
История продаж в этом доме
</span>
<span
style={{
fontSize: "10px",
letterSpacing: "1px",
color: tokens.muted2,
}}
>
{history.houseSales.length} ЛОТА
</span>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: houseGrid,
gap: "12px",
padding: "9px 18px",
fontSize: "9px",
letterSpacing: "1.2px",
color: tokens.muted2,
borderBottom: `1px solid ${tokens.lineSoft2}`,
}}
>
<span>ЛОТ</span>
<span>ЦЕНА НАЧ. ПОСЛ.</span>
<span>ДАТА</span>
<span>ЭКСПОЗИЦИЯ</span>
</div>
{history.houseSales.map((row, i) => (
<div
key={i}
style={{
display: "grid",
gridTemplateColumns: houseGrid,
gap: "12px",
padding: "13px 18px",
fontSize: "12px",
borderBottom:
i < history.houseSales.length - 1
? `1px solid ${tokens.lineSoft4}`
: undefined,
}}
>
<span style={{ color: tokens.body }}>{row.lot}</span>
<span style={{ fontFamily: tokens.font.mono, color: tokens.muted }}>
{row.priceFrom} {" "}
<b style={{ color: tokens.ink2 }}>{row.priceTo}</b>
</span>
<span style={{ fontFamily: tokens.font.mono, color: tokens.muted }}>
{row.date}
</span>
<span style={{ fontFamily: tokens.font.mono, color: tokens.muted }}>
{row.exposure}
</span>
</div>
))}
</div>
{/* ── ДКП-сделки на улице ──────────────────────────────────── */}
<div
style={{
background: tokens.surface.w55,
border: `1px solid ${tokens.line2}`,
borderRadius: "8px",
overflow: "hidden",
}}
>
<div
style={{
padding: "14px 18px",
borderBottom: `1px solid ${tokens.lineSoft}`,
}}
>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
}}
>
<div>
<div
style={{
fontSize: "8.5px",
letterSpacing: "2px",
color: tokens.muted2,
marginBottom: "5px",
}}
>
ПОХОЖИЕ КВАРТИРЫ · ПО ВАШЕЙ УЛИЦЕ
</div>
<div
style={{
fontSize: "12px",
fontWeight: 600,
letterSpacing: ".5px",
color: tokens.ink2,
}}
>
{history.dkpHeader.title}
</div>
</div>
<div
style={{
textAlign: "right",
fontSize: "9.5px",
color: tokens.muted2,
lineHeight: 1.6,
}}
>
Период:{" "}
<b style={{ color: tokens.muted }}>{history.dkpHeader.period}</b>
<br />
Источник: Росреестр + объявления
</div>
</div>
<div
style={{
fontSize: "10px",
color: tokens.hint,
marginTop: "8px",
}}
>
{history.dkpHeader.note}
</div>
</div>
{/* KPI band */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3,1fr)",
borderBottom: `1px solid ${tokens.lineSoft}`,
background: tokens.surfaceTint,
}}
>
<div
style={{
padding: "12px 18px",
borderRight: `1px solid ${tokens.lineSoft2}`,
}}
>
<div
style={{
fontSize: "9px",
letterSpacing: "1.5px",
color: tokens.muted2,
}}
>
СДЕЛОК ЗА 24 МЕС
</div>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: "22px",
fontWeight: 300,
color: tokens.ink2,
marginTop: "4px",
}}
>
{history.dkpKpi.count}
</div>
</div>
<div
style={{
padding: "12px 18px",
borderRight: `1px solid ${tokens.lineSoft2}`,
}}
>
<div
style={{
fontSize: "9px",
letterSpacing: "1.5px",
color: tokens.muted2,
}}
>
МЕДИАНА
</div>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: "22px",
fontWeight: 300,
color: tokens.ink2,
marginTop: "4px",
}}
>
{history.dkpKpi.median}{" "}
<span style={{ fontSize: "11px", color: tokens.muted }}>
/м²
</span>
</div>
</div>
<div style={{ padding: "12px 18px" }}>
<div
style={{
fontSize: "9px",
letterSpacing: "1.5px",
color: tokens.muted2,
}}
>
ДИАПАЗОН
</div>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: "22px",
fontWeight: 300,
color: tokens.accent,
marginTop: "4px",
}}
>
{history.dkpKpi.range}{" "}
<span style={{ fontSize: "11px", color: tokens.muted }}>
млн
</span>
</div>
</div>
</div>
{/* Table header */}
<div
style={{
display: "grid",
gridTemplateColumns: dkpGrid,
gap: "10px",
padding: "9px 18px",
fontSize: "9px",
letterSpacing: "1px",
color: tokens.muted2,
borderBottom: `1px solid ${tokens.lineSoft2}`,
}}
>
<span>АДРЕС</span>
<span>ПЛОЩАДЬ</span>
<span>ЦЕНА СДЕЛКИ</span>
<span>/М²</span>
<span>ДАТА</span>
<span>ОБЪЯВЛЕНИЕ ДО СДЕЛКИ</span>
</div>
{dkpRows.map((r, i) => (
<div
key={i}
style={{
display: "grid",
gridTemplateColumns: dkpGrid,
gap: "10px",
padding: "11px 18px",
fontSize: "11px",
borderBottom: `1px solid ${tokens.lineSoft3}`,
alignItems: "center",
}}
>
<span style={{ color: tokens.body }}>
Екатеринбург, Академика Ландау
</span>
<span style={{ fontFamily: tokens.font.mono, color: tokens.muted }}>
{r.area}
</span>
<span style={{ fontFamily: tokens.font.mono, color: tokens.ink2 }}>
{r.price}
</span>
<span style={{ fontFamily: tokens.font.mono, color: tokens.muted }}>
{r.ppm}
</span>
<span
style={{ fontFamily: tokens.font.mono, color: tokens.muted2 }}
>
01.01.26
</span>
<span
style={{
display: "flex",
alignItems: "center",
gap: "7px",
fontFamily: tokens.font.mono,
fontSize: "10px",
color: tokens.muted,
}}
>
<span
style={{
fontSize: "8px",
letterSpacing: ".5px",
background: tokens.badgeTint,
border: `1px solid ${tokens.line2}`,
borderRadius: "3px",
padding: "1px 5px",
color: tokens.accent,
}}
>
Яндекс
</span>
{r.ask}
<span style={{ color: r.deltaColor }}>{r.delta}</span>
</span>
</div>
))}
<div
style={{
padding: "11px 18px",
fontSize: "10px",
color: tokens.hint,
background: tokens.surfaceTint,
}}
>
Показано {dkpRows.length} из {history.dkpKpi.count} сделок
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,330 @@
"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).
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) {
return (
<>
<style>{`
.ld-close {
width: 32px;
height: 32px;
flex: 0 0 auto;
border: 1px solid ${tokens.line};
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: ${tokens.muted};
font-size: 16px;
background: ${tokens.surface.w60};
transition: all .15s;
}
.ld-close:hover {
border-color: ${tokens.accent};
color: ${tokens.accent};
}
`}</style>
{/* scrim */}
<div
onClick={onClose}
style={{
position: "absolute",
inset: 0,
background: "rgba(22,42,70,.32)",
backdropFilter: "blur(2px)",
opacity: open ? 1 : 0,
pointerEvents: open ? "auto" : "none",
transition: "opacity .3s",
zIndex: 40,
}}
/>
{/* drawer */}
<div
style={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
width: 452,
transform: open ? "translateX(0)" : "translateX(108%)",
transition: "transform .38s cubic-bezier(.22,.61,.36,1)",
zIndex: 41,
background:
"linear-gradient(180deg,rgba(245,249,253,.97),rgba(235,242,250,.97))",
backdropFilter: "blur(10px)",
borderLeft: `1px solid ${tokens.line3}`,
boxShadow: "-18px 0 50px rgba(40,80,130,.16)",
display: "flex",
flexDirection: "column",
padding: "30px 32px",
overflowY: "auto",
fontFamily: tokens.font.sans,
}}
>
{/* left accent rail */}
<div
style={{
position: "absolute",
left: 0,
top: 18,
bottom: 18,
width: 1,
background: `linear-gradient(180deg,transparent,${tokens.accent},transparent)`,
opacity: 0.5,
}}
/>
{/* corner brackets */}
<div
style={{
position: "absolute",
left: 14,
top: 14,
width: 16,
height: 16,
borderLeft: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`,
}}
/>
<div
style={{
position: "absolute",
right: 14,
bottom: 14,
width: 16,
height: 16,
borderRight: `1.5px solid ${tokens.accent}`,
borderBottom: `1.5px solid ${tokens.accent}`,
}}
/>
{/* header */}
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
}}
>
<div>
<div
style={{
fontSize: 9,
letterSpacing: "3px",
color: tokens.muted2,
}}
>
ПОЯСНЕНИЕ К РАСЧЁТУ
</div>
<div
style={{
fontSize: 22,
fontWeight: 300,
letterSpacing: "1.5px",
color: tokens.ink2,
marginTop: 8,
}}
>
КОЭФФИЦИЕНТ ЛОКАЦИИ
</div>
</div>
<div className="ld-close" onClick={onClose}>
</div>
</div>
{/* big coefficient */}
<div
style={{
display: "flex",
alignItems: "baseline",
gap: 14,
marginTop: 24,
paddingBottom: 20,
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 */}
<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> из-за баланса факторов ниже.
</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 */}
<div
style={{
fontSize: 9,
letterSpacing: "2px",
color: tokens.muted2,
marginTop: 26,
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,
}}
>
ЧТО СНИЖАЕТ ЦЕНУ
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
{locationFactors.negatives.map((f, i) => (
<FactorRow key={i} factor={f} variant="neg" />
))}
</div>
{/* footer source */}
<div
style={{
marginTop: 24,
paddingTop: 16,
borderTop: `1px solid ${tokens.lineSoft}`,
fontSize: 10.5,
lineHeight: 1.6,
color: tokens.hint,
}}
>
{locationFactors.footer}
</div>
</div>
</>
);
}

View file

@ -0,0 +1,302 @@
// 03 СВОДКА ОБЪЕКТА — right-rail summary card for the /trade-in/v2 "МЕРА Оценка"
// design port. Faithful markup port of МЕРА Оценка.dc.html lines 397-422:
// address + "В РАСЧЁТЕ" badge, four clickable per-section totals, and a
// "КАЧЕСТВО ДАННЫХ" block with a donut. Data from fixtures; clicks fire
// onNavigate(row.nav) only — no API, no fetching.
"use client";
import { tokens } from "./tokens";
import { object, summary } from "./fixtures";
interface ObjectSummaryProps {
onNavigate: (i: number) => void;
}
export function ObjectSummary({ onNavigate }: ObjectSummaryProps) {
return (
<div
style={{
position: "relative",
background: tokens.surface.w50,
backdropFilter: "blur(6px)",
border: `1px solid ${tokens.line2}`,
borderRadius: 8,
padding: "15px 16px",
flex: 1,
display: "flex",
flexDirection: "column",
color: tokens.ink,
fontFamily: tokens.font.sans,
}}
>
<style>{`
.os-row { transition: background .12s; }
.os-row:hover { background: rgba(46,139,255,.07); }
`}</style>
{/* HUD corner bracket */}
<div
style={{
position: "absolute",
right: -1,
top: -1,
width: 12,
height: 12,
borderRight: `1.5px solid ${tokens.bracket}`,
borderTop: `1.5px solid ${tokens.bracket}`,
}}
/>
{/* Header: 03 СВОДКА ОБЪЕКТА + В РАСЧЁТЕ badge */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 5,
}}
>
<div style={{ display: "flex", alignItems: "baseline", gap: 9 }}>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 14,
color: tokens.accent,
}}
>
03
</span>
<span style={{ fontSize: 12, fontWeight: 600, letterSpacing: 1.5 }}>
СВОДКА ОБЪЕКТА
</span>
</div>
<span
style={{
display: "flex",
alignItems: "center",
gap: 4,
fontSize: 8,
letterSpacing: 0.8,
color: tokens.success,
border: `1px solid ${tokens.successBorder}`,
borderRadius: 20,
padding: "2px 7px",
whiteSpace: "nowrap",
}}
>
<span
style={{
width: 5,
height: 5,
borderRadius: "50%",
background: tokens.success,
}}
/>
В РАСЧЁТЕ
</span>
</div>
<div
style={{
fontSize: 9,
letterSpacing: 1.2,
color: tokens.muted3,
marginBottom: 13,
}}
>
ИТОГИ ПО РАЗДЕЛАМ
</div>
{/* Address */}
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 9,
marginBottom: 14,
}}
>
<svg
width="15"
height="18"
viewBox="0 0 15 18"
fill="none"
style={{ marginTop: 1 }}
>
<path
d="M7.5 17C7.5 17 14 11 14 6.5A6.5 6.5 0 1 0 1 6.5C1 11 7.5 17 7.5 17Z"
stroke={tokens.accent}
strokeWidth="1.2"
/>
<circle cx="7.5" cy="6.5" r="2.3" fill={tokens.accent} />
</svg>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{object.address}</div>
<div style={{ fontSize: 11, color: tokens.muted, marginTop: 2 }}>
{object.city}
</div>
</div>
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
<path
d="M5 1h7v7M12 1L6 7M9 8v4H1V4h4"
stroke={tokens.muted2}
strokeWidth="1"
/>
</svg>
</div>
{/* Per-section totals (clickable) */}
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
{summary.rows.map((row, i) => (
<div
key={row.label}
className="os-row"
onClick={() => onNavigate(row.nav)}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "9px 6px",
borderBottom:
i < summary.rows.length - 1
? `1px dotted ${tokens.lineGradient}`
: undefined,
cursor: "pointer",
borderRadius: 5,
}}
>
<span
style={{
fontSize: 10.5,
color: tokens.body,
display: "flex",
alignItems: "center",
gap: 6,
whiteSpace: "nowrap",
paddingRight: 12,
}}
>
<span
style={{
width: 5,
height: 5,
borderRadius: "50%",
background:
row.dot === "accent" ? tokens.accent : tokens.scatterDeal,
}}
/>
{row.label}
</span>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 11,
color: tokens.ink2,
whiteSpace: "nowrap",
}}
>
{row.value}
</span>
<span style={{ color: tokens.accent, fontSize: 11 }}></span>
</span>
</div>
))}
</div>
{/* КАЧЕСТВО ДАННЫХ */}
<div
style={{
marginTop: 14,
background: tokens.infoSoftBg,
border: `1px solid ${tokens.infoSoftBorder}`,
borderRadius: 6,
padding: 12,
display: "flex",
gap: 12,
alignItems: "center",
}}
>
<svg width="50" height="50" viewBox="0 0 50 50" fill="none">
<circle
cx="25"
cy="25"
r="20"
stroke={tokens.track}
strokeWidth="3"
/>
<circle
cx="25"
cy="25"
r="20"
stroke={tokens.accent}
strokeWidth="3"
strokeLinecap="round"
strokeDasharray="126"
strokeDashoffset="72"
transform="rotate(-90 25 25)"
/>
<circle cx="25" cy="25" r="11" stroke="#cdddec" strokeWidth="1" />
<circle cx="25" cy="25" r="1.6" fill={tokens.accent} />
</svg>
<div style={{ flex: 1 }}>
<div
style={{
fontSize: 9.5,
letterSpacing: 1.5,
color: tokens.muted2,
marginBottom: 7,
}}
>
КАЧЕСТВО ДАННЫХ
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 10,
color: tokens.muted,
lineHeight: 1.7,
}}
>
<span>Источников</span>
<span style={{ fontFamily: tokens.font.mono, color: tokens.ink }}>
{summary.quality.sources}
</span>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 10,
color: tokens.muted,
lineHeight: 1.7,
}}
>
<span>Достоверность</span>
<span style={{ color: tokens.ink }}>
{summary.quality.confidence}
</span>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 10,
color: tokens.muted,
lineHeight: 1.7,
}}
>
<span>CV</span>
<span
style={{ fontFamily: tokens.font.mono, color: tokens.accent }}
>
{summary.quality.cv}
</span>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,958 @@
"use client";
// 01 ПАРАМЕТРЫ КВАРТИРЫ — left card of the /trade-in/v2 "МЕРА Оценка" port.
// Faithful markup port (МЕРА Оценка.dc.html lines 146-240). Markup-first: all
// values come from fixtures (no API). Dropdowns / balcony toggle drive local UI
// state only. Hover/active + @keyframes live in a pp-prefixed local <style>.
import { useState, type CSSProperties } from "react";
import { tokens } from "./tokens";
import { object, dropdownOptions } from "./fixtures";
type DdKey = "rooms" | "houseType" | "repair" | "radius" | "crm" | null;
interface DdProps {
open: boolean;
onToggle: () => void;
value: string;
options: string[];
onSelect: (v: string) => void;
mono: boolean;
triggerFontSize: number;
optionFontSize: number;
variant?: "solid" | "dashed";
openUp?: boolean;
triggerHeight?: number;
triggerBg?: string;
triggerColor?: string;
caretColor?: string;
}
function Dd({
open,
onToggle,
value,
options,
onSelect,
mono,
triggerFontSize,
optionFontSize,
variant = "solid",
openUp = false,
triggerHeight = 33,
triggerBg = tokens.surface.w62,
triggerColor = tokens.ink,
caretColor = tokens.muted2,
}: DdProps) {
const panelStyle: CSSProperties = {
position: "absolute",
left: 0,
right: 0,
background: tokens.surface.w98,
border: `1px solid ${tokens.line}`,
borderRadius: 6,
zIndex: 30,
padding: 3,
...(openUp
? {
bottom: "calc(100% + 4px)",
boxShadow: "0 -10px 26px rgba(40,80,130,.18)",
}
: {
top: "calc(100% + 4px)",
boxShadow: "0 10px 26px rgba(40,80,130,.18)",
}),
};
return (
<div style={{ position: "relative" }}>
<div
className={
variant === "dashed" ? "pp-dd-trigger-dashed" : "pp-dd-trigger"
}
onClick={onToggle}
style={{
height: triggerHeight,
background: triggerBg,
borderRadius: 6,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 13px",
fontFamily: mono ? tokens.font.mono : tokens.font.sans,
fontSize: triggerFontSize,
color: triggerColor,
cursor: "pointer",
}}
>
{value}
<span
style={{
color: caretColor,
fontSize: 9,
fontFamily: tokens.font.sans,
}}
>
</span>
</div>
{open && (
<div style={panelStyle}>
{options.map((o) => (
<div
key={o}
className="pp-dd-opt"
onClick={() => onSelect(o)}
style={{
padding: "7px 10px",
fontSize: optionFontSize,
fontFamily: mono ? tokens.font.mono : tokens.font.sans,
borderRadius: 4,
cursor: "pointer",
color: o === value ? tokens.accent : tokens.ink,
fontWeight: o === value ? 600 : 400,
}}
>
{o}
</div>
))}
</div>
)}
</div>
);
}
const styles = `
@keyframes pp-pulsedot{0%,100%{opacity:.45}50%{opacity:1}}
.pp-dot{animation:pp-pulsedot 2.6s ease-in-out infinite}
.pp-dd-trigger{border:1px solid ${tokens.line};transition:border-color .15s}
.pp-dd-trigger:hover{border-color:${tokens.accent}}
.pp-dd-trigger-dashed{border:1px dashed ${tokens.line};transition:border-color .15s}
.pp-dd-trigger-dashed:hover{border-color:${tokens.accent}}
.pp-dd-opt{transition:background .15s}
.pp-dd-opt:hover{background:rgba(46,139,255,.1)}
.pp-eval-btn{background:linear-gradient(90deg,rgba(255,255,255,.7),rgba(233,242,252,.7));box-shadow:0 0 0 0 rgba(46,139,255,0);transition:all .18s}
.pp-eval-btn:hover{background:linear-gradient(90deg,rgba(46,139,255,.12),rgba(46,139,255,.18));box-shadow:0 6px 22px rgba(46,139,255,.28)}
.pp-eval-btn:active{transform:translateY(1px)}
`;
const hintLabel: CSSProperties = {
fontSize: 9.5,
letterSpacing: 1.5,
color: tokens.muted2,
marginBottom: 3,
};
const optHint: CSSProperties = {
fontSize: 9,
color: tokens.muted4,
};
const fieldRow: CSSProperties = {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 12,
};
const textField: CSSProperties = {
height: 33,
background: tokens.surface.w62,
border: `1px solid ${tokens.line}`,
borderRadius: 6,
display: "flex",
alignItems: "center",
padding: "0 13px",
fontFamily: tokens.font.mono,
fontSize: 14,
};
export default function ParamsPanel() {
const [openDd, setOpenDd] = useState<DdKey>(null);
const [rooms, setRooms] = useState(object.rooms);
const [houseType, setHouseType] = useState(object.houseType);
const [repair, setRepair] = useState(object.repair);
const [radius, setRadius] = useState("500 м");
const [crm, setCrm] = useState(dropdownOptions.crm[0]);
const [balcony, setBalcony] = useState(object.balcony);
const toggle = (k: Exclude<DdKey, null>) =>
setOpenDd((cur) => (cur === k ? null : k));
const pick = (setter: (v: string) => void) => (v: string) => {
setter(v);
setOpenDd(null);
};
const balOn: CSSProperties = {
border: `1px solid ${tokens.accent}`,
color: tokens.accent,
fontWeight: 600,
background: "rgba(46,139,255,.1)",
};
const balOff: CSSProperties = {
border: `1px solid ${tokens.line}`,
color: tokens.muted,
fontWeight: 400,
background: "transparent",
};
const balBase: CSSProperties = {
flex: 1,
borderRadius: 6,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
letterSpacing: 1,
cursor: "pointer",
transition: "all .15s",
};
return (
<div
style={{
position: "relative",
background: tokens.surface.w50,
backdropFilter: "blur(6px)",
border: `1px solid ${tokens.line2}`,
borderRadius: 8,
padding: "16px 18px",
display: "flex",
flexDirection: "column",
}}
>
<style>{styles}</style>
{/* HUD corner brackets */}
<div
style={{
position: "absolute",
left: -1,
top: -1,
width: 13,
height: 13,
borderLeft: `1.5px solid ${tokens.bracket}`,
borderTop: `1.5px solid ${tokens.bracket}`,
}}
/>
<div
style={{
position: "absolute",
right: -1,
bottom: -1,
width: 13,
height: 13,
borderRight: `1.5px solid ${tokens.bracket}`,
borderBottom: `1.5px solid ${tokens.bracket}`,
}}
/>
{/* header */}
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
marginBottom: 13,
}}
>
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 15,
color: tokens.accent,
}}
>
01
</span>
<span style={{ fontSize: 13, fontWeight: 600, letterSpacing: 2 }}>
ПАРАМЕТРЫ КВАРТИРЫ
</span>
</div>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 10,
letterSpacing: 1,
color: tokens.muted2,
}}
>
ШАГ 1/1
</span>
</div>
{/* MAP */}
<div
style={{
position: "relative",
height: 140,
border: `1px solid ${tokens.line}`,
borderRadius: 6,
overflow: "hidden",
background: tokens.mapBg,
flex: "0 0 auto",
}}
>
<svg
viewBox="0 0 440 210"
preserveAspectRatio="xMidYMid slice"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
}}
>
<rect width="440" height="210" fill="#e6edf4" />
<g stroke="#dbe5ef" strokeWidth={1}>
<path d="M0 38H440M0 78H440M0 118H440M0 158H440M0 198H440" />
<path d="M40 0V210M110 0V210M180 0V210M250 0V210M320 0V210M390 0V210" />
</g>
<g stroke="#f5f8fc" strokeWidth={8} strokeLinecap="round">
<path d="M-10 92 H450" />
<path d="M150 -10 V220" />
<path d="M-10 30 L260 0" />
</g>
<g stroke="#eef3f9" strokeWidth={5}>
<path d="M300 -10 L470 150" />
<path d="M-10 170 H450" />
</g>
<path d="M150 92 L470 200" stroke="#cfe0f3" strokeWidth={6} />
<g fill="none" stroke="#2e8bff" strokeDasharray="3 4">
<circle cx="208" cy="100" r="44" opacity={0.55} />
<circle cx="208" cy="100" r="80" opacity={0.4} />
<circle cx="208" cy="100" r="112" opacity={0.28} />
</g>
<circle cx="208" cy="100" r="80" fill="#2e8bff" opacity={0.04} />
</svg>
{/* center pin */}
<div
style={{
position: "absolute",
left: "47%",
top: "48%",
transform: "translate(-50%,-100%)",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
background: tokens.surface.w85,
border: `1px solid ${tokens.accent}`,
borderRadius: 4,
padding: "4px 8px",
whiteSpace: "nowrap",
boxShadow: "0 3px 10px rgba(46,139,255,.25)",
}}
>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: tokens.accent,
}}
/>
<span style={{ fontSize: 11, fontWeight: 600 }}>
{object.address}
</span>
</div>
<div
style={{
width: 0,
height: 0,
borderLeft: "5px solid transparent",
borderRight: "5px solid transparent",
borderTop: `7px solid ${tokens.accent}`,
}}
/>
</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}`,
}}
/>
{/* map controls */}
<div
style={{
position: "absolute",
right: 9,
bottom: 9,
display: "flex",
flexDirection: "column",
gap: 5,
}}
>
<div
style={{
width: 24,
height: 24,
background: tokens.surface.w80,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all .15s",
fontSize: 15,
color: tokens.muted,
}}
>
+
</div>
<div
style={{
width: 24,
height: 24,
background: tokens.surface.w80,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all .15s",
fontSize: 15,
color: tokens.muted,
}}
>
</div>
<div
style={{
width: 24,
height: 24,
background: tokens.surface.w80,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all .15s",
color: tokens.accent,
}}
>
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
<circle cx="6.5" cy="6.5" r="4" stroke="#2e8bff" />
<line x1="6.5" y1="0" x2="6.5" y2="2.5" stroke="#2e8bff" />
<line x1="6.5" y1="10.5" x2="6.5" y2="13" stroke="#2e8bff" />
<line x1="0" y1="6.5" x2="2.5" y2="6.5" stroke="#2e8bff" />
<line x1="10.5" y1="6.5" x2="13" y2="6.5" stroke="#2e8bff" />
</svg>
</div>
</div>
{/* map corner bracket */}
<div
style={{
position: "absolute",
left: 8,
top: 8,
width: 12,
height: 12,
borderLeft: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`,
}}
/>
</div>
{/* radius row */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 14,
marginTop: 13,
flex: "0 0 auto",
}}
>
<span style={{ fontSize: 10, letterSpacing: 1.5, color: tokens.muted }}>
РАДИУС АНАЛИЗА
</span>
<div style={{ position: "relative" }}>
<div
onClick={() => toggle("radius")}
style={{
display: "flex",
alignItems: "center",
gap: 8,
background: tokens.surface.w60,
border: `1px solid ${tokens.line}`,
borderRadius: 5,
padding: "6px 10px",
fontSize: 12,
fontFamily: tokens.font.mono,
cursor: "pointer",
}}
>
<span>{radius}</span>
<span style={{ color: tokens.muted2, fontSize: 9 }}></span>
</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
style={{
display: "flex",
alignItems: "center",
gap: 16,
fontSize: 10.5,
color: tokens.muted,
marginLeft: "auto",
}}
>
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.accent,
}}
/>
Объявления
</span>
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.dealDot,
}}
/>
Сделки
</span>
</div>
</div>
{/* INPUTS */}
<div
style={{
marginTop: 10,
display: "flex",
flexDirection: "column",
gap: 5,
flex: 1,
minHeight: 0,
}}
>
<div>
<div style={hintLabel}>АДРЕС (YANDEX / NOMINATIM)</div>
<div style={{ position: "relative" }}>
<div
style={{
height: 33,
background: tokens.surface.w62,
border: `1px solid ${tokens.line}`,
borderRadius: 6,
display: "flex",
alignItems: "center",
padding: "0 38px 0 13px",
fontSize: 13,
color: tokens.ink,
}}
>
{object.address} · Куйбышева, 48
</div>
<svg
style={{
position: "absolute",
right: 12,
top: "50%",
transform: "translateY(-50%)",
}}
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
>
<circle cx="7.5" cy="7.5" r="4.5" stroke="#2e8bff" />
<line x1="7.5" y1="0" x2="7.5" y2="3" stroke="#2e8bff" />
<line x1="7.5" y1="12" x2="7.5" y2="15" stroke="#2e8bff" />
<line x1="0" y1="7.5" x2="3" y2="7.5" stroke="#2e8bff" />
<line x1="12" y1="7.5" x2="15" y2="7.5" stroke="#2e8bff" />
</svg>
</div>
</div>
<div style={fieldRow}>
<div>
<div style={hintLabel}>ПЛОЩАДЬ, М²</div>
<div style={textField}>{object.area}</div>
</div>
<div>
<div style={hintLabel}>КОМНАТ</div>
<Dd
open={openDd === "rooms"}
onToggle={() => toggle("rooms")}
value={rooms}
options={dropdownOptions.rooms}
onSelect={pick(setRooms)}
mono
triggerFontSize={14}
optionFontSize={13}
/>
</div>
</div>
<div style={fieldRow}>
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>ЭТАЖ</span>
<span style={optHint}>если знаешь</span>
</div>
<div style={textField}>{object.floor}</div>
</div>
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>ВСЕГО ЭТАЖЕЙ</span>
<span style={optHint}>если знаешь</span>
</div>
<div style={textField}>{object.totalFloors}</div>
</div>
</div>
<div
style={{
fontSize: 10,
color: tokens.hint,
lineHeight: 1.4,
marginTop: -3,
}}
>
1-й и последний этаж снижают цену на 510% заполни если знаешь
</div>
<div style={fieldRow}>
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>ГОД ПОСТРОЙКИ</span>
<span style={optHint}>опц.</span>
</div>
<div style={textField}>{object.year}</div>
</div>
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>ТИП ДОМА</span>
<span style={optHint}>опц.</span>
</div>
<Dd
open={openDd === "houseType"}
onToggle={() => toggle("houseType")}
value={houseType}
options={dropdownOptions.houseType}
onSelect={pick(setHouseType)}
mono={false}
triggerFontSize={13}
optionFontSize={12}
/>
</div>
</div>
<div style={fieldRow}>
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>СОСТОЯНИЕ РЕМОНТА</span>
<span style={optHint}>опц.</span>
</div>
<Dd
open={openDd === "repair"}
onToggle={() => toggle("repair")}
value={repair}
options={dropdownOptions.repair}
onSelect={pick(setRepair)}
mono={false}
triggerFontSize={13}
optionFontSize={12}
/>
</div>
<div>
<div style={hintLabel}>БАЛКОН / ЛОДЖИЯ</div>
<div style={{ height: 40, display: "flex", gap: 6 }}>
<div
onClick={() => setBalcony(false)}
style={{ ...balBase, ...(balcony ? balOff : balOn) }}
>
НЕТ
</div>
<div
onClick={() => setBalcony(true)}
style={{ ...balBase, ...(balcony ? balOn : balOff) }}
>
ДА
</div>
</div>
</div>
</div>
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>CRM ДЛЯ МЕНЕДЖЕРА</span>
<span style={optHint}>опц.</span>
</div>
<Dd
open={openDd === "crm"}
onToggle={() => toggle("crm")}
value={crm}
options={dropdownOptions.crm}
onSelect={pick(setCrm)}
mono={false}
triggerFontSize={12}
optionFontSize={12}
variant="dashed"
openUp
triggerHeight={34}
triggerBg={tokens.surface.w50}
triggerColor={tokens.hint}
caretColor={tokens.muted4}
/>
</div>
</div>
{/* button */}
<button
type="button"
className="pp-eval-btn"
style={{
marginTop: 9,
height: 46,
width: "100%",
border: `1px solid ${tokens.accent}`,
borderRadius: 7,
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
cursor: "pointer",
fontFamily: tokens.font.sans,
color: tokens.ink,
flex: "0 0 auto",
}}
>
<span style={{ fontSize: 14, fontWeight: 600, letterSpacing: 3 }}>
ОЦЕНИТЬ КВАРТИРУ
</span>
<span
style={{
position: "absolute",
right: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 26,
height: 26,
border: `1px solid ${tokens.accent}`,
borderRadius: "50%",
color: tokens.accent,
}}
>
</span>
</button>
{/* cache status */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: 11,
flex: "0 0 auto",
fontSize: 9.5,
letterSpacing: 1.5,
color: tokens.muted2,
}}
>
<span>КЭШ ПО АДРЕСУ 24 Ч</span>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.success,
}}
>
<span
className="pp-dot"
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: tokens.success,
boxShadow: `0 0 6px ${tokens.success}`,
}}
/>
ГОТОВ
</span>
</div>
</div>
);
}

View file

@ -0,0 +1,880 @@
"use client";
import { Fragment } from "react";
import { tokens } from "./tokens";
import {
ranges,
resultCards,
resultMeta,
scatterMini,
sources,
} from "./fixtures";
const {
accent,
onAccent,
accentGlow,
ink,
muted,
muted2,
muted3,
line,
line2,
line3,
bracket,
barLo,
barMid,
track,
success,
hint,
scatterDeal,
disabledBorder,
disabledValue,
disabledLabel,
surface,
font,
} = tokens;
interface ResultPanelProps {
onNavigate: (i: number) => void;
}
// Mini-histogram column tint: peak column = accent, its neighbours = mid, rest = lo.
function barColor(i: number, bars: number[]): string {
const peak = bars.indexOf(Math.max(...bars));
if (i === peak) return accent;
if (i === peak - 1 || i === peak + 1) return barMid;
return barLo;
}
// Render a string[] with <br/> between lines (faithful to the design line breaks).
function lines(parts: string[]) {
return parts.map((part, i) => (
<Fragment key={i}>
{i > 0 && <br />}
{part}
</Fragment>
));
}
// Split a source name on its parenthetical so the design's forced
// "РОСРЕЕСТР / (ВНУТР.)" two-line break (line-height 1.3) is reproduced; plain
// names without " (" stay on a single line.
function sourceName(name: string) {
const idx = name.indexOf(" (");
if (idx === -1) return name;
return (
<>
{name.slice(0, idx)}
<br />
{name.slice(idx + 1)}
</>
);
}
export default function ResultPanel({ onNavigate }: ResultPanelProps) {
return (
<div
style={{ display: "flex", flexDirection: "column", gap: 14, minWidth: 0 }}
>
<style>{`
.rp-more { transition: opacity 120ms ease; }
.rp-more:hover { opacity: .6; }
`}</style>
{/* header */}
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
}}
>
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
<span style={{ fontFamily: font.mono, fontSize: 15, color: accent }}>
02
</span>
<span style={{ fontSize: 13, fontWeight: 600, letterSpacing: 2 }}>
РЕЗУЛЬТАТ ОЦЕНКИ
</span>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 22,
fontSize: 10,
letterSpacing: 1,
color: muted,
}}
>
<span>
ИСТОЧНИКОВ:{" "}
<b style={{ color: ink, fontFamily: font.mono }}>
{resultMeta.sources}
</b>
</span>
<span>
ДОСТОВЕРНОСТЬ: <b style={{ color: ink }}>{resultMeta.confidence}</b>
</span>
<span>
CV{" "}
<b style={{ color: accent, fontFamily: font.mono }}>
{resultMeta.cv}
</b>
</span>
</div>
</div>
{/* result cards */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr",
gap: 14,
flex: "0 0 auto",
}}
>
{resultCards.map((card, ci) => (
<div
key={ci}
style={{
position: "relative",
background: surface.w55,
backdropFilter: "blur(6px)",
border: `1px solid ${line2}`,
borderRadius: 8,
padding: "15px 16px 13px",
}}
>
<div
style={{
position: "absolute",
left: -1,
top: -1,
width: 12,
height: 12,
borderLeft: `1.5px solid ${bracket}`,
borderTop: `1.5px solid ${bracket}`,
}}
/>
<div
style={{
fontSize: 9.5,
letterSpacing: 1.5,
color: muted2,
lineHeight: 1.6,
}}
>
{lines(card.title)}
</div>
<div
style={{
marginTop: 14,
display: "flex",
alignItems: "baseline",
gap: 6,
}}
>
<span
style={{
fontFamily: font.mono,
fontSize: 38,
fontWeight: 300,
letterSpacing: -1,
}}
>
{card.value}
</span>
<span
style={{
fontSize: 13,
color: muted,
letterSpacing: 1,
whiteSpace: "nowrap",
}}
>
{card.unit}
</span>
</div>
<div
style={{
fontFamily: font.mono,
fontSize: 11,
color: muted,
marginTop: 10,
}}
>
{card.range}
</div>
<div
style={{
fontFamily: font.mono,
fontSize: 10,
color: muted2,
marginTop: 4,
}}
>
{card.ppm}
</div>
{card.bars ? (
<div
style={{
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
gap: 12,
marginTop: 12,
}}
>
<div
style={{
display: "flex",
alignItems: "flex-end",
gap: 2,
height: 20,
flex: 1,
}}
>
{card.bars.map((h, bi) => (
<span
key={bi}
style={{
flex: 1,
height: `${h}%`,
background: barColor(bi, card.bars as number[]),
}}
/>
))}
</div>
<div
className="rp-more"
onClick={() => onNavigate(card.nav)}
style={{
display: "flex",
alignItems: "center",
gap: 4,
cursor: "pointer",
fontSize: 9,
letterSpacing: 0.3,
color: accent,
whiteSpace: "nowrap",
}}
>
Подробнее <span></span>
</div>
</div>
) : (
<>
<div
className="rp-more"
onClick={() => onNavigate(card.nav)}
style={{
marginTop: 16,
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 4,
cursor: "pointer",
fontSize: 9,
letterSpacing: 0.3,
color: accent,
}}
>
Подробнее <span></span>
</div>
<div
style={{
position: "absolute",
right: 14,
top: 13,
width: 58,
height: 58,
}}
>
<svg width="58" height="58" viewBox="0 0 58 58">
<circle
cx="29"
cy="29"
r="25"
fill="none"
stroke={track}
strokeWidth="3"
/>
<circle
cx="29"
cy="29"
r="25"
fill="none"
stroke={accent}
strokeWidth="3"
strokeLinecap="round"
strokeDasharray="157"
strokeDashoffset="140"
transform="rotate(-90 29 29)"
/>
</svg>
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<span
style={{
fontFamily: font.mono,
fontSize: 13,
fontWeight: 500,
color: accent,
}}
>
{card.delta}
</span>
<span
style={{
fontSize: 6.5,
letterSpacing: 1,
color: muted2,
marginTop: 1,
}}
>
{card.deltaLabel}
</span>
</div>
</div>
</>
)}
</div>
))}
</div>
{/* ranges + radar */}
<div
style={{
position: "relative",
background: surface.w50,
backdropFilter: "blur(6px)",
border: `1px solid ${line2}`,
borderRadius: 8,
padding: "18px 20px",
display: "grid",
gridTemplateColumns: "1fr 168px 1fr",
gap: 24,
alignItems: "center",
flex: "0 0 auto",
}}
>
<div
style={{
position: "absolute",
right: -1,
bottom: -1,
width: 13,
height: 13,
borderRight: `1.5px solid ${bracket}`,
borderBottom: `1.5px solid ${bracket}`,
}}
/>
{/* left range — ads */}
<div>
<div
style={{
fontSize: 9,
letterSpacing: 1.3,
color: muted2,
lineHeight: 1.5,
}}
>
{lines(ranges.ads.label)}
</div>
<div
style={{
fontFamily: font.mono,
fontSize: 11,
color: muted,
margin: "12px 0 16px",
}}
>
медиана · <b style={{ color: ink }}>{ranges.ads.median}</b>
</div>
<div
style={{
position: "relative",
height: 3,
background: track,
borderRadius: 2,
}}
>
<div
style={{
position: "absolute",
left: ranges.ads.marker.fillLeft,
right: ranges.ads.marker.fillRight,
top: 0,
bottom: 0,
background: `linear-gradient(90deg,${barMid},${accent})`,
borderRadius: 2,
}}
/>
<div
style={{
position: "absolute",
left: ranges.ads.marker.dotLeft,
top: "50%",
transform: "translate(-50%,-50%)",
width: 13,
height: 13,
borderRadius: "50%",
background: onAccent,
border: `3px solid ${accent}`,
boxShadow: `0 0 8px ${accentGlow}`,
}}
/>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontFamily: font.mono,
fontSize: 10,
color: muted2,
marginTop: 10,
}}
>
<span>{ranges.ads.lo}</span>
<span style={{ color: accent }}>{ranges.ads.median}</span>
<span>{ranges.ads.hi}</span>
</div>
</div>
{/* radar — price × sell-time scatter */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 6,
}}
>
<div style={{ fontSize: 8, letterSpacing: 1.5, color: muted2 }}>
ЦЕНА × СРОК ПРОДАЖИ
</div>
<svg width="168" height="140" viewBox="0 0 168 140" fill="none">
<g stroke="#e3ecf4" strokeWidth="1">
<line x1="34" y1="38" x2="158" y2="38" />
<line x1="34" y1="74" x2="158" y2="74" />
<line x1="96" y1="10" x2="96" y2="110" />
</g>
<line
x1="34"
y1="10"
x2="34"
y2="110"
stroke={line3}
strokeWidth="1"
/>
<line
x1="34"
y1="110"
x2="158"
y2="110"
stroke={line3}
strokeWidth="1"
/>
<line
x1="40"
y1="100"
x2="150"
y2="30"
stroke={accent}
strokeWidth="1"
strokeDasharray="3 4"
opacity="0.45"
/>
<g fill={scatterDeal}>
{scatterMini.deals.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
))}
</g>
<g fill={accent}>
{scatterMini.analogs.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
))}
</g>
<circle
cx={scatterMini.subject.x}
cy={scatterMini.subject.y}
r={scatterMini.subject.r}
fill={ink}
/>
<circle
cx={scatterMini.subject.x}
cy={scatterMini.subject.y}
r="6.5"
fill="none"
stroke={ink}
strokeWidth="1"
opacity="0.4"
/>
<g
fontFamily={font.mono}
fontSize="6.5"
fill={muted2}
textAnchor="end"
>
{scatterMini.yTicks.map((t, i) => (
<text key={i} x={30} y={t.y}>
{t.label}
</text>
))}
</g>
<g
fontFamily={font.mono}
fontSize="6.5"
fill={muted2}
textAnchor="middle"
>
{scatterMini.xTicks.map((t, i) => (
<text key={i} x={t.x} y={120}>
{t.label}
</text>
))}
</g>
<text
x="96"
y="134"
fontSize="6.5"
letterSpacing="1"
fill={muted2}
textAnchor="middle"
>
СРОК ПРОДАЖИ, ДН
</text>
<text
x="11"
y="60"
fontSize="6.5"
letterSpacing="1"
fill={muted2}
textAnchor="middle"
transform="rotate(-90 11 60)"
>
ЦЕНА ПРОДАЖИ
</text>
</svg>
<div
style={{
display: "flex",
gap: 12,
fontSize: 7.5,
letterSpacing: 0.5,
color: muted2,
}}
>
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
<span
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: accent,
}}
/>
аналоги
</span>
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
<span
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: scatterDeal,
}}
/>
сделки
</span>
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
<span
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: ink,
}}
/>
объект
</span>
</div>
<div
className="rp-more"
onClick={() => onNavigate(3)}
style={{
display: "flex",
alignItems: "center",
gap: 4,
cursor: "pointer",
fontSize: 9,
letterSpacing: 0.3,
color: accent,
marginTop: 2,
}}
>
Подробнее <span></span>
</div>
</div>
{/* right range — deals */}
<div>
<div
style={{
fontSize: 9,
letterSpacing: 1.3,
color: muted2,
lineHeight: 1.5,
}}
>
{lines(ranges.deals.label)}
</div>
<div
style={{
fontFamily: font.mono,
fontSize: 11,
color: muted,
margin: "12px 0 16px",
}}
>
медиана · <b style={{ color: ink }}>{ranges.deals.median}</b>
</div>
<div
style={{
position: "relative",
height: 3,
background: track,
borderRadius: 2,
}}
>
<div
style={{
position: "absolute",
left: ranges.deals.marker.fillLeft,
right: ranges.deals.marker.fillRight,
top: 0,
bottom: 0,
background: `linear-gradient(90deg,${barMid},${accent})`,
borderRadius: 2,
}}
/>
<div
style={{
position: "absolute",
left: ranges.deals.marker.dotLeft,
top: "50%",
transform: "translate(-50%,-50%)",
width: 13,
height: 13,
borderRadius: "50%",
background: onAccent,
border: `3px solid ${accent}`,
boxShadow: `0 0 8px ${accentGlow}`,
}}
/>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontFamily: font.mono,
fontSize: 10,
color: muted2,
marginTop: 10,
}}
>
<span>{ranges.deals.lo}</span>
<span style={{ color: accent }}>{ranges.deals.median}</span>
<span>{ranges.deals.hi}</span>
</div>
</div>
</div>
{/* sources */}
<div
style={{
background: surface.w50,
backdropFilter: "blur(6px)",
border: `1px solid ${line2}`,
borderRadius: 8,
padding: "14px 18px 13px",
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 11,
}}
>
<span
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: 2,
color: ink,
}}
>
ИСТОЧНИКИ ДАННЫХ
</span>
<span
className="rp-more"
onClick={() => onNavigate(2)}
style={{
display: "flex",
alignItems: "center",
gap: 4,
cursor: "pointer",
fontSize: 9.5,
letterSpacing: 0.3,
color: accent,
}}
>
Подробнее <span></span>
</span>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(7,1fr)",
gap: 9,
flex: 1,
minHeight: 0,
alignItems: "stretch",
alignContent: "stretch",
}}
>
{sources.map((s, si) =>
s.active ? (
<div
key={si}
style={{
border: `1px solid ${line}`,
borderRadius: 6,
padding: "10px 9px",
display: "flex",
flexDirection: "column",
background: surface.w50,
}}
>
<div
style={{
fontSize: 9,
letterSpacing: 0.5,
color: ink,
fontWeight: 600,
...(s.name.includes(" (") ? { lineHeight: 1.3 } : {}),
}}
>
{sourceName(s.name)}
</div>
<div
style={{
fontFamily: font.mono,
fontSize: 26,
fontWeight: 300,
marginTop: "auto",
}}
>
{s.count}
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 5,
fontSize: 9,
color: muted,
marginTop: 3,
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: success,
}}
/>
{s.label}
</div>
</div>
) : (
<div
key={si}
style={{
border: `1px solid ${disabledBorder}`,
borderRadius: 6,
padding: "10px 9px",
display: "flex",
flexDirection: "column",
background: surface.w25,
}}
>
<div
style={{
fontSize: 9,
letterSpacing: 0.5,
color: muted3,
fontWeight: 600,
}}
>
{s.name}
</div>
<div
style={{
fontFamily: font.mono,
fontSize: 22,
fontWeight: 300,
marginTop: "auto",
color: disabledValue,
}}
>
{s.count}
</div>
<div
style={{ fontSize: 9, color: disabledLabel, marginTop: 3 }}
>
{s.label}
</div>
</div>
),
)}
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: 11,
}}
>
<span style={{ fontSize: 10, color: hint }}>
При недоступности источника частичный результат не блокируется
</span>
<span style={{ fontSize: 9.5, letterSpacing: 1.5, color: muted2 }}>
ПОСТРОЕНО ПО 6 АНАЛОГАМ И 10 СДЕЛКАМ ЗА 6 МЕСЯЦЕВ
</span>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,150 @@
"use client";
// Overlay wrapper for the nav-driven section panels of the /trade-in/v2
// "МЕРА Оценка" design. Faithful port of the design overlay container
// (МЕРА Оценка.dc.html lines 443-453 + 615-616): a glass/blur absolute layer
// with HUD corner brackets, a header (section number + title + "← К ОЦЕНКЕ"
// button) and a scrollable body that swaps the active view.
import { tokens } from "./tokens";
import { overlayNums, overlayTitles } from "./fixtures";
import HistoryView from "./HistoryView";
import SourcesView from "./SourcesView";
import AnalyticsView from "./AnalyticsView";
import { CacheView } from "./CacheView";
interface SectionOverlayProps {
active: number;
onClose: () => void;
onNavigate: (i: number) => void;
}
export default function SectionOverlay({
active,
onClose,
onNavigate,
}: SectionOverlayProps) {
return (
<div
style={{
position: "absolute",
left: 28,
right: 28,
top: 80,
bottom: 30,
zIndex: 30,
display: "flex",
flexDirection: "column",
background:
"linear-gradient(180deg,rgba(244,248,252,.985),rgba(235,242,250,.985))",
backdropFilter: "blur(12px)",
border: `1px solid ${tokens.line3}`,
borderRadius: 8,
boxShadow: "0 22px 60px rgba(40,80,130,.18)",
overflow: "hidden",
fontFamily: tokens.font.sans,
}}
>
<style>{`
.tiv2-so-back { transition: all .15s; }
.tiv2-so-back:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
`}</style>
{/* HUD corner brackets */}
<div
style={{
position: "absolute",
left: 12,
top: 12,
width: 15,
height: 15,
borderLeft: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`,
pointerEvents: "none",
}}
/>
<div
style={{
position: "absolute",
right: 12,
bottom: 12,
width: 15,
height: 15,
borderRight: `1.5px solid ${tokens.accent}`,
borderBottom: `1.5px solid ${tokens.accent}`,
pointerEvents: "none",
}}
/>
{/* overlay header */}
<div
style={{
flex: "0 0 auto",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "16px 24px",
borderBottom: "1px solid #cdd9e6",
}}
>
<div style={{ display: "flex", alignItems: "baseline", gap: 11 }}>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 15,
color: tokens.accent,
}}
>
{overlayNums[active]}
</span>
<span
style={{
fontSize: 14,
fontWeight: 600,
letterSpacing: 2.5,
color: tokens.ink2,
}}
>
{overlayTitles[active]}
</span>
</div>
<div
onClick={onClose}
className="tiv2-so-back"
style={{
display: "flex",
alignItems: "center",
gap: 8,
cursor: "pointer",
border: `1px solid ${tokens.line}`,
borderRadius: 6,
padding: "6px 12px",
background: tokens.surface.w60,
}}
>
<span style={{ fontSize: 13 }}></span>
<span
style={{ fontSize: 10, letterSpacing: 1.5, color: tokens.muted }}
>
К ОЦЕНКЕ
</span>
</div>
</div>
{/* overlay body */}
<div
style={{
flex: 1,
minHeight: 0,
overflowY: "auto",
padding: "22px 24px 26px",
}}
>
{active === 1 && <HistoryView />}
{active === 2 && <SourcesView />}
{active === 3 && <AnalyticsView onNavigate={onNavigate} />}
{active === 4 && <CacheView />}
</div>
</div>
);
}

View file

@ -0,0 +1,401 @@
import type { CSSProperties } from "react";
import { tokens } from "./tokens";
import { adRows, dealRows, marketAds, marketDeals } from "./fixtures";
import type { AdRow, DealRow } from "./types";
// Overlay 05 — РЫНОК · АНАЛОГИ И СДЕЛКИ.
// Faithful markup port from "МЕРА Оценка.dc.html" (lines 492-537).
// Card A: объявления в продаже (3 KPI + filter chips + adRows table).
// Card B: фактические сделки (3 KPI + dealRows table).
// Static markup: data from fixtures, no fetching, no interactive state.
// One-off tints not present in the v2 palette (kept literal, faithful to design).
const KPI_BG = "rgba(238,244,250,.5)";
const BADGE_BG = "#eaf1f8";
const AVITO_DOT = "#e8743b";
const AD_GRID = "2.4fr 1fr 1fr 1.2fr .7fr 1fr";
const DEAL_GRID = "2.6fr 1.2fr 1fr 1.2fr 1fr";
const card: CSSProperties = {
background: tokens.surface.w55,
border: `1px solid ${tokens.line2}`,
borderRadius: 8,
overflow: "hidden",
};
const cardHeader: CSSProperties = {
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
padding: "14px 18px",
borderBottom: `1px solid ${tokens.lineSoft}`,
};
const eyebrow: CSSProperties = {
fontSize: "8.5px",
letterSpacing: "2px",
color: tokens.muted2,
marginBottom: 5,
};
const cardTitle: CSSProperties = {
fontSize: "12px",
fontWeight: 600,
color: tokens.ink2,
};
const headerRight: CSSProperties = {
textAlign: "right",
fontSize: "9.5px",
color: tokens.muted2,
lineHeight: 1.6,
};
const kpiRow: CSSProperties = {
display: "grid",
gridTemplateColumns: "repeat(3,1fr)",
borderBottom: `1px solid ${tokens.lineSoft}`,
background: KPI_BG,
};
const kpiLabel: CSSProperties = {
fontSize: "9px",
letterSpacing: "1.5px",
color: tokens.muted2,
};
const tableHead: CSSProperties = {
gap: "10px",
padding: "9px 18px",
fontSize: "9px",
letterSpacing: "1px",
color: tokens.muted2,
borderBottom: `1px solid ${tokens.lineSoft2}`,
};
const tableRow: CSSProperties = {
gap: "10px",
padding: "11px 18px",
fontSize: "11px",
borderBottom: `1px solid ${tokens.lineSoft3}`,
alignItems: "center",
};
const metaSpan: CSSProperties = {
color: tokens.muted2,
fontFamily: tokens.font.mono,
fontSize: "9.5px",
};
const badge: CSSProperties = {
fontSize: "9px",
background: BADGE_BG,
border: `1px solid ${tokens.line2}`,
borderRadius: 3,
padding: "2px 8px",
color: tokens.muted,
};
const chip: CSSProperties = {
fontSize: "10px",
color: tokens.muted,
border: `1px solid ${tokens.line2}`,
borderRadius: 20,
padding: "3px 10px",
};
const monoMuted: CSSProperties = {
fontFamily: tokens.font.mono,
color: tokens.muted,
};
const monoInk: CSSProperties = {
fontFamily: tokens.font.mono,
color: tokens.ink2,
};
const monoMuted2: CSSProperties = {
fontFamily: tokens.font.mono,
color: tokens.muted2,
};
function KpiCell({
label,
value,
unit,
valueColor = tokens.ink2,
unitColor = tokens.muted,
last = false,
}: {
label: string;
value: string;
unit: string;
valueColor?: string;
unitColor?: string;
last?: boolean;
}) {
return (
<div
style={{
padding: "12px 18px",
borderRight: last ? undefined : `1px solid ${tokens.lineSoft2}`,
}}
>
<div style={kpiLabel}>{label}</div>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: "22px",
fontWeight: 300,
color: valueColor,
marginTop: 4,
}}
>
{value}{" "}
<span style={{ fontSize: "11px", color: unitColor }}>{unit}</span>
</div>
</div>
);
}
export default function SourcesView() {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
<style>{`
.sv-row { transition: background .12s ease; }
.sv-row:hover { background: ${tokens.surface.w50}; }
.sv-rr { transition: background .12s ease, border-color .12s ease; }
.sv-rr:hover { background: ${BADGE_BG}; border-color: ${tokens.accent}; }
`}</style>
{/* объявления */}
<div style={card}>
<div style={cardHeader}>
<div>
<div style={eyebrow}>ПОХОЖИЕ КВАРТИРЫ · В ПРОДАЖЕ</div>
<div style={cardTitle}>
Объявления аналогичные квартиры в продаже
</div>
</div>
<div style={headerRight}>
Обновлено: <br />
Источников: <b style={{ color: tokens.muted }}>2 / 7</b>
</div>
</div>
<div style={kpiRow}>
<KpiCell
label="ОБЪЯВЛЕНИЙ ПО АНАЛОГАМ"
value={marketAds.kpi.count}
unit="шт · из 2 источников"
/>
<KpiCell
label="МЕДИАНА"
value={marketAds.kpi.median}
unit={`млн ₽ · ${marketAds.kpi.ppm} ₽/м²`}
/>
<KpiCell
label="ДИАПАЗОН P25P75"
value={marketAds.kpi.range}
unit={`млн · CV ${marketAds.kpi.cv}`}
valueColor={tokens.accent}
last
/>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "11px 18px",
borderBottom: `1px solid ${tokens.lineSoft3}`,
}}
>
<span
style={{
fontSize: "9px",
letterSpacing: "1.5px",
color: tokens.muted2,
}}
>
ФИЛЬТР
</span>
{marketAds.filters.map((f, i) => (
<span key={i} style={chip}>
{f}
</span>
))}
<span
style={{
marginLeft: "auto",
fontSize: "10px",
color: tokens.muted,
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: 2,
background: AVITO_DOT,
}}
/>
Avito · 10
</span>
</div>
<div
style={{
...tableHead,
display: "grid",
gridTemplateColumns: AD_GRID,
}}
>
<span>АДРЕС</span>
<span>ИСТОЧНИК</span>
<span>/М²</span>
<span>СТОИМОСТЬ</span>
<span>ДИСТ.</span>
<span />
</div>
{adRows.map((r: AdRow, i) => (
<div
key={i}
className="sv-row"
style={{
...tableRow,
display: "grid",
gridTemplateColumns: AD_GRID,
}}
>
<span style={{ color: tokens.body }}>
{r.addr}
<span style={metaSpan}>
{" · "}
{r.meta}
</span>
</span>
<span>
<span style={badge}>Avito</span>
</span>
<span style={monoMuted}>{r.ppm}</span>
<span style={monoInk}>{r.price}</span>
<span style={monoMuted2}></span>
<span
className="sv-rr"
style={{
fontSize: "9.5px",
color: tokens.accent,
border: `1px solid ${tokens.line2}`,
borderRadius: 4,
padding: "3px 8px",
textAlign: "center",
}}
>
Росреестр
</span>
</div>
))}
</div>
{/* фактические сделки */}
<div style={card}>
<div style={cardHeader}>
<div>
<div style={eyebrow}>ПОХОЖИЕ КВАРТИРЫ · СДЕЛКИ</div>
<div style={cardTitle}>
Фактические сделки по аналогичным квартирам
</div>
</div>
<div style={headerRight}>
Период: <b style={{ color: tokens.muted }}>за 12 мес.</b>
<br />
проверенные данные · подтверждено Росреестром
</div>
</div>
<div style={kpiRow}>
<KpiCell
label="СДЕЛОК ПО АНАЛОГАМ"
value={marketDeals.kpi.count}
unit="шт · Росреестр"
/>
<KpiCell
label="МЕДИАНА СДЕЛОК"
value={marketDeals.kpi.median}
unit={`млн ₽ · ${marketDeals.kpi.delta}`}
unitColor={tokens.success}
/>
<KpiCell
label="ДИАПАЗОН"
value={marketDeals.kpi.range}
unit="млн"
valueColor={tokens.accent}
last
/>
</div>
<div
style={{
...tableHead,
display: "grid",
gridTemplateColumns: DEAL_GRID,
}}
>
<span>АДРЕС</span>
<span>ИСТОЧНИК</span>
<span>/М²</span>
<span>ЦЕНА СДЕЛКИ</span>
<span>ДАТА</span>
</div>
{dealRows.map((r: DealRow, i) => (
<div
key={i}
className="sv-row"
style={{
...tableRow,
display: "grid",
gridTemplateColumns: DEAL_GRID,
}}
>
<span style={{ color: tokens.body }}>
{r.addr}
<span style={metaSpan}>
{" · "}
{r.meta}
</span>
</span>
<span>
<span style={badge}>Росреестр</span>{" "}
<span style={{ fontSize: "9px", color: tokens.muted2 }}>
по улице
</span>
</span>
<span style={monoMuted}>{r.ppm}</span>
<span style={monoInk}>{r.price}</span>
<span style={monoMuted2}>01.01.26</span>
</div>
))}
<div
style={{
padding: "11px 18px",
fontSize: "10px",
color: tokens.hint,
background: KPI_BG,
}}
>
Показано 10 фактических сделок
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,407 @@
"use client";
// Top navigation bar for the /trade-in/v2 "МЕРА Оценка" design port.
// 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.
import { useState } from "react";
import type { CSSProperties } from "react";
import { tokens } from "./tokens";
import { navLabels, user, version } from "./fixtures";
interface TopNavProps {
active: number;
onNavigate: (i: number) => void;
}
const menuItemStyle: CSSProperties = {
display: "flex",
alignItems: "center",
gap: "10px",
padding: "9px 10px",
borderRadius: "6px",
cursor: "pointer",
fontSize: "12.5px",
color: tokens.body,
transition: "background .12s",
};
export default function TopNav({ active, onNavigate }: TopNavProps) {
const [userOpen, setUserOpen] = useState(false);
return (
<>
<style>{`
.tnav-tab { color: ${tokens.muted}; font-weight: 400; cursor: pointer; transition: color .2s; }
.tnav-tab:hover { color: ${tokens.ink}; }
.tnav-tab--active { color: ${tokens.ink}; font-weight: 600; }
.tnav-userbtn:hover { background: rgba(46,139,255,.07); }
.tnav-menuitem:hover { background: rgba(46,139,255,.09); }
.tnav-logout:hover { background: rgba(205,104,104,.1); }
`}</style>
<div
style={{
height: "54px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderBottom: "1px solid rgba(150,175,200,.4)",
flex: "0 0 auto",
}}
>
{/* Logo + version */}
<div style={{ display: "flex", alignItems: "center", gap: "13px" }}>
<svg
width="184"
height="53"
viewBox="100 440 812 238"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g transform="translate(0,-20)">
<path
d="M164 520h250M610 520h250"
stroke="#c2d3e3"
strokeWidth="2.5"
fill="none"
/>
<path
d="M436 520h78"
stroke="#2e8bff"
strokeWidth="4"
fill="none"
/>
<circle cx="548" cy="520" r="5" fill="#2e8bff" />
<circle cx="790" cy="520" r="5" fill="#2e8bff" opacity=".75" />
<path
d="M118 502v36M906 502v36"
stroke="#c2d3e3"
strokeWidth="2"
fill="none"
opacity=".7"
/>
<path
d="M112 520h22M890 520h22"
stroke="#2e8bff"
strokeWidth="3"
fill="none"
opacity=".75"
/>
<text
x="512"
y="572"
textAnchor="middle"
style={{ fontFamily: tokens.font.sans }}
fontSize="150"
fontWeight="300"
letterSpacing="16"
fill="#17263a"
>
МЕРА
</text>
<text
x="512"
y="672"
textAnchor="middle"
style={{ fontFamily: tokens.font.sans }}
fontSize="27"
fontWeight="400"
letterSpacing="11"
fill="#6f8195"
>
ОЦЕНКА НЕДВИЖИМОСТИ
</text>
</g>
</svg>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: "9.5px",
letterSpacing: "1px",
color: tokens.muted2,
borderLeft: `1px solid ${tokens.line}`,
paddingLeft: "12px",
}}
>
{version}
</div>
</div>
{/* Nav tabs */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "24px",
fontSize: "11px",
letterSpacing: "1px",
}}
>
{navLabels.map((label, i) => (
<div
key={label}
onClick={() => onNavigate(i)}
className={`tnav-tab${active === i ? " tnav-tab--active" : ""}`}
style={{ position: "relative", whiteSpace: "nowrap" }}
>
{label}
<div
style={{
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
bottom: "-16px",
width: "30px",
height: "2px",
background: tokens.accent,
opacity: active === i ? 1 : 0,
transition: "opacity .2s",
}}
/>
{i === 0 && (
<div
style={{
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
bottom: "-22px",
width: 0,
height: 0,
borderLeft: "3px solid transparent",
borderRight: "3px solid transparent",
borderTop: `4px solid ${tokens.accent}`,
opacity: active === 0 ? 1 : 0,
}}
/>
)}
</div>
))}
</div>
{/* User menu */}
<div style={{ position: "relative" }}>
<div
onClick={() => setUserOpen((o) => !o)}
className="tnav-userbtn"
style={{
display: "flex",
alignItems: "center",
gap: "10px",
cursor: "pointer",
padding: "5px 8px 5px 6px",
borderRadius: "9px",
transition: "background .15s",
}}
>
<div
style={{
width: "34px",
height: "34px",
borderRadius: "50%",
background: `linear-gradient(135deg, ${tokens.accentLite}, ${tokens.accentDeep})`,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: tokens.onAccent,
fontSize: "12px",
fontWeight: 600,
fontFamily: tokens.font.sans,
letterSpacing: ".5px",
boxShadow: "0 3px 10px rgba(46,139,255,.3)",
}}
>
{user.initials}
</div>
<div style={{ lineHeight: 1.25 }}>
<div
style={{
fontSize: "12.5px",
fontWeight: 600,
color: tokens.ink,
fontFamily: tokens.font.sans,
}}
>
{user.name}
</div>
<div
style={{
fontSize: "9px",
color: tokens.muted2,
fontFamily: tokens.font.sans,
letterSpacing: ".5px",
}}
>
{user.org}
</div>
</div>
<span style={{ color: tokens.muted2, fontSize: "9px" }}></span>
</div>
{userOpen && (
<div
style={{
position: "absolute",
right: 0,
top: "calc(100% + 9px)",
width: "236px",
background: tokens.surface.w99,
border: `1px solid ${tokens.line}`,
borderRadius: "9px",
boxShadow: "0 16px 40px rgba(40,80,130,.2)",
zIndex: 50,
padding: "7px",
fontFamily: tokens.font.sans,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "11px",
padding: "10px 10px 12px",
borderBottom: `1px solid ${tokens.lineSoft2}`,
marginBottom: "6px",
}}
>
<div
style={{
width: "38px",
height: "38px",
borderRadius: "50%",
background: `linear-gradient(135deg, ${tokens.accentLite}, ${tokens.accentDeep})`,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: tokens.onAccent,
fontSize: "13px",
fontWeight: 600,
}}
>
{user.initials}
</div>
<div>
<div
style={{
fontSize: "13px",
fontWeight: 600,
color: tokens.ink2,
}}
>
{user.name}
</div>
<div style={{ fontSize: "10px", color: tokens.muted2 }}>
{user.email}
</div>
</div>
</div>
<div className="tnav-menuitem" style={menuItemStyle}>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<circle
cx="7.5"
cy="5"
r="3"
stroke="#6f8195"
strokeWidth="1.2"
/>
<path
d="M2 13c0-3 2.5-4.5 5.5-4.5S13 10 13 13"
stroke="#6f8195"
strokeWidth="1.2"
/>
</svg>
Профиль
</div>
<div className="tnav-menuitem" style={menuItemStyle}>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<path
d="M3 1.5h6l3 3v9H3z"
stroke="#6f8195"
strokeWidth="1.2"
/>
<path d="M9 1.5v3h3" stroke="#6f8195" strokeWidth="1.2" />
</svg>
Мои отчёты{" "}
<span
style={{
marginLeft: "auto",
fontFamily: tokens.font.mono,
fontSize: "10px",
color: tokens.accent,
}}
>
{user.reports}
</span>
</div>
<div className="tnav-menuitem" style={menuItemStyle}>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<circle
cx="7.5"
cy="7.5"
r="2.3"
stroke="#6f8195"
strokeWidth="1.2"
/>
<path
d="M7.5 1v2M7.5 12v2M1 7.5h2M12 7.5h2"
stroke="#6f8195"
strokeWidth="1.2"
/>
</svg>
Настройки
</div>
<div className="tnav-menuitem" style={menuItemStyle}>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<circle
cx="7.5"
cy="7.5"
r="6"
stroke="#6f8195"
strokeWidth="1.2"
/>
<path
d="M5.8 5.8a1.7 1.7 0 1 1 2.4 2.1c-.5.4-.7.7-.7 1.3M7.5 11.2v.2"
stroke="#6f8195"
strokeWidth="1.2"
/>
</svg>
Помощь
</div>
<div
style={{
height: "1px",
background: tokens.lineSoft2,
margin: "6px 4px",
}}
/>
<div
onClick={() => setUserOpen(false)}
className="tnav-logout"
style={{ ...menuItemStyle, color: tokens.danger }}
>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<path
d="M9 1.5H2.5v12H9M6 7.5h7M10.5 5l2.5 2.5L10.5 10"
stroke="#cd6868"
strokeWidth="1.2"
/>
</svg>
Выйти
</div>
</div>
)}
</div>
</div>
</>
);
}

View file

@ -0,0 +1,665 @@
// Sample/markup data for the /trade-in/v2 "МЕРА Оценка" design port.
// Extracted verbatim from the pixel design (МЕРА Оценка.dc.html). This is the
// data source for the markup — components must NOT fetch; they read from here.
// Colour fields reference tokens so the palette stays the single source of truth.
import { tokens } from "./tokens";
import type {
Analytics,
CacheKpi,
CacheRow,
DkpRow,
AdRow,
DealRow,
DropdownOptions,
History,
LocationFactors,
MarketAds,
MarketDeals,
ObjectInfo,
Ranges,
Report,
ResultCard,
ResultMeta,
ScatterMini,
SourceCard,
Summary,
User,
} from "./types";
export const report: Report = {
id: "9b9e2737",
date: "20.06.2026",
validUntil: "21.06.2026",
};
export const object: ObjectInfo = {
address: "ул. Малышева, 30",
city: "Екатеринбург, Россия",
area: "54",
rooms: "2",
floor: "5",
totalFloors: "17",
year: "1985",
houseType: "Панельный",
repair: "Хороший",
balcony: true,
locationCoef: "0.87",
streetView: "Street View · май 2024",
compass: "СЕВЕРО-ЗАПАД",
};
// ---- 02 RESULT ------------------------------------------------------------
export const resultCards: ResultCard[] = [
{
title: ["РЕКОМЕНДОВАННАЯ ЦЕНА", "В ОБЪЯВЛЕНИИ"],
value: "11,29",
unit: "млн ₽",
range: "7,72 — 14,46 млн ₽",
ppm: "225 801 ₽/м² · по объявлениям",
bars: [30, 55, 80, 100, 70, 45, 25, 35],
nav: 2,
},
{
title: ["ОЖИДАЕМАЯ СДЕЛКА", "(ПОЛУЧИТЕ)"],
value: "10,00",
unit: "млн ₽",
range: "6,83 — 12,81 млн ₽",
ppm: "199 973 ₽/м²",
delta: "11%",
deltaLabel: "К РЫНКУ",
nav: 2,
},
{
title: ["ДКП · РОСРЕЕСТР", "(ФАКТИЧЕСКИЕ СДЕЛКИ)"],
value: "9,43",
unit: "млн ₽",
range: "6,10 — 12,72 млн ₽",
ppm: "188 672 ₽/м² · 44 сделки",
bars: [25, 45, 70, 100, 85, 55, 30, 20],
nav: 1,
},
];
export const resultMeta: ResultMeta = {
sources: "3 / 7",
confidence: "СРЕДНЯЯ",
cv: "59.7%",
};
export const ranges: Ranges = {
ads: {
label: ["ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ", "(БЕЗ УЧЁТА РЕМОНТА)"],
median: "11,29 млн ₽",
lo: "7,72 млн",
hi: "14,46 млн",
marker: { fillLeft: "8%", fillRight: "18%", dotLeft: "58%" },
},
deals: {
label: ["ДИАПАЗОН ЦЕН ПО", "ФАКТИЧЕСКИМ СДЕЛКАМ"],
median: "6,80 млн ₽",
lo: "4,30 млн",
hi: "19,70 млн",
marker: { fillLeft: "4%", fillRight: "55%", dotLeft: "33%" },
},
};
// Mini "ЦЕНА × СРОК ПРОДАЖИ" scatter inside the ranges card (viewBox 168×140).
export const scatterMini: ScatterMini = {
deals: [
{ x: 52, y: 96, r: 2.4 },
{ x: 64, y: 88, r: 2.4 },
{ x: 58, y: 101, r: 2.4 },
{ x: 74, y: 85, r: 2.4 },
{ x: 47, y: 103, r: 2.4 },
{ x: 88, y: 79, r: 2.4 },
{ x: 70, y: 93, r: 2.4 },
{ x: 55, y: 90, r: 2.4 },
{ x: 82, y: 99, r: 2.4 },
{ x: 44, y: 97, r: 2.4 },
],
analogs: [
{ x: 118, y: 48, r: 3 },
{ x: 100, y: 60, r: 3 },
{ x: 132, y: 40, r: 3 },
{ x: 92, y: 66, r: 3 },
{ x: 112, y: 54, r: 3 },
{ x: 142, y: 34, r: 3 },
],
subject: { x: 96, y: 70, r: 3.6 },
yTicks: [
{ label: "15М", y: 13 },
{ label: "10М", y: 77 },
{ label: "4М", y: 112 },
],
xTicks: [
{ label: "0", x: 34 },
{ label: "90", x: 96 },
{ label: "180", x: 158 },
],
};
export const sources: SourceCard[] = [
{ name: "ЦИАН", count: "7", label: "лотов", active: true },
{ name: "Я.НЕДВИЖИМОСТЬ", count: "1", label: "лот", active: true },
{ name: "РОСРЕЕСТР (ВНУТР.)", count: "10", label: "лотов", active: true },
{ name: "АВИТО ОЦЕНКА", count: "—", label: "нет данных", active: false },
{ name: "ДОМКЛИК ПРАЙС", count: "—", label: "нет данных", active: false },
{ name: "RESTATE", count: "—", label: "нет данных", active: false },
{ name: "N1.RU", count: "—", label: "нет данных", active: false },
];
// ---- 03 SUMMARY -----------------------------------------------------------
export const summary: Summary = {
rows: [
{ label: "Продажи в доме", value: "2 · 32 дн", dot: "accent", nav: 1 },
{ label: "Аналоги", value: "23 · 6,13 млн", dot: "accent", nav: 2 },
{ label: "Сделки", value: "10 · 5,95 млн", dot: "muted", nav: 2 },
{ label: "Аналитика", value: "67 дн · 3%", dot: "accent", nav: 3 },
],
quality: { sources: "3 / 7", confidence: "средняя", cv: "59.7%" },
};
// ---- OVERLAY 04: ПРОДАЖИ В ДОМЕ -------------------------------------------
export const dkpRows: DkpRow[] = [
{
area: "33.9 м²",
price: "5 950 000 ₽",
ppm: "175 516",
ask: "5 272 800 ₽ за 3 дн до",
delta: "+12.8%",
deltaColor: tokens.danger,
},
{
area: "35.4 м²",
price: "4 900 000 ₽",
ppm: "138 418",
ask: "5 186 700 ₽ за 3 дн до",
delta: "5.5%",
deltaColor: tokens.success,
},
{
area: "36.2 м²",
price: "4 200 000 ₽",
ppm: "116 022",
ask: "5 186 700 ₽ за 3 дн до",
delta: "19.0%",
deltaColor: tokens.success,
},
{
area: "37.0 м²",
price: "7 503 227 ₽",
ppm: "202 790",
ask: "5 272 800 ₽ за 3 дн до",
delta: "+42.3%",
deltaColor: tokens.danger,
},
{
area: "36.8 м²",
price: "7 140 243 ₽",
ppm: "194 028",
ask: "5 272 800 ₽ за 3 дн до",
delta: "+35.4%",
deltaColor: tokens.danger,
},
{
area: "42.1 м²",
price: "6 535 050 ₽",
ppm: "155 227",
ask: "5 272 800 ₽ за 3 дн до",
delta: "+23.9%",
deltaColor: tokens.danger,
},
{
area: "42.0 м²",
price: "6 650 000 ₽",
ppm: "158 333",
ask: "5 781 600 ₽ за 3 дн до",
delta: "+15.0%",
deltaColor: tokens.danger,
},
{
area: "32.5 м²",
price: "5 362 650 ₽",
ppm: "165 005",
ask: "5 325 600 ₽ за 3 дн до",
delta: "+0.7%",
deltaColor: tokens.danger,
},
{
area: "38.8 м²",
price: "4 380 000 ₽",
ppm: "112 887",
ask: "5 325 600 ₽ за 3 дн до",
delta: "17.8%",
deltaColor: tokens.success,
},
{
area: "34.9 м²",
price: "5 000 000 ₽",
ppm: "143 266",
ask: "5 254 500 ₽ за 3 дн до",
delta: "4.8%",
deltaColor: tokens.success,
},
];
export const history: History = {
houseSales: [
{
lot: "2-к. квартира, 50 м², 6/16 эт.",
priceFrom: "6,25",
priceTo: "5,98 млн ₽",
date: "11.07.2025",
exposure: "32 дн.",
},
{
lot: "2-к. квартира, 60 м², 6/16 эт.",
priceFrom: "7,29",
priceTo: "7,09 млн ₽",
date: "11.07.2025",
exposure: "31 дн.",
},
],
dkpHeader: {
title: "ДКП-сделки на улице Академика Ландау",
period: "24 мес",
note: "78% сделок имеют историческую цену в объявлении · медианный торг 36.0%. Данные по улице, не по дому.",
},
dkpKpi: { count: "95", median: "140 066", range: "3,1 7,5" },
};
// ---- OVERLAY 05: АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ -----------------------------------
export const adRows: AdRow[] = [
{
addr: "ул. Академика Ландау, 39",
meta: "32.5 м² · 1-к · этаж 12/15",
ppm: "170 769",
price: "5 550 000",
},
{
addr: "ул. Академика Ландау, 35А/2",
meta: "33.5 м² · 1-к · этаж 13/25",
ppm: "174 626",
price: "5 850 000",
},
{
addr: "ул. Академика Ландау, 38",
meta: "32.7 м² · 1-к · этаж 10/16",
ppm: "171 253",
price: "5 600 000",
},
{
addr: "ул. Академика Ландау, 35",
meta: "32.7 м² · 1-к · этаж 6/12",
ppm: "178 999",
price: "5 853 300",
},
{
addr: "ул. Академика Ландау, 35",
meta: "37.4 м² · 1-к · этаж 8/12",
ppm: "149 732",
price: "5 600 000",
},
{
addr: "ул. Академика Парина, 29",
meta: "40.5 м² · 1-к · этаж 22/23",
ppm: "160 493",
price: "6 500 000",
},
{
addr: "ул. Академика Ландау, 39",
meta: "32.5 м² · 1-к · этаж 15/15",
ppm: "146 153",
price: "4 750 000",
},
{
addr: "ул. Академика Ландау, 39",
meta: "34.9 м² · 1-к · этаж 9/15",
ppm: "143 266",
price: "5 000 000",
},
{
addr: "ул. Академика Парина, 33",
meta: "37.1 м² · 1-к · этаж 6/14",
ppm: "160 916",
price: "5 970 000",
},
{
addr: "ул. Академика Ландау, 34",
meta: "41.7 м² · 1-к · этаж 22/23",
ppm: "167 865",
price: "6 999 999",
},
];
export const dealRows: DealRow[] = [
{
addr: "Екатеринбург, Академика Парина",
meta: "38.1 м² · 1-к",
ppm: "161 417",
price: "6 150 000",
},
{
addr: "Екатеринбург, Академика Ландау",
meta: "33.9 м² · 1-к",
ppm: "175 516",
price: "5 950 000",
},
{
addr: "Екатеринбург, Академика Ландау",
meta: "39.1 м² · 1-к",
ppm: "148 338",
price: "5 800 000",
},
{
addr: "Екатеринбург, Академика Парина",
meta: "33.8 м² · 1-к",
ppm: "147 929",
price: "5 000 000",
},
{
addr: "Екатеринбург, Академика Сахарова",
meta: "37.1 м² · 1-к",
ppm: "167 116",
price: "6 200 000",
},
{
addr: "Екатеринбург, Академика Вавилова",
meta: "38.8 м² · 1-к",
ppm: "165 437",
price: "6 418 972",
},
{
addr: "Екатеринбург, Академика Ландау",
meta: "42.0 м² · 1-к",
ppm: "158 333",
price: "6 650 000",
},
{
addr: "Екатеринбург, Академика Ландау",
meta: "34.9 м² · 1-к",
ppm: "143 266",
price: "5 000 000",
},
{
addr: "Екатеринбург, Академика Парина",
meta: "32.9 м² · 1-к",
ppm: "159 574",
price: "5 250 000",
},
{
addr: "Екатеринбург, Академика Парина",
meta: "41.2 м² · 1-к",
ppm: "101 942",
price: "4 200 000",
},
];
export const marketAds: MarketAds = {
kpi: {
count: "23",
median: "6,13",
ppm: "161 351",
range: "4,77,5",
cv: "45.9%",
},
filters: ["Расстояние ≤ 2 км", "Планировка 1-к", "Площадь 32.5 41.7 м²"],
};
export const marketDeals: MarketDeals = {
kpi: {
count: "10",
median: "5,95",
delta: "3% к рынку",
range: "4,2 6,7",
},
};
// ---- OVERLAY 06: АНАЛИТИКА ДОМА -------------------------------------------
export const analytics: Analytics = {
header: { ads: "615 объявлений", radius: "в радиусе 300 м" },
kpis: [
{
label: "СРЕДНЯЯ ЭКСПОЗИЦИЯ",
value: "67",
unit: "дн.",
sub: "по архивным объявлениям",
},
{
label: "СРЕДНИЙ ТОРГ",
value: "3.0%",
color: tokens.success,
sub: "от начальной до итоговой цены",
},
{
label: "ДОЛЯ СНЯТЫХ",
value: "69%",
sub: "421 из 615",
},
],
sellTimeNote: "Медиана экспозиции по архивным лотам · эталон 177 394 ₽/м²",
sellTime: [
{
tier: "5% от рынка",
days: "~63 дн.",
range: "обычно 18180 дн.",
count: "69 аналогов",
variant: "success",
},
{
tier: "По медиане",
days: "~65 дн.",
range: "обычно 18182 дн.",
count: "79 аналогов",
variant: "accent",
},
{
tier: "+5%",
days: "~78 дн.",
range: "обычно 16212 дн.",
count: "61 аналог",
variant: "gold",
},
{
tier: "+10%",
days: "~67 дн.",
range: "обычно 26260 дн.",
count: "57 аналогов",
variant: "danger",
},
],
priceHistory: {
note: "Медиана ₽/м² по годам · только этот дом · Avito + Яндекс (596 лотов)",
years: ["2022", "2023", "2024", "2025", "2026"],
avito: "58,75 260,82 470,69 680,69 885,73",
yandex: "58,75 260,82 470,51 680,42 885,24",
},
scatterDetail: {
note: "16 сделок и 6 аналогов в радиусе 300 м",
deals: [
{ x: 150, y: 210, r: 4.5 },
{ x: 200, y: 196, r: 4.5 },
{ x: 175, y: 222, r: 4.5 },
{ x: 250, y: 188, r: 4.5 },
{ x: 220, y: 204, r: 4.5 },
{ x: 300, y: 176, r: 4.5 },
{ x: 270, y: 200, r: 4.5 },
{ x: 330, y: 166, r: 4.5 },
{ x: 360, y: 182, r: 4.5 },
{ x: 195, y: 214, r: 4.5 },
{ x: 285, y: 190, r: 4.5 },
{ x: 160, y: 200, r: 4.5 },
{ x: 345, y: 198, r: 4.5 },
{ x: 240, y: 172, r: 4.5 },
{ x: 315, y: 186, r: 4.5 },
{ x: 180, y: 232, r: 4.5 },
],
analogs: [
{ x: 600, y: 96, r: 5.5 },
{ x: 540, y: 124, r: 5.5 },
{ x: 670, y: 84, r: 5.5 },
{ x: 500, y: 142, r: 5.5 },
{ x: 640, y: 104, r: 5.5 },
{ x: 730, y: 70, r: 5.5 },
],
subject: { x: 445, y: 150, r: 6 },
yTicks: [
{ label: "15М", y: 24 },
{ label: "12М", y: 81 },
{ label: "9М", y: 138 },
{ label: "6М", y: 195 },
{ label: "4М", y: 252 },
],
xTicks: [
{ label: "0", x: 60 },
{ label: "45", x: 265 },
{ label: "90", x: 470 },
{ label: "135", x: 675 },
{ label: "180", x: 880 },
],
},
};
// ---- OVERLAY 07: ПРЕДЫДУЩИЕ ОЦЕНКИ ----------------------------------------
export const cacheRows: CacheRow[] = [
{
addr: "ул. Малышева, 30",
time: "12:45:02",
src: "3 / 7",
status: "свежий",
statusColor: tokens.success,
},
{
addr: "ул. Куйбышева, 48",
time: "12:31:50",
src: "4 / 7",
status: "свежий",
statusColor: tokens.success,
},
{
addr: "ул. Академика Ландау, 35",
time: "11:58:14",
src: "2 / 7",
status: "свежий",
statusColor: tokens.success,
},
{
addr: "ул. 8 Марта, 120",
time: "09:12:40",
src: "3 / 7",
status: "устаревает",
statusColor: tokens.warn,
},
{
addr: "пр. Ленина, 5",
time: "вчера 18:04",
src: "5 / 7",
status: "устарел",
statusColor: tokens.danger,
},
{
addr: "ул. Белинского, 86",
time: "вчера 14:22",
src: "2 / 7",
status: "устарел",
statusColor: tokens.danger,
},
];
export const cacheKpi: CacheKpi[] = [
{ label: "ВСЕГО ОЦЕНОК", value: "128", sub: "за последние 30 дней" },
{
label: "СРЕДНЯЯ ЦЕНА ОЦЕНОК",
value: "9,6",
unit: "млн ₽",
sub: "по проведённым оценкам",
},
{ label: "ПОВТОРНЫЕ АДРЕСА", value: "18", unit: "%", sub: "доля переоценок" },
];
// ---- INPUTS / DROPDOWNS ---------------------------------------------------
export const dropdownOptions: DropdownOptions = {
rooms: ["1", "2", "3", "4", "5+"],
houseType: [
"Панельный",
"Кирпичный",
"Монолитный",
"Блочный",
"Монолитно-кирпичный",
],
repair: [
"Без отделки",
"Требует ремонта",
"Удовлетворительный",
"Хороший",
"Отличный",
],
radius: ["300 м", "500 м", "1000 м", "2000 м"],
crm: [
"Не заполнять",
"Создать сделку",
"Привязать к контакту",
"Экспорт в CRM",
],
};
// ---- КОЭФ. ЛОКАЦИИ DRAWER -------------------------------------------------
export const locationFactors: LocationFactors = {
coef: "0.87",
intro:
"Показывает, как адрес корректирует стоимость относительно медианы по Екатеринбургу. 1.00 — средний уровень. 0.87 означает, что локация снижает цену примерно на 13% из-за баланса факторов ниже.",
formula: { base: "11,29 млн", coef: "0.87", result: "9,82 млн ₽" },
positives: [
{ label: "Центр города, пешая доступность ключевых точек", delta: "+0.06" },
{ label: "Транспортные узлы и остановки рядом", delta: "+0.05" },
{ label: "Набережная и парк в 10 минутах", delta: "+0.04" },
{ label: "Развитая торговая инфраструктура", delta: "+0.03" },
],
negatives: [
{ label: "Оживлённая магистраль, шумовая нагрузка", delta: "0.07" },
{ label: "Дефицит парковочных мест", delta: "0.05" },
{ label: "Возраст жилого фонда района (1985)", delta: "0.04" },
{ label: "Износ инженерных сетей квартала", delta: "0.02" },
],
footer:
"Источник геоданных: OpenStreetMap POI, транспортная доступность, шумовые и экологические слои. Коэффициент пересчитывается при смене адреса.",
};
// ---- NAV / CHROME ---------------------------------------------------------
export const navLabels: string[] = [
"ОЦЕНКА",
"ПРОДАЖИ В ДОМЕ",
"АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ",
"АНАЛИТИКА",
"ПРЕДЫДУЩИЕ ОЦЕНКИ",
];
export const overlayTitles: string[] = [
"",
"ПРОДАЖИ В ДОМЕ",
"АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ",
"АНАЛИТИКА ДОМА",
"ПРЕДЫДУЩИЕ ОЦЕНКИ",
];
export const overlayNums: string[] = ["", "04", "05", "06", "07"];
export const user: User = {
name: "Андрей Петров",
org: "Брусника",
email: "a.petrov@mera.ru",
initials: "АП",
reports: 128,
};
export const version = "v2.0.6";

View file

@ -0,0 +1,115 @@
// Design tokens for the /trade-in/v2 "МЕРА Оценка" HUD/blueprint port.
// Faithful colour palette extracted from the pixel design (МЕРА Оценка.dc.html).
// This file is the shared colour contract for every v2 component — import from
// here instead of hardcoding hex literals (one-off SVG stroke/opacity may stay
// inline). NOTE: this is an isolated design port, so it intentionally does NOT
// reuse the global GenDesign analytics tokens (different visual language).
// Page background behind the 1536×1024 artboard.
export const pageBg = "#dde6ef";
export const tokens = {
// Brand / accent
accent: "#2e8bff",
accentDeep: "#0d6fd6",
accentLite: "#46a8ff",
onAccent: "#fff", // opaque white text/glyph on accent-coloured surfaces (avatar initials)
// Ink / headings
ink: "#1c2c40",
ink2: "#17263a",
// Body text
body: "#39506a",
body2: "#46586c",
// Muted text (light → lighter)
muted: "#6f8195",
muted2: "#91a1b4",
muted3: "#9aaabb",
muted4: "#b3c0cf",
hint: "#8a9aac", // small helper / caption text
// Borders & hairlines (strong → soft)
line: "#c2d3e3",
line2: "#c8d7e6",
line3: "#b8c8d8", // photo/card edge, chart axes
lineSoft: "#d3dfeb",
lineSoft2: "#e2e9f1",
lineSoft3: "#eef3f8",
lineSoft4: "#eaf0f6", // pale-blue hairline — house-sales row separator (design line 462)
lineGradient: "#cbd7e3", // decorative gradient line fade stops (footer hairline)
// HUD corner brackets
bracket: "#9bbce0",
bracket2: "#8fb4dd",
// Semantic
success: "#1baa6b",
successBorder: "#b6e0cc", // soft green border for success badges (В РАСЧЁТЕ)
warn: "#b58a3e",
gold: "#c79a3e",
danger: "#cd6868",
// Info-soft panel (data-quality box)
infoSoftBg: "rgba(233,242,252,.5)", // translucent blue panel fill
infoSoftBorder: "#d3e0ec", // soft blue panel border
surfaceTint: "rgba(238,244,250,.5)", // translucent blue-tinted surface — KPI band + table footer (design lines 471, 487)
// Data-viz tints (mini histograms / range bars / scatter)
barLo: "#cfddec",
barMid: "#a9cef5",
track: "#dbe6f1", // range track + donut track
scatterDeal: "#9aafc4", // grey "сделки" dots (price×time scatter charts)
dealDot: "#8fa3b8", // grey "сделки" marker — map dot + legend (design 186, 203); darker than scatterDeal
dotStroke: "#fff", // white outline ring around map marker dots (design 185-187)
// Solid backgrounds
photoBg: "#e7eef6", // hero photo container fill behind building image
mapBg: "#e4ebf3", // map container fill behind blueprint SVG (design 156)
badgeTint: "#eaf1f8", // opaque pale-blue fill — source badge ("Яндекс") chip (design line 484)
// Disabled / no-data source card (02 RESULT · inactive source tile, design lines 366-384)
disabledBorder: "#d8e2ec", // inactive source card edge
disabledValue: "#c2cdd9", // greyed-out "—" count value
disabledLabel: "#aab7c5", // greyed-out "нет данных" caption
// Accent glow (range-marker dot halo, design lines 303/341)
accentGlow: "rgba(46,139,255,.5)",
// Translucent white surfaces (glass cards / overlays)
surface: {
w25: "rgba(255,255,255,.25)",
w40: "rgba(255,255,255,.4)",
w50: "rgba(255,255,255,.5)",
w55: "rgba(255,255,255,.55)",
w60: "rgba(255,255,255,.6)",
w62: "rgba(255,255,255,.62)",
w65: "rgba(255,255,255,.65)",
w70: "rgba(255,255,255,.7)",
w72: "rgba(255,255,255,.72)",
w80: "rgba(255,255,255,.8)",
w85: "rgba(255,255,255,.85)",
w98: "rgba(255,255,255,.98)",
w99: "rgba(255,255,255,.99)",
},
// Font CSS variables (provided by app/trade-in/v2/layout.tsx via next/font)
font: {
sans: "var(--font-manrope), sans-serif",
mono: "var(--font-plex-mono), monospace",
},
// Artboard background (radial glows + blueprint grid + base gradient).
// Ported verbatim from the design root div (lines 23-29).
gradientBg: [
"radial-gradient(620px 320px at 78% 16%, rgba(46,139,255,.07), transparent 60%)",
"radial-gradient(560px 360px at 24% 64%, rgba(46,139,255,.055), transparent 60%)",
"radial-gradient(480px 300px at 60% 92%, rgba(46,139,255,.04), transparent 60%)",
"repeating-linear-gradient(0deg, rgba(120,150,180,.045) 0 1px, transparent 1px 38px)",
"repeating-linear-gradient(90deg, rgba(120,150,180,.045) 0 1px, transparent 1px 38px)",
"linear-gradient(160deg,#f4f8fc 0%,#eaf1f8 55%,#e6eef7 100%)",
].join(", "),
pageBg,
};

View file

@ -0,0 +1,284 @@
// TypeScript shapes for the /trade-in/v2 design fixtures.
// All values are sample/markup data (see fixtures.ts) — no API types here.
export interface Report {
id: string;
date: string;
validUntil: string;
}
export interface ObjectInfo {
address: string;
city: string;
area: string;
rooms: string;
floor: string;
totalFloors: string;
year: string;
houseType: string;
repair: string;
balcony: boolean;
locationCoef: string;
streetView: string;
compass: string;
}
// ---- 02 RESULT ------------------------------------------------------------
export interface ResultCard {
/** Header label, one entry per visual line. */
title: string[];
/** Big mono number, e.g. "11,29". */
value: string;
/** Unit suffix, e.g. "млн ₽". */
unit: string;
/** Low — high range line, e.g. "7,72 — 14,46 млн ₽". */
range: string;
/** ₽/м² sub-line (may include a "· N сделки" tail). */
ppm: string;
/** Mini-histogram column heights in % (cards 1 & 3). */
bars?: number[];
/** Donut delta value, e.g. "11%" (card 2). */
delta?: string;
/** Donut caption, e.g. "К РЫНКУ" (card 2). */
deltaLabel?: string;
/** Nav index opened by the card's "Подробнее →" link. */
nav: number;
}
export interface ResultMeta {
sources: string;
confidence: string;
cv: string;
}
export interface RangeMarker {
/** Filled segment offsets / dot position, as CSS % strings. */
fillLeft: string;
fillRight: string;
dotLeft: string;
}
export interface RangeBar {
/** Header label, one entry per visual line. */
label: string[];
median: string;
lo: string;
hi: string;
marker: RangeMarker;
}
export interface Ranges {
ads: RangeBar;
deals: RangeBar;
}
export interface ScatterPoint {
x: number;
y: number;
r: number;
}
export interface AxisTickX {
label: string;
x: number;
}
export interface AxisTickY {
label: string;
y: number;
}
export interface ScatterMini {
deals: ScatterPoint[];
analogs: ScatterPoint[];
subject: ScatterPoint;
yTicks: AxisTickY[];
xTicks: AxisTickX[];
}
export interface SourceCard {
name: string;
/** Lot count, e.g. "7" or "—" when no data. */
count: string;
/** Count caption: "лотов" / "лот" / "нет данных". */
label: string;
active: boolean;
}
// ---- 03 SUMMARY -----------------------------------------------------------
export interface SummaryRow {
label: string;
value: string;
dot: "accent" | "muted";
nav: number;
}
export interface SummaryQuality {
sources: string;
confidence: string;
cv: string;
}
export interface Summary {
rows: SummaryRow[];
quality: SummaryQuality;
}
// ---- OVERLAY: ПРОДАЖИ В ДОМЕ / ИСТОРИЯ ------------------------------------
export interface DkpRow {
area: string;
price: string;
ppm: string;
ask: string;
delta: string;
/** Colour for the delta (success / danger token value). */
deltaColor: string;
}
export interface HouseSaleRow {
lot: string;
priceFrom: string;
priceTo: string;
date: string;
exposure: string;
}
export interface History {
houseSales: HouseSaleRow[];
dkpHeader: { title: string; period: string; note: string };
dkpKpi: { count: string; median: string; range: string };
}
// ---- OVERLAY: АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ --------------------------------------
export interface AdRow {
addr: string;
meta: string;
ppm: string;
price: string;
}
export interface DealRow {
addr: string;
meta: string;
ppm: string;
price: string;
}
export interface MarketAds {
kpi: {
count: string;
median: string;
ppm: string;
range: string;
cv: string;
};
filters: string[];
}
export interface MarketDeals {
kpi: { count: string; median: string; delta: string; range: string };
}
// ---- OVERLAY: АНАЛИТИКА ДОМА ----------------------------------------------
export interface AnalyticsKpi {
label: string;
value: string;
unit?: string;
/** Optional value colour override (token value). */
color?: string;
sub: string;
}
export interface SellTimeTier {
tier: string;
days: string;
range: string;
count: string;
variant: "success" | "accent" | "gold" | "danger";
}
export interface PriceHistory {
note: string;
years: string[];
/** SVG polyline points string (viewBox 0 0 900 220). */
avito: string;
yandex: string;
}
export interface ScatterDetail {
note: string;
deals: ScatterPoint[];
analogs: ScatterPoint[];
subject: ScatterPoint;
yTicks: AxisTickY[];
xTicks: AxisTickX[];
}
export interface Analytics {
header: { ads: string; radius: string };
kpis: AnalyticsKpi[];
sellTime: SellTimeTier[];
/** Sub-line above the sell-time tiers (carries the эталон ₽/м² number). */
sellTimeNote: string;
priceHistory: PriceHistory;
scatterDetail: ScatterDetail;
}
// ---- OVERLAY: ПРЕДЫДУЩИЕ ОЦЕНКИ (CACHE) -----------------------------------
export interface CacheRow {
addr: string;
time: string;
src: string;
status: string;
/** Status dot/text colour (success / warn / danger token value). */
statusColor: string;
}
export interface CacheKpi {
label: string;
value: string;
unit?: string;
sub: string;
}
// ---- INPUTS / DROPDOWNS ---------------------------------------------------
export interface DropdownOptions {
rooms: string[];
houseType: string[];
repair: string[];
radius: string[];
crm: string[];
}
// ---- КОЭФ. ЛОКАЦИИ DRAWER -------------------------------------------------
export interface LocationFactor {
label: string;
delta: string;
}
export interface LocationFactors {
coef: string;
intro: string;
formula: { base: string; coef: string; result: string };
positives: LocationFactor[];
negatives: LocationFactor[];
footer: string;
}
// ---- USER / CHROME --------------------------------------------------------
export interface User {
name: string;
org: string;
email: string;
initials: string;
reports: number;
}