feat(tradein): Stage 4b — IMV benchmark badge + house info card on estimate result #461
6 changed files with 432 additions and 0 deletions
|
|
@ -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} />
|
||||
|
|
|
|||
117
tradein-mvp/frontend/src/components/trade-in/HouseInfoCard.tsx
Normal file
117
tradein-mvp/frontend/src/components/trade-in/HouseInfoCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue