gendesign/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx
bot-backend 1722309372
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(tradein/v2): UI/UX-аудит P2 — контент/честность/иерархия (#2062)
- Опц. поля больше не шлют непроверенные дефолты в ценовую модель:
  ТИП/РЕМОНТ → «Не указано»→undefined, БАЛКОН tri-state (null пока не
  трогали) → undefined.
- Валидация у поля: красная рамка + сообщение под адресом/площадью,
  показываются ВСЕ блокеры (не только первый); низ — только серверная ошибка.
- Копирайт «вы»: «если знаете»; кэш-строка → «ДАННЫЕ АКТУАЛЬНЫ ·
  ДЕЙСТВИТЕЛЕН 24 Ч»; drawer без жаргона (убраны (asking)/POI).
- Даты сделок Росреестра → «янв 2026» (месяц, не фейк-день 01.01);
  бренды источников без method-суффиксов (РОСРЕЕСТР/АВИТО/ДОМКЛИК);
  «эталон»→«опорная цена».
- График истории цен: зум оси Y к реальному диапазону данных + динамические
  подписи оси; легенда серии показывается только при наличии линии.
- 3 цены весомее (font-weight 400); медиана-маркеры range-баров → тонкий тик
  (не выглядит как draggable-ручка); фото здания — синий duotone +
  десатурация (не крадёт первый взгляд у цен).
- Таблицы аналогов/сделок: площадь/комнаты/этаж вынесены из адресной ячейки
  в колонку ПАРАМЕТРЫ; ↗→› в СВОДКЕ.

Сознательно НЕ тронуто: перераспределение accent-синего (item 9) — axe уже 0,
а широкое снятие акцента конфликтует с «не ломать HUD-палитру»; монотонность
тиров срока продажи не форсируется (это были бы выдуманные данные).
2026-06-28 20:51:49 +03:00

568 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
// Overlay 06 "АНАЛИТИКА ДОМА" — markup port from the МЕРА Оценка pixel design.
// Accepts one composite `data` prop (default = the design fixture); page.tsx
// feeds mapAnalytics(...) output. The price-history dots navigate to the
// "Аналогичные объявления" overlay via onNavigate?.(2).
import { useMemo, useState } from "react";
import { tokens } from "./tokens";
import { analytics as ANALYTICS_FIXTURE } from "./fixtures";
import type { Analytics, SellTimeTier } from "./types";
interface AnalyticsViewProps {
data?: Analytics;
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. Empty
// series (graceful-degradation case) parse to [] — no dots, no hover targets.
interface Pt {
x: number;
y: number;
}
function parsePoints(s: string): Pt[] {
return s
.trim()
.split(/\s+/)
.filter((pair) => pair.length > 0)
.map((pair) => {
const [x, y] = pair.split(",").map(Number);
return { x, y };
});
}
// 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({
data = ANALYTICS_FIXTURE,
onNavigate,
}: AnalyticsViewProps) {
const [hoverHist, setHoverHist] = useState(false);
// Price-history series + hover targets, derived from the data prop. Яндекс
// only draws dots where its series diverges from Avito (последние 3 года).
const avitoPts = useMemo(
() => parsePoints(data.priceHistory.avito),
[data.priceHistory.avito],
);
const yandexPts = useMemo(
() => parsePoints(data.priceHistory.yandex),
[data.priceHistory.yandex],
);
const yandexDots = useMemo(
() =>
yandexPts.filter(
(p, i) =>
!avitoPts[i] || p.x !== avitoPts[i].x || p.y !== avitoPts[i].y,
),
[yandexPts, avitoPts],
);
const hoverPts = useMemo<Pt[]>(
() => [...avitoPts, ...yandexDots],
[avitoPts, yandexDots],
);
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 }}>
{data.header.ads} · {data.header.radius}
</div>
</div>
{/* KPI row */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3,1fr)",
gap: 16,
}}
>
{data.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" }}
>
{data.sellTimeNote}
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4,1fr)",
gap: 12,
}}
>
{data.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 }}>
{data.priceHistory.note}
</div>
</div>
<div style={{ display: "flex", gap: 16, fontSize: 10 }}>
{avitoPts.length > 0 ? (
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span
style={{ width: 14, height: 2, background: tokens.accent }}
/>
Avito
</span>
) : null}
{yandexPts.length > 0 ? (
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.muted,
}}
>
<span
style={{ width: 14, height: 2, background: tokens.gold }}
/>
Яндекс
</span>
) : null}
</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}>
{data.priceHistory.yTicks.map((t) => (
<text key={t.label} x={0} y={t.y + 4}>
{t.label}
</text>
))}
</g>
<g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}>
{data.priceHistory.years.map((yr, i) => (
<text key={yr} x={histYearX[i]} y={214}>
{yr}
</text>
))}
</g>
<polyline
points={data.priceHistory.avito}
fill="none"
stroke={tokens.accent}
strokeWidth={2.5}
/>
<polyline
points={data.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 }}>
Зависимость цены от срока экспозиции · {data.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}>
{data.scatterDetail.deals.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
))}
</g>
<g fill={tokens.accent}>
{data.scatterDetail.analogs.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
))}
</g>
<circle
cx={data.scatterDetail.subject.x}
cy={data.scatterDetail.subject.y}
r={data.scatterDetail.subject.r}
fill={tokens.ink}
/>
<circle
cx={data.scatterDetail.subject.x}
cy={data.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"
>
{data.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"
>
{data.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>
);
}