From 92d00ec8746fba2e046c77022b92d2ed35506a92 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 24 May 2026 12:58:39 +0000 Subject: [PATCH] feat(tradein-ui): mock-disclaimer + progressive enrichment + UX polish (#517) --- tradein-mvp/frontend/src/app/page.tsx | 15 ++- .../src/components/trade-in/DealsCard.tsx | 16 +-- .../src/components/trade-in/HeroSummary.tsx | 123 +++++++++++++++++- 3 files changed, 135 insertions(+), 19 deletions(-) diff --git a/tradein-mvp/frontend/src/app/page.tsx b/tradein-mvp/frontend/src/app/page.tsx index aacfbdd1..99afcf14 100644 --- a/tradein-mvp/frontend/src/app/page.tsx +++ b/tradein-mvp/frontend/src/app/page.tsx @@ -9,7 +9,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import "@/components/trade-in/trade-in.css"; -import type { AggregatedEstimate, TradeInEstimateInput } from "@/types/trade-in"; +import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState } from "@/types/trade-in"; import { useEstimateMutation, useEstimate } from "@/lib/trade-in-api"; import { EstimateForm } from "@/components/trade-in/EstimateForm"; import { Topbar } from "@/components/trade-in/Topbar"; @@ -75,6 +75,12 @@ export default function TradeInPage() { } : null); + function handleResubmit(patch: { house_type?: HouseType; repair_state?: RepairState }) { + if (!resultData) return; + const enrichedInput: TradeInEstimateInput = { ...resultData.input, ...patch }; + handleSubmit(enrichedInput); + } + const isPending = mutation.isPending; return ( @@ -149,7 +155,12 @@ export default function TradeInPage() { {resultData ? ( <> - + -
-
-
Секция 3 · Сделки
-

Фактические сделки по аналогичным квартирам

-
-
-
-

- Нет фактических сделок в радиусе 2 км за 12 месяцев. -

-
- - ); + return null; } // Сортируем по дате DESC, берем медиану и range diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index 1126e8f4..812a6e06 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -6,11 +6,14 @@ * HeroSummary — Секция 1 «Сводка» из mockup tradein.html. * Показывает медиану + достоверность CV + параметры объекта + 2 ценовых бара (объявления / сделки). */ -import type { AggregatedEstimate, TradeInEstimateInput } from "@/types/trade-in"; +import { useState } from "react"; +import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState } from "@/types/trade-in"; interface Props { estimate: AggregatedEstimate; input: TradeInEstimateInput; + onResubmit: (patch: { house_type?: HouseType; repair_state?: RepairState }) => void; + isResubmitting?: boolean; } const HOUSE_TYPE_LABELS: Record = { @@ -45,12 +48,30 @@ function calcCv(estimate: AggregatedEstimate): number { return ((estimate.range_high_rub - estimate.range_low_rub) / m) * 100; } -export function HeroSummary({ estimate, input }: Props) { +/** Returns true when address looks like a non-residential geocode fallback */ +function isMockAddress(estimate: AggregatedEstimate): boolean { + const addr = estimate.target_address ?? ""; + if (/^(Склад|Гараж|Подсобка|Нежилое),\s/i.test(addr)) return true; + if (estimate.confidence_explanation?.startsWith("address_not_geocoded")) return true; + return false; +} + +export function HeroSummary({ estimate, input, onResubmit, isResubmitting = false }: Props) { const cv = calcCv(estimate); const conf = CONF_LABELS[estimate.confidence] ?? CONF_LABELS.low; const m = estimate.median_price_rub; const lo = estimate.range_low_rub; const hi = estimate.range_high_rub; + + const showMockDisclaimer = isMockAddress(estimate); + + // Progressive enrichment state + const needsHouseType = estimate.house_type === null; + const needsRepairState = estimate.repair_state === null; + const showEnrichment = + (needsHouseType || needsRepairState) && estimate.confidence !== "low"; + const [enrichHouseType, setEnrichHouseType] = useState(""); + const [enrichRepairState, setEnrichRepairState] = useState(""); // Фото первого аналога с картинкой — вместо пустого серого плейсхолдера. const heroPhoto = estimate.analogs.find((a) => a.photo_url)?.photo_url ?? null; // Расчёт ширины для price bar (50% = середина): медиана внутри min/max @@ -68,9 +89,37 @@ export function HeroSummary({ estimate, input }: Props) { const houseType = estimate.house_type ?? input.house_type; const repairState = estimate.repair_state ?? input.repair_state; const hasBalcony = estimate.has_balcony ?? input.has_balcony; + const estDaysOnMarket = estimate.est_days_on_market; + + function handleEnrichSubmit(e: React.FormEvent) { + e.preventDefault(); + const patch: { house_type?: HouseType; repair_state?: RepairState } = {}; + if (enrichHouseType) patch.house_type = enrichHouseType as HouseType; + if (enrichRepairState) patch.repair_state = enrichRepairState as RepairState; + onResubmit(patch); + } return (
+ {showMockDisclaimer && ( +
+ ⚠️ Адрес распознан неточно — оценка может быть приближённой. + Уточните адрес и пересчитайте для более точного результата. +
+ )}
Секция 1 · Сводка
@@ -161,6 +210,14 @@ export function HeroSummary({ estimate, input }: Props) { Аналогов {estimate.n_analogs}
+
+ Срок продажи + {estDaysOnMarket !== null ? ( + {estDaysOnMarket} дн. + ) : ( + нет данных + )} +
@@ -277,6 +334,68 @@ export function HeroSummary({ estimate, input }: Props) { )} + + {showEnrichment && ( +
+

+ Уточните для повышения точности оценки: +

+
+
+ {needsHouseType && ( + + )} + {needsRepairState && ( + + )} +
+ +
+
+ )}
); }