From 9de690ad4fbdd8f492c7bc3d2dbb54939b43a8d5 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 7 Aug 2026 10:00:48 +0000 Subject: [PATCH] =?UTF-8?q?chore(tradein/frontend):=20=D1=83=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D1=82=D1=8C=20=D0=BE=D1=81=D1=82=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D1=8B=D0=B9=20=D1=81=D0=BB=D0=BE=D0=B9=20=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D1=81=D1=82=D1=83=D1=80=20(#2755)=20(#2785)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis/[cad]/ptica/ptica.module.css | 29 +- tradein-mvp/frontend/src/app/v2/page.tsx | 2 +- .../src/components/trade-in/v2/CacheView.tsx | 2 +- .../src/components/trade-in/v2/Footer.tsx | 4 +- .../components/trade-in/v2/ParamsPanel.tsx | 6 +- .../components/trade-in/v2/SectionOverlay.tsx | 4 +- .../components/trade-in/v2/SourcesView.tsx | 30 +- .../src/components/trade-in/v2/TopNav.tsx | 4 +- .../v2/__tests__/SourcesView.test.tsx | 94 +++ .../src/components/trade-in/v2/fixtures.ts | 671 ------------------ .../src/components/trade-in/v2/mappers.ts | 53 +- .../src/components/trade-in/v2/types.ts | 19 +- .../src/components/trade-in/v2/ui-config.ts | 62 ++ tradein-mvp/frontend/src/lib/city-registry.ts | 2 +- 14 files changed, 221 insertions(+), 761 deletions(-) create mode 100644 tradein-mvp/frontend/src/components/trade-in/v2/__tests__/SourcesView.test.tsx delete mode 100644 tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts create mode 100644 tradein-mvp/frontend/src/components/trade-in/v2/ui-config.ts diff --git a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css index e4c70c5f..31117249 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css +++ b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css @@ -10,8 +10,8 @@ /* ===================== SCOPED TOKENS (dark) ===================== */ .pticaRoot[data-theme="dark"] { --font-ui: - var(--font-inter), "Inter", "Manrope", -apple-system, "Segoe UI", - system-ui, sans-serif; + var(--font-inter), "Inter", "Manrope", -apple-system, "Segoe UI", system-ui, + sans-serif; --font-mono: var(--font-plex-mono), "IBM Plex Mono", "Roboto Mono", ui-monospace, monospace; @@ -907,31 +907,6 @@ letter-spacing: 0.08em; } -/* ===================== PLACEHOLDER PANEL ===================== */ -.placeholderPanel { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 10px; - text-align: center; - min-height: 240px; - border-style: dashed; -} -.placeholderPanel .soon { - font-size: 22px; - letter-spacing: 0.12em; - color: var(--text-soft); - font-weight: 700; - text-transform: uppercase; -} -.placeholderPanel p { - font-size: 10px; - color: var(--text-soft); - margin: 0; - max-width: 280px; -} - /* ===================== STATE SCREENS ===================== */ .stateScreen { display: grid; diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index 1476a7f7..e2122ac5 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -652,7 +652,7 @@ export default function TradeInV2Page() { // TopNav «Мои отчёты» badge: per-user estimate count. quota.used is the // authoritative monthly counter; fall back to the loaded history length, then - // to the fixture default (undefined) for unwired/pre-load states. + // to undefined (TopNav renders 0) for pre-load states. const reportsCount = quota.data?.used ?? history.data?.length; // Real user for the TopNav menu. undefined until /me resolves → TopNav shows diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/CacheView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/CacheView.tsx index 1c699b66..106e7d7a 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/CacheView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/CacheView.tsx @@ -160,7 +160,7 @@ export function CacheView({ data, onSelectRow }: CacheViewProps) { // #2420 — clicking/activating a row navigates to the full report // for that historical estimate (SectionOverlay -> page.tsx). Rows // stay a passive role="row"/role="cell" ARIA-grid когда onSelectRow - // не задан (unwired/storybook usage), меняя только tabIndex/handlers + // не задан (SectionOverlay без обработчика), меняя только tabIndex/handlers // — the grid structure itself is unchanged. const clickable = Boolean(onSelectRow); function activate() { diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx index 01ab9d22..9dab743a 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/Footer.tsx @@ -4,7 +4,7 @@ // МЕРА v2.0.6 wordmark on the right. Static markup, id/date/validUntil via `data`. import { tokens } from "./tokens"; -import { version } from "./fixtures"; +import { version } from "./ui-config"; import type { Report } from "./types"; interface FooterProps { @@ -13,7 +13,7 @@ interface FooterProps { // error, not a silent fallback to a fabricated report id/date. data: Report; // M4: hide the «ДЕЙСТВИТЕЛЕН ДО» validity line when there is no real estimate - // (mirrors HeroBar). Optional → unwired/fixture usage keeps showing it. + // (mirrors HeroBar). Optional: omitted → the line stays visible. hasEstimate?: boolean; } diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx index 91b8dc93..9d6b75a0 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx @@ -27,7 +27,7 @@ import { type SetStateAction, } from "react"; import { tokens } from "./tokens"; -import { dropdownOptions } from "./fixtures"; +import { dropdownOptions } from "./ui-config"; import { HOUSE_TYPE_FROM_RU, HOUSE_TYPE_RU, @@ -541,8 +541,8 @@ const radiusPanel: CSSProperties = { }; interface ParamsPanelProps { - /** Called with the validated form payload on «ОЦЕНИТЬ КВАРТИРУ». Optional so - * the still-unwired v2 page renders without props (tsc-safe). */ + /** Called with the validated form payload on «ОЦЕНИТЬ КВАРТИРУ». Optional + * dates back to the pre-wiring page; v2/page.tsx always supplies it. */ onSubmit?: (input: TradeInEstimateInput) => void; /** Disables the button + shows a pending label while the estimate is running. */ isPending?: boolean; diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx index b4ecdc72..c00225ab 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SectionOverlay.tsx @@ -9,7 +9,7 @@ import { useEffect, useRef } from "react"; import { tokens } from "./tokens"; -import { overlayNums, overlayTitles } from "./fixtures"; +import { overlayNums, overlayTitles } from "./ui-config"; import HistoryView from "./HistoryView"; import SourcesView from "./SourcesView"; import AnalyticsView from "./AnalyticsView"; @@ -37,7 +37,7 @@ interface SectionOverlayProps { estimate?: AggregatedEstimate | null; // #2420 — «ПРЕДЫДУЩИЕ ОЦЕНКИ» row click: navigate to the full report for // that historical estimate. Optional (threaded straight to CacheView's own - // optional onSelectRow — unwired usage leaves rows inert). + // optional onSelectRow — without a handler rows stay inert). onSelectEstimate?: (id: string) => void; } diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx index fb9ceffc..8ee85362 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx @@ -139,8 +139,9 @@ const monoMuted2: CSSProperties = { color: tokens.muted2, }; -// Shared "open original ↗" cell style (anchor + fixture-fallback span render -// identically; textDecoration:none keeps the anchor visually a button). +// "Открыть оригинал ↗" cell style; textDecoration:none keeps the anchor +// visually a button. Only a real ever gets it — a row without a link +// renders a plain dash, not a button-shaped span you cannot click. const linkBtn: CSSProperties = { fontSize: "9.5px", color: tokens.accent, @@ -210,7 +211,7 @@ const AD_COLS: Col[] = [ head: ИСТОЧНИК, cell: (r) => ( - {r.source ?? "Avito"} + {r.source} ), empty: () => false, @@ -263,7 +264,7 @@ const AD_COLS: Col[] = [ head: ДИСТ., cell: (r) => ( - {r.dist ?? "—"} + {r.dist} ), empty: (r) => isDash(r.dist), @@ -276,11 +277,10 @@ const AD_COLS: Col[] = [ // role="cell" sits on a display:contents wrapper so the stays the grid // item (identical layout) AND keeps its link role inside the cell. cell: (r) => { - // safeUrl gates href against javascript:/data: schemes (XSS). url is - // undefined for the design fixtures (→ "Росреестр ↗" fallback, keeps the - // storybook markup pixel-identical), null for a real row with no link - // (→ "—"), and a string when a real listing URL is available. - const href = typeof r.url === "string" ? safeUrl(r.url) : null; + // safeUrl gates href against javascript:/data: schemes (XSS). url is a + // string when the listing has a link and null when it has none (→ "—"). + // There is no third case: mapSources always sets the field. + const href = r.url === null ? null : safeUrl(r.url); return ( {href ? ( @@ -293,12 +293,8 @@ const AD_COLS: Col[] = [ > Источник ↗ - ) : r.url === null ? ( - ) : ( - - Росреестр ↗ - + )} ); @@ -336,9 +332,9 @@ const DEAL_COLS: Col[] = [ head: ИСТОЧНИК, cell: (r) => ( - {r.source ?? "Росреестр"}{" "} + {r.source}{" "} - {r.tier ?? "по улице"} + {r.tier} ), @@ -372,7 +368,7 @@ const DEAL_COLS: Col[] = [ head: ДАТА, cell: (r) => ( - {r.date ?? "—"} + {r.date} ), empty: (r) => isDash(r.date), diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx index 241af7d8..9bf5a19d 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx @@ -4,7 +4,7 @@ // 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/version come from fixtures, the user +// own useState. No data fetching — labels/version come from ./ui-config, the user // identity is fed in from the page (real useMe), colours from tokens. import { useState } from "react"; @@ -13,7 +13,7 @@ import type { CSSProperties } from "react"; import { API_BASE_URL } from "@/lib/api"; import { tokens } from "./tokens"; -import { navLabels, version } from "./fixtures"; +import { navLabels, version } from "./ui-config"; import { useSupportChat } from "./SupportChatContext"; // Real logged-in user identity, derived by the page from useMe() diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/SourcesView.test.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/SourcesView.test.tsx new file mode 100644 index 00000000..3ab3a05d --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/SourcesView.test.tsx @@ -0,0 +1,94 @@ +// #2755 — колонка ссылки в таблице аналогов «05 РЫНОК». +// +// Здесь до этого жила третья ветка: когда `url` приходил `undefined`, рисовался +// НЕкликабельный , стилизованный под кнопку-ссылку «Росреестр ↗». Ветка +// была недостижима (mapSources всегда ставит `string | null`), но выглядела как +// рабочий элемент интерфейса. Ветку убрали, поле сделали обязательным — этот +// тест держит обе половины: ссылка есть только там, где есть URL, а строка без +// URL получает прочерк, а не кнопку-обманку. + +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import SourcesView from "../SourcesView"; +import { mapSources } from "../mappers"; +import { FIXTURE_ESTIMATE } from "@/app/ui-preview/estimate/fixture"; +import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in"; + +function lot(over: Partial): AnalogLot { + return { + address: "ул. Репина, 73", + area_m2: 54, + rooms: 2, + floor: 7, + total_floors: 16, + price_rub: 9_700_000, + price_per_m2: 179_629, + listing_date: "2026-05-12", + days_on_market: 18, + photo_url: null, + source: "avito", + source_url: null, + distance_m: 120, + tier: null, + lat: 56.8401, + lon: 60.5702, + ...over, + }; +} + +// Оффлайн-фикстура оценки репозитория с подменёнными аналогами: остальные поля +// (sources_used, коридоры, KPI) настоящие, меняем ровно то, что проверяем. +function estimateWith(analogs: AnalogLot[]): AggregatedEstimate { + return { + ...FIXTURE_ESTIMATE, + analogs, + n_analogs: analogs.length, + actual_deals: [], + }; +} + +describe("SourcesView — колонка ссылки на оригинал", () => { + it("есть URL — кликабельная ссылка на него", () => { + const estimate = estimateWith([ + lot({ + address: "ул. Репина, 73", + source_url: "https://www.avito.ru/ekaterinburg/kvartiry/1", + }), + ]); + + render(); + + const link = screen.getByRole("link", { name: /Источник/ }); + expect(link).toHaveAttribute( + "href", + "https://www.avito.ru/ekaterinburg/kvartiry/1", + ); + }); + + it("URL нет — прочерк, а не кнопка-обманка «Росреестр ↗»", () => { + const estimate = estimateWith([ + lot({ address: "ул. Викулова, 33", source_url: null }), + ]); + + render(); + + expect(screen.queryByText(/Росреестр ↗/)).toBeNull(); + expect(screen.queryByRole("link", { name: /Источник/ })).toBeNull(); + // Прочерк — единственное содержимое ячейки ссылки в строке аналога. + const row = screen.getByText("ул. Викулова, 33").closest("div"); + expect(row?.textContent).toContain("—"); + }); + + it("javascript:-схема отбрасывается safeUrl — прочерк, а не ссылка", () => { + const estimate = estimateWith([ + lot({ address: "ул. Кирова, 28", source_url: "javascript:alert(1)" }), + ]); + + render(); + + expect(screen.queryByRole("link", { name: /Источник/ })).toBeNull(); + const row = screen.getByText("ул. Кирова, 28").closest("div"); + expect(row?.textContent).toContain("—"); + }); +}); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts b/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts deleted file mode 100644 index 7c66e2ba..00000000 --- a/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts +++ /dev/null @@ -1,671 +0,0 @@ -// 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, - 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, - locationIndexLabel: "+8%", - locationIndexOk: true, - // Approximate центр Екатеринбурга near ул. Малышева, 30 — illustrative - // fixture coordinate for the HeroBar locator mini-map (unwired usage only). - lat: 56.8384, - lon: 60.6057, -}; - -// ---- 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 }, -]; - -// ---- 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,7–7,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: "обычно 18–180 дн.", - count: "69 аналогов", - variant: "success", - }, - { - tier: "По медиане", - days: "~65 дн.", - range: "обычно 18–182 дн.", - count: "79 аналогов", - variant: "accent", - }, - { - tier: "+5%", - days: "~78 дн.", - range: "обычно 16–212 дн.", - count: "61 аналог", - variant: "gold", - }, - { - tier: "+10%", - days: "~67 дн.", - range: "обычно 26–260 дн.", - 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", - yTicks: [ - { label: "200k", y: 20 }, - { label: "150k", y: 64 }, - { label: "100k", y: 108 }, - { label: "50k", y: 151 }, - { label: "0k", y: 195 }, - ], - }, - 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[] = [ - { - id: "fixture-1", - addr: "ул. Малышева, 30", - time: "12:45:02", - src: "3 / 7", - status: "свежий", - statusColor: tokens.success, - params: "2 комн · 54 м² · 5/9 эт.", - }, - { - id: "fixture-2", - addr: "ул. Куйбышева, 48", - time: "12:31:50", - src: "4 / 7", - status: "свежий", - statusColor: tokens.success, - params: "3 комн · 72 м² · 12/16 эт.", - }, - { - id: "fixture-3", - addr: "ул. Академика Ландау, 35", - time: "11:58:14", - src: "2 / 7", - status: "свежий", - statusColor: tokens.success, - params: "1 комн · 38 м²", - }, - { - id: "fixture-4", - addr: "ул. 8 Марта, 120", - time: "09:12:40", - src: "3 / 7", - status: "устаревает", - statusColor: tokens.warn, - params: "студия · 24 м² · 3/5 эт.", - }, - { - id: "fixture-5", - addr: "пр. Ленина, 5", - time: "вчера 18:04", - src: "5 / 7", - status: "устарел", - statusColor: tokens.danger, - params: "2 комн · 61,5 м²", - }, - { - id: "fixture-6", - addr: "ул. Белинского, 86", - time: "вчера 14:22", - src: "2 / 7", - status: "устарел", - statusColor: tokens.danger, - params: "4 комн · 95 м² · 7/10 эт.", - }, -]; - -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", - ], -}; - -// Note: the pre-wiring `locationFactors` fixture (fabricated 0.87 coefficient -// + base/coef/result formula + positives/negatives) lived here — removed -// together with the location-index rewrite. LocationDrawer now reads -// `LocationData` produced by `mapLocation` (./mappers.ts) from the real -// GET /trade-in/location-index response; its own default/unwired fixture is -// LOCATION_FIXTURE in ./LocationDrawer.tsx. - -// ---- 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"; diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index 81e9ff94..a2bde80b 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -1,11 +1,13 @@ // Pure API -> presentation mappers for the /trade-in/v2 "МЕРА Оценка" HUD port. // // These functions take the real trade-in API objects (AggregatedEstimate, -// StreetDealsResponse, HouseAnalyticsResponse) and produce the EXACT fixture -// shapes the v2 components already consume (see ./types + ./fixtures). The Wire -// phase swaps each component's hardcoded fixture for a `data` prop fed by one of -// these mappers; the fixtures stay as the prop DEFAULT so unwired usage still -// renders. ALL money/format/geometry lives here — never copy fixture literals. +// StreetDealsResponse, HouseAnalyticsResponse) and produce the display shapes +// the v2 components consume (see ./types). Every component's `data` prop is +// REQUIRED and fed by one of these mappers — since #2747 there is no fixture +// default to fall back on, and since #2755 there are no data fixtures at all +// (./ui-config keeps only labels/options/version). ALL money/format/geometry +// lives here; a value the API did not supply is rendered as a dash, never +// substituted with a plausible-looking constant. // // Reconciliations / known FE-approximations (look for "TODO BE-N"): // BE-1 #2043 shipped 2026-07-02: AggregatedEstimate now carries real @@ -1294,9 +1296,9 @@ export function mapSummary( // // Three pure mappers feeding the SectionOverlay views. Each returns a composite // presentation shape whose fields keep the exact ./types contract the views -// already render, so the Wire phase only swaps each view's fixture import for a -// `data` prop (fixture stays the DEFAULT). All number/format/geometry recomputed -// here from the real API objects — never copied from fixtures. Graceful nulls +// render. The `data` prop is required — the views had fixture imports until the +// Wire phase and fixture DEFAULTS until #2747; both are gone. All number/format/ +// geometry is recomputed here from the real API objects. Graceful nulls // degrade to "—" / empty so a pending/errored sub-hook never blanks an overlay. // // NB on table number formats (faithful to the pixel design, NOT app-wide fmtPpm/ @@ -1966,26 +1968,31 @@ export function mapAnalytics( // ── 05 РЫНОК · АНАЛОГИ И СДЕЛКИ (SourcesView) ─────────────────────────────── // Real per-row source/distance/url + tier/date carried alongside the existing -// display fields. Optional so the design fixtures (AdRow[]/DealRow[]) stay valid -// as the component DEFAULT; the mapper always populates them. +// display fields. REQUIRED: mapSources is the only producer of these rows and +// always populates every field. They used to be optional so the deleted design +// fixtures (AdRow[]/DealRow[]) would typecheck as the component DEFAULT — and +// that optionality is what let SourcesView keep unreachable `?? "Avito"` / +// `?? "Росреестр"` / "Росреестр ↗" fallbacks that looked like real data (#2755). +// Keep them required: an absent value must be a TS error here, not an invented +// brand name on screen. export interface AdRowData extends AdRow { - /** Source brand label for the badge (was hardcoded "Avito"). */ - source?: string; - /** Distance to the subject (was hardcoded "—"). */ - dist?: string; - /** Original listing URL (was a hardcoded "Росреестр ↗" link). */ - url?: string | null; + /** Source brand label for the badge. */ + source: string; + /** Distance to the subject; "—" when unknown. */ + dist: string; + /** Original listing URL; null when the row has no link. */ + url: string | null; /** Fix #8 — ₽/м² > OUTLIER_MULT× the pool median: excluded from spread/CV, * flagged with a "возможный выброс" badge instead of silently distorting it. */ - outlier?: boolean; + outlier: boolean; } export interface DealRowData extends DealRow { - /** Source brand label for the badge (was hardcoded "Росреестр"). */ - source?: string; - /** Tier caption next to the badge (was hardcoded "по улице"). */ - tier?: string; - /** Deal/listing date (was hardcoded "01.01.26"). */ - date?: string; + /** Source brand label for the badge. */ + source: string; + /** Tier caption next to the badge. */ + tier: string; + /** Deal/listing date; "—" when unknown. */ + date: string; } export interface SourcesData { diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts index 81ca7422..eb682c4c 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts @@ -1,5 +1,7 @@ -// TypeScript shapes for the /trade-in/v2 design fixtures. -// All values are sample/markup data (see fixtures.ts) — no API types here. +// Display shapes rendered by the /trade-in/v2 components. Not API types — the +// wire contract lives in @/types/trade-in and is converted here by ./mappers. +// Originally these described the pixel-design fixtures; the fixtures are gone +// (#2755), the shapes stayed because the mappers produce them. export interface Report { id: string; @@ -314,12 +316,7 @@ export interface DropdownOptions { // (see ./mappers.ts), which reflects the real backend contract, not the old // fabricated formula. -// ---- USER / CHROME -------------------------------------------------------- - -export interface User { - name: string; - org: string; - email: string; - initials: string; - reports: number; -} +// Note: the `User` shape («Андрей Петров» / «Брусника» / reports: 128) lived +// here for the fixture of the same name — both deleted in #2755. The real +// logged-in identity is `TopNavUser` in ./TopNav.tsx, fed from useMe(); a +// missing user renders the neutral «Гость», never a fabricated person. diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ui-config.ts b/tradein-mvp/frontend/src/components/trade-in/v2/ui-config.ts new file mode 100644 index 00000000..7e955710 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ui-config.ts @@ -0,0 +1,62 @@ +// Static UI chrome for the /trade-in/v2 "МЕРА Оценка" port: nav/overlay labels, +// select options, build version. Constants only — no numbers that could be +// mistaken for measurements. +// +// This file used to be `fixtures.ts` and carried ~600 lines of made-up prices, +// deal rows and analytics lifted from the pixel design. Those were the prop +// DEFAULTS until #2747 made every data prop required; #2755 deleted them. All +// displayed data now comes from the API through ./mappers — if a value is +// missing the UI shows an honest dash, never a plausible-looking number. + +import type { DropdownOptions } from "./types"; + +// ---- INPUTS / DROPDOWNS --------------------------------------------------- + +export const dropdownOptions: DropdownOptions = { + rooms: ["1", "2", "3", "4", "5+"], + houseType: [ + "Не указано", + "Панельный", + "Кирпичный", + "Монолитный", + "Блочный", + "Монолитно-кирпичный", + ], + repair: [ + "Не указано", + "Без отделки", + "Требует ремонта", + "Удовлетворительный", + "Хороший", + "Отличный", + ], + radius: ["300 м", "500 м", "1000 м", "2000 м"], + crm: [ + "Не заполнять", + "Создать сделку", + "Привязать к контакту", + "Экспорт в CRM", + ], +}; + +// ---- NAV / CHROME --------------------------------------------------------- + +export const navLabels: string[] = [ + "ОЦЕНКА", + "ПРОДАЖИ В ДОМЕ", + "АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ", + "АНАЛИТИКА", + "ПРЕДЫДУЩИЕ ОЦЕНКИ", +]; + +export const overlayTitles: string[] = [ + "", + "ПРОДАЖИ В ДОМЕ", + "АНАЛОГИЧНЫЕ ОБЪЯВЛЕНИЯ", + "АНАЛИТИКА ДОМА", + "ПРЕДЫДУЩИЕ ОЦЕНКИ", +]; + +export const overlayNums: string[] = ["", "04", "05", "06", "07"]; + +export const version = "v2.0.6"; diff --git a/tradein-mvp/frontend/src/lib/city-registry.ts b/tradein-mvp/frontend/src/lib/city-registry.ts index 16178d6c..bc07b8ab 100644 --- a/tradein-mvp/frontend/src/lib/city-registry.ts +++ b/tradein-mvp/frontend/src/lib/city-registry.ts @@ -79,7 +79,7 @@ export const DEFAULT_CITY: OblastCity = OBLAST_CITIES[0]; */ export const UNCONFIRMED_CITY_LABEL = "Автоопределение"; -/** RU-лейблы для дропдауна — тот же формат (string[]), что dropdownOptions.* в v2/fixtures.ts. */ +/** RU-лейблы для дропдауна — тот же формат (string[]), что dropdownOptions.* в v2/ui-config.ts. */ export const CITY_LABELS: string[] = OBLAST_CITIES.map((c) => c.label); /**