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 && (
+
+ )}
+
+ );
+}
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 рекомендует
+
+ {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 && (
+
+ )}
+
+ );
+}
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;
+}