diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx index d1001fb3..b6255a2a 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/AnalyticsView.tsx @@ -244,30 +244,36 @@ export default function AnalyticsView({
- + {avitoPts.length > 0 ? ( - Avito - - - - Яндекс - + style={{ + display: "flex", + alignItems: "center", + gap: 6, + color: tokens.muted, + }} + > + + Avito + + ) : null} + {yandexPts.length > 0 ? ( + + + Яндекс + + ) : null}
- - 200k - - - 150k - - - 100k - - - 50k - - - 0k - + {data.priceHistory.yTicks.map((t) => ( + + {t.label} + + ))} {data.priceHistory.years.map((yr, i) => ( @@ -395,8 +391,7 @@ export default function AnalyticsView({ Цена × срок продажи
- Зависимость цены от срока экспозиции ·{" "} - {data.scatterDetail.note} + Зависимость цены от срока экспозиции · {data.scatterDetail.note}
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx index c6f6f0fe..4453c25e 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx @@ -261,7 +261,20 @@ export default function HeroBar({ height: "100%", objectFit: "contain", objectPosition: "right center", - filter: "saturate(.9) brightness(1.04)", + filter: "saturate(.25) brightness(1.06) contrast(.95)", + }} + /> + )} + {/* blue duotone tint toward HUD accent — recedes the photo (only over + the real image; skip when it 404s so the placeholder stays clean) */} + {!imgFailed && ( +
)} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/LocationDrawer.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/LocationDrawer.tsx index 104acb33..fb10b365 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/LocationDrawer.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/LocationDrawer.tsx @@ -200,9 +200,9 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) { ₽/м² → 3 оценки:{" "} рекомендованная цена в объявлении - {" "} - (asking), ожидаемая цена сделки{" "} - (asking − торг по ДКП),{" "} + + , ожидаемая цена сделки (с + учётом торга по ДКП),{" "} ДКП·Росреестр (фактические сделки). CV — коэффициент вариации выборки (разброс цен).
@@ -230,7 +230,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) { padding: "12px 14px", }} > - Коэффициент локации (POI, транспорт, шум) —{" "} + Коэффициент локации (инфраструктура, транспорт, шум) —{" "} в разработке; текущая оценка по локации не корректируется.
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx index 5f09f651..917baa2a 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx @@ -209,7 +209,7 @@ export function ObjectSummary({ > {row.value} - + ))} 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 73042a75..203d0aef 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx @@ -325,11 +325,11 @@ function initRoomsLabel(rooms: number | null | undefined): string { } function initHouseTypeLabel(ht: HouseType | undefined): string { - return ht ? HOUSE_TYPE_RU[ht] : "Панельный"; + return ht ? HOUSE_TYPE_RU[ht] : "Не указано"; } function initRepairLabel(rs: RepairState | undefined): string { - return rs ? REPAIR_RU[rs] : "Хороший"; + return rs ? REPAIR_RU[rs] : "Не указано"; } // РАДИУС options. "Авто" (default) sends no radius_m → the backend keeps its @@ -383,8 +383,13 @@ export default function ParamsPanel({ const [radius, setRadius] = useState( initRadiusLabel(initialValues?.radius_m), ); - const [balcony, setBalcony] = useState(initialValues?.has_balcony ?? true); - const [validationError, setValidationError] = useState(null); + const [balcony, setBalcony] = useState( + initialValues?.has_balcony ?? null, + ); + const [fieldErrors, setFieldErrors] = useState<{ + address?: string; + area?: string; + }>({}); // ── Address autocomplete (geocode suggest, ЕКБ viewbox) ── // The typed string is debounced into `addressQuery` from inside the change @@ -407,6 +412,8 @@ export default function ParamsPanel({ setAddress(v); setCoords(null); // a manual edit invalidates a previously picked point setSuggestOpen(v.trim().length >= 3); + if (fieldErrors.address) + setFieldErrors((prev) => ({ ...prev, address: undefined })); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => setAddressQuery(v), 200); }; @@ -432,19 +439,22 @@ export default function ParamsPanel({ const handleSubmit = () => { const trimmedAddress = address.trim(); const areaNum = Number(area.replace(",", ".")); + // Collect ALL blockers (don't early-return) so every offending field shows + // its own message at once. + const errs: { address?: string; area?: string } = {}; if (trimmedAddress.length < 3) { - setValidationError("Укажите адрес квартиры — минимум 3 символа"); - return; + errs.address = "Укажите адрес квартиры — минимум 3 символа"; } if (!area.trim()) { - setValidationError("Укажите площадь квартиры"); + errs.area = "Укажите площадь квартиры"; + } else if (!Number.isFinite(areaNum) || areaNum <= 10) { + errs.area = "Площадь должна быть больше 10 м²"; + } + if (Object.keys(errs).length > 0) { + setFieldErrors(errs); return; } - if (!Number.isFinite(areaNum) || areaNum <= 10) { - setValidationError("Площадь должна быть больше 10 м²"); - return; - } - setValidationError(null); + setFieldErrors({}); setSuggestOpen(false); onSubmit?.({ address: trimmedAddress, @@ -455,7 +465,7 @@ export default function ParamsPanel({ year_built: year.trim() ? Number(year) : undefined, house_type: HOUSE_TYPE_FROM_RU[houseType], repair_state: REPAIR_FROM_RU[repair], - has_balcony: balcony, + has_balcony: balcony ?? undefined, lat: coords?.lat ?? null, lon: coords?.lon ?? null, // "Авто" → null → backend keeps its two-tier default (1000 m primary / @@ -874,7 +884,11 @@ export default function ParamsPanel({ }} placeholder="Город, улица, дом" autoComplete="off" - style={addressField} + style={ + fieldErrors.address + ? { ...addressField, border: `1px solid ${tokens.danger}` } + : addressField + } /> )} + {fieldErrors.address && ( +
+ {fieldErrors.address} +
+ )} @@ -933,13 +952,26 @@ export default function ParamsPanel({ type="text" inputMode="decimal" value={area} - onChange={(e) => setArea(e.target.value.replace(/[^\d.,]/g, ""))} + onChange={(e) => { + setArea(e.target.value.replace(/[^\d.,]/g, "")); + if (fieldErrors.area) + setFieldErrors((prev) => ({ ...prev, area: undefined })); + }} onKeyDown={(e) => { if (e.key === "Enter") handleSubmit(); }} placeholder="напр. 54" - style={inputField} + style={ + fieldErrors.area + ? { ...inputField, border: `1px solid ${tokens.danger}` } + : inputField + } /> + {fieldErrors.area && ( +
+ {fieldErrors.area} +
+ )}
КОМНАТ
@@ -960,7 +992,7 @@ export default function ParamsPanel({
ЭТАЖ - если знаешь + если знаете
ВСЕГО ЭТАЖЕЙ - если знаешь + если знаете
- 1-й и последний этаж снижают цену на 5–10% — заполни если знаешь + 1-й и последний этаж снижают цену на 5–10% — заполните, если знаете
@@ -1067,13 +1099,13 @@ export default function ParamsPanel({
setBalcony(false)} - style={{ ...balBase, ...(balcony ? balOff : balOn) }} + style={{ ...balBase, ...(balcony === false ? balOn : balOff) }} > НЕТ
setBalcony(true)} - style={{ ...balBase, ...(balcony ? balOn : balOff) }} + style={{ ...balBase, ...(balcony === true ? balOn : balOff) }} > ДА
@@ -1152,10 +1184,11 @@ export default function ParamsPanel({ - {/* inline error (validation or server) — only when present */} - {(validationError || error) && ( + {/* inline SERVER error — only when present (field-level validation is + surfaced at each field above). */} + {error && (
- {validationError ?? error} + {error}
)} @@ -1172,7 +1205,7 @@ export default function ParamsPanel({ color: tokens.muted2, }} > - КЭШ ПО АДРЕСУ — 24 Ч + ДАННЫЕ АКТУАЛЬНЫ - ГОТОВ + ДЕЙСТВИТЕЛЕН 24 Ч
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx index a5777628..227d691c 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx @@ -13,8 +13,6 @@ import type { ResultPanelData } from "./mappers"; const { accent, - onAccent, - accentGlow, ink, muted, muted2, @@ -194,7 +192,7 @@ export default function ResultPanel({ style={{ fontFamily: font.mono, fontSize: 38, - fontWeight: 300, + fontWeight: 400, letterSpacing: -1, }} > @@ -455,12 +453,10 @@ export default function ResultPanel({ left: data.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}`, + width: 3, + height: 16, + borderRadius: 1, + background: accent, }} />
@@ -704,12 +700,10 @@ export default function ResultPanel({ left: data.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}`, + width: 3, + height: 16, + borderRadius: 1, + background: accent, }} /> 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 618ea259..c48b608e 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx @@ -16,8 +16,8 @@ import type { SourcesData } from "./mappers"; const KPI_BG = "rgba(238,244,250,.5)"; const BADGE_BG = "#eaf1f8"; -const AD_GRID = "2.4fr 1fr 1fr 1.2fr .7fr 1fr"; -const DEAL_GRID = "2.6fr 1.2fr 1fr 1.2fr 1fr"; +const AD_GRID = "1.9fr 1.3fr 1fr 1fr 1.1fr .6fr .9fr"; +const DEAL_GRID = "2fr 1.3fr 1.2fr 1fr 1.1fr 1fr"; const card: CSSProperties = { background: tokens.surface.w55, @@ -267,6 +267,7 @@ export default function SourcesView({ }} > АДРЕС + ПАРАМЕТРЫ ИСТОЧНИК ₽/М² СТОИМОСТЬ @@ -290,13 +291,8 @@ export default function SourcesView({ gridTemplateColumns: AD_GRID, }} > - - {r.addr} - - {" · "} - {r.meta} - - + {r.addr} + {r.meta} {r.source ?? "Avito"} @@ -370,6 +366,7 @@ export default function SourcesView({ }} > АДРЕС + ПАРАМЕТРЫ ИСТОЧНИК ₽/М² ЦЕНА СДЕЛКИ @@ -386,13 +383,8 @@ export default function SourcesView({ gridTemplateColumns: DEAL_GRID, }} > - - {r.addr} - - {" · "} - {r.meta} - - + {r.addr} + {r.meta} {r.source ?? "Росреестр"}{" "} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts b/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts index 3f310437..fabe7f35 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts @@ -479,6 +479,13 @@ export const analytics: Analytics = { 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: 65 }, + { label: "100k", y: 110 }, + { label: "50k", y: 155 }, + { label: "0k", y: 195 }, + ], }, scatterDetail: { note: "16 сделок и 6 аналогов в радиусе 300 м", @@ -589,6 +596,7 @@ export const cacheKpi: CacheKpi[] = [ export const dropdownOptions: DropdownOptions = { rooms: ["1", "2", "3", "4", "5+"], houseType: [ + "Не указано", "Панельный", "Кирпичный", "Монолитный", @@ -596,6 +604,7 @@ export const dropdownOptions: DropdownOptions = { "Монолитно-кирпичный", ], repair: [ + "Не указано", "Без отделки", "Требует ремонта", "Удовлетворительный", 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 aa0a09db..84d6bf87 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -177,6 +177,28 @@ export function fmtDate(iso: string | null | undefined): string { }); } +const RU_MONTHS = [ + "янв", + "фев", + "мар", + "апр", + "май", + "июн", + "июл", + "авг", + "сен", + "окт", + "ноя", + "дек", +]; +/** ISO -> "янв 2026" (Росреестр deal dates are month-granular; the day is fake). null/invalid -> "—". */ +function fmtMonthYear(iso: string | null | undefined): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "—"; + return `${RU_MONTHS[d.getMonth()]} ${d.getFullYear()}`; +} + /** Same as fmtDate but shifted by `hours` (TODO BE-1 created_at = expires_at−24h). */ function fmtDateShift(iso: string | null | undefined, hours: number): string { if (!iso) return "—"; @@ -446,9 +468,9 @@ function numLabel(v: number | null): string { const SOURCE_SLOTS: ReadonlyArray<{ name: string; keys: string[] }> = [ { name: "ЦИАН", keys: ["cian"] }, { name: "Я.НЕДВИЖИМОСТЬ", keys: ["yandex"] }, - { name: "РОСРЕЕСТР (ВНУТР.)", keys: ["rosreestr"] }, - { name: "АВИТО ОЦЕНКА", keys: ["avito", "avito_imv"] }, - { name: "ДОМКЛИК ПРАЙС", keys: ["domklik", "domclick"] }, + { name: "РОСРЕЕСТР", keys: ["rosreestr"] }, + { name: "АВИТО", keys: ["avito", "avito_imv"] }, + { name: "ДОМКЛИК", keys: ["domklik", "domclick"] }, { name: "RESTATE", keys: ["restate"] }, { name: "N1.RU", keys: ["n1", "n1ru"] }, ]; @@ -1239,7 +1261,7 @@ export function mapHistory( delta: pct1(pr.discount_pct), deltaColor, addr: pr.deal_address ? deglueAddr(pr.deal_address) : undefined, - date: fmtDate(pr.deal_date), + date: fmtMonthYear(pr.deal_date), source: pr.listing_source, }; }); @@ -1329,18 +1351,25 @@ function buildSellTime(s: SellTimeSensitivityResponse | null): SellTimeTier[] { }); } -// Price-history polyline geometry. viewBox 0 0 900 220; y maps 0→195 … 200k→20 -// (the chart's own gridlines), x distributes the years evenly across [58,885] -// (reproduces the 5-year design exactly). Points string is "x,y x,y …" per source. +// Price-history polyline geometry. viewBox 0 0 900 220; the plot band runs +// y=20 (top) … y=195 (bottom) over 175px, x distributes the years evenly across +// [58,885]. The Y domain is DATA-DRIVEN (zoomed to the real median ₽/м² band) +// so a narrow series like 120–160k fills the plot instead of flat-lining against +// a fixed 0–200k scale; the axis labels are emitted as yTicks for the 5 gridlines. const PH_X_LEFT = 58; const PH_X_RIGHT = 885; +const PH_Y_TOP = 20; // top gridline pixel (high value) +const PH_Y_BOTTOM = 195; // bottom gridline pixel (low value) +const PH_Y_SPAN = PH_Y_BOTTOM - PH_Y_TOP; // 175 +const PH_GRID_Y = [20, 65, 110, 155, 195]; // 5 gridlines (top → bottom) +// Both polylines are drawn from these source keys; the Y zoom is computed over +// the union of their finite medians so the two series share one axis. +const PH_SERIES_SOURCES = ["avito_imv", "yandex_valuation"]; + function phYearX(i: number, n: number): number { if (n <= 1) return (PH_X_LEFT + PH_X_RIGHT) / 2; return PH_X_LEFT + ((PH_X_RIGHT - PH_X_LEFT) * i) / (n - 1); } -function phPriceY(ppm: number): number { - return clamp(195 - (ppm / 200000) * 175, 20, 195); -} function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory { const empty: PriceHistory = { @@ -1348,6 +1377,7 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory { years: [], avito: "", yandex: "", + yTicks: [], }; if (!a || a.price_history.length === 0) return empty; const ph = a.price_history; @@ -1358,13 +1388,44 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory { const yearIndex = new Map(yearsNum.map((y, i) => [y, i] as const)); const n = yearsNum.length; + // Data-driven Y zoom: collect every finite median ₽/м² that BOTH series draw + // so the polylines fill the plot band instead of squashing against 0–200k. + const allPpm = ph + .filter( + (p) => + PH_SERIES_SOURCES.includes(p.source) && + Number.isFinite(p.median_price_per_m2), + ) + .map((p) => p.median_price_per_m2); + if (allPpm.length === 0) return empty; + + let yMin = Math.min(...allPpm); + let yMax = Math.max(...allPpm); + if (yMin === yMax) { + yMin = yMin * 0.97; + yMax = yMax * 1.03; + } else { + const pad = (yMax - yMin) * 0.08; + yMin -= pad; + yMax += pad; + } + yMin = Math.max(0, yMin); + const span = yMax - yMin || 1; + + const priceY = (ppm: number): number => + clamp( + PH_Y_BOTTOM - ((ppm - yMin) / span) * PH_Y_SPAN, + PH_Y_TOP, + PH_Y_BOTTOM, + ); + const series = (src: string): string => ph .filter((p) => p.source === src && Number.isFinite(p.median_price_per_m2)) .sort((x, y) => x.year - y.year) .map((p) => { const x = Math.round(phYearX(yearIndex.get(p.year) ?? 0, n)); - const y = Math.round(phPriceY(p.median_price_per_m2)); + const y = Math.round(priceY(p.median_price_per_m2)); return `${x},${y}`; }) .join(" "); @@ -1375,11 +1436,17 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory { ["лот", "лота", "лотов"], )})`; + const yTicks: AxisTickY[] = PH_GRID_Y.map((gy) => { + const v = yMin + ((PH_Y_BOTTOM - gy) / PH_Y_SPAN) * span; + return { label: `${Math.round(v / 1000)}k`, y: gy }; + }); + return { note, years: yearsNum.map(String), avito: series("avito_imv"), yandex: series("yandex_valuation"), + yTicks, }; } @@ -1537,7 +1604,7 @@ export function mapAnalytics( const sellTgt = sellTime?.target_median_price_per_m2 ?? null; const sellTimeNote = `Медиана экспозиции по архивным лотам${ - sellTgt != null ? ` · эталон ${fmtPpm(sellTgt)}` : "" + sellTgt != null ? ` · опорная цена ${fmtPpm(sellTgt)}` : "" }`; return { @@ -1644,7 +1711,7 @@ export function mapSources( price: numRu(l.price_rub), source: sourceLabel(l.source), tier: tierLabel(l.tier), - date: fmtDate(l.listing_date), + date: fmtMonthYear(l.listing_date), })); const marketAds: MarketAds = { 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 e0cb76d9..45a83eac 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts @@ -212,6 +212,8 @@ export interface PriceHistory { /** SVG polyline points string (viewBox 0 0 900 220). */ avito: string; yandex: string; + /** Y-axis tick labels + pixel y, recomputed for the data-driven ₽/м² domain. */ + yTicks: AxisTickY[]; } export interface ScatterDetail {