feat(tradein): Stage 4b — IMV benchmark badge + house info card on estimate result #461

Merged
lekss361 merged 1 commit from feat/tradein-frontend-stage4b into main 2026-05-23 13:28:24 +00:00
6 changed files with 432 additions and 0 deletions

View file

@ -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 ? (
<>
<HeroSummary estimate={resultData.estimate} input={resultData.input} />
<IMVBenchmark
benchmark={imvBenchmark.data}
isLoading={imvBenchmark.isPending}
/>
<HouseInfoCard
houses={houses.data}
isLoading={houses.isPending}
/>
<PhotoUpload estimateId={resultData.estimate.estimate_id} />
<ListingsCard estimate={resultData.estimate} />
<DealsCard estimate={resultData.estimate} />

View file

@ -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 (
<div className="house-info-card house-info-card--loading">
<h3>Дом и инфраструктура</h3>
<p>Загрузка</p>
</div>
);
}
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 (
<section className="house-info-card" aria-label="Информация о доме">
<header className="house-info-card__head">
<h3 className="house-info-card__title">Дом</h3>
{h.short_address && (
<p className="house-info-card__addr">{h.short_address}</p>
)}
</header>
{h.rating != null && (
<div className="house-info-card__rating">
<strong>{h.rating.toFixed(1)}</strong>
<span className="house-info-card__rating-label">
{h.reviews_count != null
? `${h.reviews_count} отзывов`
: "рейтинг"}
</span>
</div>
)}
{features.length > 0 && (
<dl className="house-info-card__features">
{features.map((f) => (
<div key={f.label} className="house-info-card__feature">
<dt>{f.label}</dt>
<dd>{f.value}</dd>
</div>
))}
</dl>
)}
{houses.length > 1 && (
<footer className="house-info-card__more">
+ {houses.length - 1}{" "}
{houses.length - 1 === 1 ? "соседний дом" : "соседних домов"}
</footer>
)}
</section>
);
}

View file

@ -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 (
<div className="imv-benchmark imv-benchmark--loading">
<span className="imv-benchmark__label">Avito IMV</span>
<span className="imv-benchmark__value">загрузка</span>
</div>
);
}
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 (
<section className="imv-benchmark" aria-label="Avito IMV benchmark">
<header className="imv-benchmark__head">
<span className="imv-benchmark__title">Avito IMV</span>
<a
href="https://www.avito.ru/evaluation/realty"
target="_blank"
rel="noopener noreferrer"
className="imv-benchmark__source"
>
источник: avito.ru/evaluation/realty
</a>
</header>
<div className="imv-benchmark__row">
<div className="imv-benchmark__metric">
<div className="imv-benchmark__metric-label">Avito рекомендует</div>
<div className="imv-benchmark__metric-value">
{formatRub(recommended_price)}
</div>
</div>
<div className="imv-benchmark__metric">
<div className="imv-benchmark__metric-label">Диапазон</div>
<div className="imv-benchmark__metric-value">
{formatRub(lower_price)} {formatRub(higher_price)}
</div>
</div>
<div className="imv-benchmark__metric">
<div className="imv-benchmark__metric-label">Аналогов в рынке</div>
<div className="imv-benchmark__metric-value">
{market_count != null
? new Intl.NumberFormat("ru-RU").format(market_count)
: "—"}
</div>
</div>
</div>
{our_median_price != null && diffLabel && (
<footer className="imv-benchmark__compare">
Наша оценка: <strong>{formatRub(our_median_price)}</strong> ({diffLabel})
</footer>
)}
</section>
);
}

View file

@ -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;
}

View file

@ -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<HouseInfoForEstimate[]>({
queryKey: ["trade-in", "estimate", estimate_id, "houses"],
queryFn: () =>
apiFetch<HouseInfoForEstimate[]>(`${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<IMVBenchmarkResponse>({
queryKey: ["trade-in", "estimate", estimate_id, "imv-benchmark"],
queryFn: () =>
apiFetch<IMVBenchmarkResponse>(`${BASE}/estimate/${estimate_id}/imv-benchmark`),
enabled: estimate_id !== null && estimate_id.length > 0,
staleTime: 10 * 60_000,
});
}

View file

@ -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<Record<string, unknown>>;
}
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;
}