From 72d2b97ee08f12c70c2a3cfce4352d47ce4e8513 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 23 May 2026 13:28:23 +0000 Subject: [PATCH] =?UTF-8?q?feat(tradein):=20Stage=204b=20=E2=80=94=20IMV?= =?UTF-8?q?=20benchmark=20badge=20+=20house=20info=20card=20on=20estimate?= =?UTF-8?q?=20result=20(#461)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/frontend/src/app/page.tsx | 16 ++ .../src/components/trade-in/HouseInfoCard.tsx | 117 ++++++++++++++ .../src/components/trade-in/IMVBenchmark.tsx | 90 +++++++++++ .../src/components/trade-in/trade-in.css | 143 ++++++++++++++++++ tradein-mvp/frontend/src/lib/trade-in-api.ts | 30 ++++ tradein-mvp/frontend/src/types/trade-in.ts | 36 +++++ 6 files changed, 432 insertions(+) create mode 100644 tradein-mvp/frontend/src/components/trade-in/HouseInfoCard.tsx create mode 100644 tradein-mvp/frontend/src/components/trade-in/IMVBenchmark.tsx diff --git a/tradein-mvp/frontend/src/app/page.tsx b/tradein-mvp/frontend/src/app/page.tsx index cf2473f8..70566f91 100644 --- a/tradein-mvp/frontend/src/app/page.tsx +++ b/tradein-mvp/frontend/src/app/page.tsx @@ -15,11 +15,14 @@ import { API_BASE_URL } from "@/lib/api"; import { EstimateForm } from "@/components/trade-in/EstimateForm"; import { SourcesProgress } from "@/components/trade-in/SourcesProgress"; import { HeroSummary } from "@/components/trade-in/HeroSummary"; +import { IMVBenchmark } from "@/components/trade-in/IMVBenchmark"; +import { HouseInfoCard } from "@/components/trade-in/HouseInfoCard"; import { PhotoUpload } from "@/components/trade-in/PhotoUpload"; import { ListingsCard } from "@/components/trade-in/ListingsCard"; import { DealsCard } from "@/components/trade-in/DealsCard"; import { OfferCard } from "@/components/trade-in/OfferCard"; import { TestPresets } from "@/components/trade-in/TestPresets"; +import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api"; function useEstimateId() { if (typeof window === "undefined") return null; @@ -41,6 +44,11 @@ export default function TradeInPage() { const mutation = useEstimateMutation(); + const currentEstimateId = + freshResult?.estimate.estimate_id ?? urlEstimateId; + const imvBenchmark = useEstimateImvBenchmark(currentEstimateId); + const houses = useEstimateHouses(currentEstimateId); + function handleSubmit(input: TradeInEstimateInput) { mutation.mutate(input, { onSuccess: (estimate) => { @@ -157,6 +165,14 @@ export default function TradeInPage() { {resultData ? ( <> + + diff --git a/tradein-mvp/frontend/src/components/trade-in/HouseInfoCard.tsx b/tradein-mvp/frontend/src/components/trade-in/HouseInfoCard.tsx new file mode 100644 index 00000000..85bf0cf7 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/HouseInfoCard.tsx @@ -0,0 +1,117 @@ +"use client"; + +import type { HouseInfoForEstimate } from "@/types/trade-in"; + +interface Props { + houses: HouseInfoForEstimate[] | undefined; + isLoading: boolean; +} + +function formatHouseType(t: string | null): string { + switch (t) { + case "monolith": + case "monolithic": + return "монолит"; + case "panel": + return "панель"; + case "brick": + return "кирпич"; + case "monolith_brick": + return "монолит-кирпич"; + case "block": + return "блочный"; + case "wood": + return "дерево"; + default: + return t ?? "—"; + } +} + +export function HouseInfoCard({ houses, isLoading }: Props) { + if (isLoading) { + return ( +
+

Дом и инфраструктура

+

Загрузка…

+
+ ); + } + + if (!houses || houses.length === 0) { + return null; + } + + // Show ближайший дом (первый в массиве — sorted by distance) + const h = houses[0]; + + const features: Array<{ label: string; value: string }> = []; + if (h.year_built != null) + features.push({ label: "Год постройки", value: String(h.year_built) }); + if (h.total_floors != null) + features.push({ label: "Этажей", value: String(h.total_floors) }); + if (h.house_type) + features.push({ label: "Тип дома", value: formatHouseType(h.house_type) }); + if (h.passenger_elevators != null) + features.push({ + label: "Пассажирский лифт", + value: String(h.passenger_elevators), + }); + if (h.cargo_elevators != null) + features.push({ + label: "Грузовой лифт", + value: String(h.cargo_elevators), + }); + if (h.has_concierge != null) + features.push({ label: "Консьерж", value: h.has_concierge ? "да" : "нет" }); + if (h.closed_yard != null) + features.push({ + label: "Закрытая территория", + value: h.closed_yard ? "да" : "нет", + }); + if (h.has_playground != null) + features.push({ + label: "Детская площадка", + value: h.has_playground ? "да" : "нет", + }); + if (h.parking_type) + features.push({ label: "Парковка", value: h.parking_type }); + if (h.developer_name) + features.push({ label: "Застройщик", value: h.developer_name }); + + return ( +
+
+

