feat(tradein): Stage 4b — IMV benchmark badge + house info card on estimate result (#461)
This commit is contained in:
parent
d28bf4e66a
commit
72d2b97ee0
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 { EstimateForm } from "@/components/trade-in/EstimateForm";
|
||||||
import { SourcesProgress } from "@/components/trade-in/SourcesProgress";
|
import { SourcesProgress } from "@/components/trade-in/SourcesProgress";
|
||||||
import { HeroSummary } from "@/components/trade-in/HeroSummary";
|
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 { PhotoUpload } from "@/components/trade-in/PhotoUpload";
|
||||||
import { ListingsCard } from "@/components/trade-in/ListingsCard";
|
import { ListingsCard } from "@/components/trade-in/ListingsCard";
|
||||||
import { DealsCard } from "@/components/trade-in/DealsCard";
|
import { DealsCard } from "@/components/trade-in/DealsCard";
|
||||||
import { OfferCard } from "@/components/trade-in/OfferCard";
|
import { OfferCard } from "@/components/trade-in/OfferCard";
|
||||||
import { TestPresets } from "@/components/trade-in/TestPresets";
|
import { TestPresets } from "@/components/trade-in/TestPresets";
|
||||||
|
import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api";
|
||||||
|
|
||||||
function useEstimateId() {
|
function useEstimateId() {
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
|
|
@ -41,6 +44,11 @@ export default function TradeInPage() {
|
||||||
|
|
||||||
const mutation = useEstimateMutation();
|
const mutation = useEstimateMutation();
|
||||||
|
|
||||||
|
const currentEstimateId =
|
||||||
|
freshResult?.estimate.estimate_id ?? urlEstimateId;
|
||||||
|
const imvBenchmark = useEstimateImvBenchmark(currentEstimateId);
|
||||||
|
const houses = useEstimateHouses(currentEstimateId);
|
||||||
|
|
||||||
function handleSubmit(input: TradeInEstimateInput) {
|
function handleSubmit(input: TradeInEstimateInput) {
|
||||||
mutation.mutate(input, {
|
mutation.mutate(input, {
|
||||||
onSuccess: (estimate) => {
|
onSuccess: (estimate) => {
|
||||||
|
|
@ -157,6 +165,14 @@ export default function TradeInPage() {
|
||||||
{resultData ? (
|
{resultData ? (
|
||||||
<>
|
<>
|
||||||
<HeroSummary estimate={resultData.estimate} input={resultData.input} />
|
<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} />
|
<PhotoUpload estimateId={resultData.estimate.estimate_id} />
|
||||||
<ListingsCard estimate={resultData.estimate} />
|
<ListingsCard estimate={resultData.estimate} />
|
||||||
<DealsCard 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 { flex-direction: column; align-items: flex-start; }
|
||||||
.card-head .card-meta { text-align: left; }
|
.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 { apiFetch } from "./api";
|
||||||
import type {
|
import type {
|
||||||
AggregatedEstimate,
|
AggregatedEstimate,
|
||||||
|
HouseInfoForEstimate,
|
||||||
|
IMVBenchmarkResponse,
|
||||||
TradeInEstimateInput,
|
TradeInEstimateInput,
|
||||||
} from "@/types/trade-in";
|
} 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
|
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;
|
repair_state: RepairState | null;
|
||||||
has_balcony: boolean | 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