Дом

+ {h.short_address && ( +

{h.short_address}

+ )} +
+ {h.rating != null && ( +
+ {h.rating.toFixed(1)} + + {h.reviews_count != null + ? `${h.reviews_count} отзывов` + : "рейтинг"} + +
+ )} + {features.length > 0 && ( +
+ {features.map((f) => ( +
+
{f.label}
+
{f.value}
+
+ ))} +
+ )} + {houses.length > 1 && ( +
+ + {houses.length - 1}{" "} + {houses.length - 1 === 1 ? "соседний дом" : "соседних домов"} +
+ )} +
+ ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/IMVBenchmark.tsx b/tradein-mvp/frontend/src/components/trade-in/IMVBenchmark.tsx new file mode 100644 index 00000000..f032a98b --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/IMVBenchmark.tsx @@ -0,0 +1,90 @@ +"use client"; + +import type { IMVBenchmarkResponse } from "@/types/trade-in"; + +interface Props { + benchmark: IMVBenchmarkResponse | undefined; + isLoading: boolean; +} + +function formatRub(n: number | null | undefined): string { + if (n == null) return "—"; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)} млн ₽`; + return new Intl.NumberFormat("ru-RU").format(n) + " ₽"; +} + +export function IMVBenchmark({ benchmark, isLoading }: Props) { + if (isLoading) { + return ( +
+ Avito IMV + загрузка… +
+ ); + } + + if (!benchmark || !benchmark.available) { + return null; // нет данных от Avito — не показываем + } + + const { + recommended_price, + lower_price, + higher_price, + market_count, + our_median_price, + diff_pct, + } = benchmark; + + const diffLabel = + diff_pct == null + ? null + : diff_pct > 0 + ? `выше Avito на ${diff_pct.toFixed(1)}%` + : diff_pct < 0 + ? `ниже Avito на ${Math.abs(diff_pct).toFixed(1)}%` + : "совпадает с Avito"; + + return ( +
+
+ Avito IMV + + источник: avito.ru/evaluation/realty + +
+
+
+
Avito рекомендует
+
+ {formatRub(recommended_price)} +
+
+
+
Диапазон
+
+ {formatRub(lower_price)} – {formatRub(higher_price)} +
+
+
+
Аналогов в рынке
+
+ {market_count != null + ? new Intl.NumberFormat("ru-RU").format(market_count) + : "—"} +
+
+
+ {our_median_price != null && diffLabel && ( +
+ Наша оценка: {formatRub(our_median_price)} ({diffLabel}) +
+ )} +
+ ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/trade-in.css b/tradein-mvp/frontend/src/components/trade-in/trade-in.css index 1cfb9017..cb1092fb 100644 --- a/tradein-mvp/frontend/src/components/trade-in/trade-in.css +++ b/tradein-mvp/frontend/src/components/trade-in/trade-in.css @@ -1096,3 +1096,146 @@ .card-head { flex-direction: column; align-items: flex-start; } .card-head .card-meta { text-align: left; } } + +/* ── Stage 4b: IMV benchmark + house info card ── */ + +.imv-benchmark { + margin: 16px 0; + padding: 16px; + border: 1px solid #e5e7eb; + border-radius: 12px; + background: #fafbfc; +} + +.imv-benchmark--loading { + display: flex; + gap: 8px; + align-items: center; + color: #6b7280; +} + +.imv-benchmark__head { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 12px; +} + +.imv-benchmark__title { + font-weight: 600; + font-size: 14px; +} + +.imv-benchmark__source { + font-size: 11px; + color: #6b7280; + text-decoration: none; +} + +.imv-benchmark__source:hover { + text-decoration: underline; +} + +.imv-benchmark__row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 16px; +} + +.imv-benchmark__metric-label { + font-size: 12px; + color: #6b7280; + margin-bottom: 2px; +} + +.imv-benchmark__metric-value { + font-size: 16px; + font-weight: 600; + color: #111827; +} + +.imv-benchmark__compare { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #e5e7eb; + font-size: 13px; + color: #374151; +} + +/* House info card */ + +.house-info-card { + margin: 16px 0; + padding: 16px; + border: 1px solid #e5e7eb; + border-radius: 12px; + background: #fff; +} + +.house-info-card__head { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 12px; +} + +.house-info-card__title { + font-size: 16px; + font-weight: 600; + margin: 0; +} + +.house-info-card__addr { + font-size: 13px; + color: #6b7280; + margin: 0; +} + +.house-info-card__rating { + display: flex; + gap: 8px; + align-items: baseline; + margin-bottom: 12px; +} + +.house-info-card__rating strong { + font-size: 24px; + color: #f59e0b; +} + +.house-info-card__rating-label { + font-size: 12px; + color: #6b7280; +} + +.house-info-card__features { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 8px 16px; + margin: 0; +} + +.house-info-card__feature { + display: flex; + justify-content: space-between; + font-size: 13px; + padding: 4px 0; + border-bottom: 1px dotted #e5e7eb; +} + +.house-info-card__feature dt { + color: #6b7280; +} + +.house-info-card__feature dd { + margin: 0; + font-weight: 500; + color: #111827; +} + +.house-info-card__more { + margin-top: 12px; + font-size: 12px; + color: #6b7280; + font-style: italic; +} diff --git a/tradein-mvp/frontend/src/lib/trade-in-api.ts b/tradein-mvp/frontend/src/lib/trade-in-api.ts index 88c3138c..58d7e087 100644 --- a/tradein-mvp/frontend/src/lib/trade-in-api.ts +++ b/tradein-mvp/frontend/src/lib/trade-in-api.ts @@ -5,6 +5,8 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { apiFetch } from "./api"; import type { AggregatedEstimate, + HouseInfoForEstimate, + IMVBenchmarkResponse, TradeInEstimateInput, } from "@/types/trade-in"; @@ -37,3 +39,31 @@ export function useEstimate(estimate_id: string | null) { staleTime: 10 * 60_000, // 10 min — estimates expire at expires_at anyway }); } + +/** + * GET /api/v1/trade-in/estimate/{id}/houses + * Houses в радиусе 500m от target адреса estimate (Stage 2c data). + */ +export function useEstimateHouses(estimate_id: string | null) { + return useQuery({ + queryKey: ["trade-in", "estimate", estimate_id, "houses"], + queryFn: () => + apiFetch(`${BASE}/estimate/${estimate_id}/houses`), + enabled: estimate_id !== null && estimate_id.length > 0, + staleTime: 10 * 60_000, + }); +} + +/** + * GET /api/v1/trade-in/estimate/{id}/imv-benchmark + * Avito IMV benchmark для UI badge (Stage 3 IMV cache lookup). + */ +export function useEstimateImvBenchmark(estimate_id: string | null) { + return useQuery({ + queryKey: ["trade-in", "estimate", estimate_id, "imv-benchmark"], + queryFn: () => + apiFetch(`${BASE}/estimate/${estimate_id}/imv-benchmark`), + enabled: estimate_id !== null && estimate_id.length > 0, + staleTime: 10 * 60_000, + }); +} diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 77ddcb36..60cbfd89 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -76,3 +76,39 @@ export interface AggregatedEstimate { repair_state: RepairState | null; has_balcony: boolean | null; } + +// ── Stage 4a/4b response types ── + +export interface HouseInfoForEstimate { + house_id: number | null; + ext_house_id: string | null; + address: string | null; + short_address: string | null; + lat: number | null; + lon: number | null; + year_built: number | null; + total_floors: number | null; + house_type: string | null; + passenger_elevators: number | null; + cargo_elevators: number | null; + has_concierge: boolean | null; + closed_yard: boolean | null; + has_playground: boolean | null; + parking_type: string | null; + developer_name: string | null; + rating: number | null; + reviews_count: number | null; + raw_characteristics: Array>; +} + +export interface IMVBenchmarkResponse { + available: boolean; + cache_key: string | null; + recommended_price: number | null; + lower_price: number | null; + higher_price: number | null; + market_count: number | null; + fetched_at: string | null; + our_median_price: number | null; + diff_pct: number | null; +}