feat(tradein/cian): surface valuation chart + rent + change-pct on /trade-in #530
6 changed files with 210 additions and 3 deletions
|
|
@ -46,6 +46,13 @@ class AnalogLot(BaseModel):
|
|||
distance_m: int | None = None # расстояние до целевой квартиры в метрах
|
||||
|
||||
|
||||
class CianChartPoint(BaseModel):
|
||||
"""Одна точка 7-месячного chart Cian Valuation Calculator."""
|
||||
|
||||
date: str
|
||||
price: float
|
||||
|
||||
|
||||
class CianValuationSummary(BaseModel):
|
||||
"""Cian Valuation Calculator данные для UI.
|
||||
|
||||
|
|
@ -55,7 +62,7 @@ class CianValuationSummary(BaseModel):
|
|||
|
||||
sale_price_rub: int | None = None
|
||||
rent_price_rub: int | None = None
|
||||
chart: list[dict[str, Any]] = Field(default_factory=list)
|
||||
chart: list[CianChartPoint] = Field(default_factory=list)
|
||||
chart_change_pct: float | None = None
|
||||
chart_change_direction: Literal["increase", "decrease", "neutral"] | None = None
|
||||
|
||||
|
|
|
|||
|
|
@ -874,9 +874,20 @@ async def estimate_quality(
|
|||
CianValuationSummary(
|
||||
sale_price_rub=int(cian_val.sale_price_rub) if cian_val.sale_price_rub else None,
|
||||
rent_price_rub=int(cian_val.rent_price_rub) if cian_val.rent_price_rub else None,
|
||||
chart=list(cian_val.chart or []),
|
||||
chart=[
|
||||
{
|
||||
"date": p.get("month_date") or p.get("date") or "",
|
||||
"price": p["price"],
|
||||
}
|
||||
for p in (cian_val.chart or [])
|
||||
if p.get("price") is not None
|
||||
],
|
||||
chart_change_pct=cian_val.chart_change_pct,
|
||||
chart_change_direction=cian_val.chart_change_direction,
|
||||
chart_change_direction=(
|
||||
cian_val.chart_change_direction
|
||||
if cian_val.chart_change_direction in {"increase", "decrease", "neutral"}
|
||||
else None
|
||||
),
|
||||
)
|
||||
if cian_val is not None
|
||||
else None
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { Topbar } from "@/components/trade-in/Topbar";
|
|||
import { SourcesProgress } from "@/components/trade-in/SourcesProgress";
|
||||
import { HeroSummary } from "@/components/trade-in/HeroSummary";
|
||||
import { IMVBenchmark } from "@/components/trade-in/IMVBenchmark";
|
||||
import { CianValuationCard } from "@/components/trade-in/CianValuationCard";
|
||||
import { HouseInfoCard } from "@/components/trade-in/HouseInfoCard";
|
||||
import { PhotoUpload } from "@/components/trade-in/PhotoUpload";
|
||||
import { ListingsCard } from "@/components/trade-in/ListingsCard";
|
||||
|
|
@ -165,6 +166,7 @@ export default function TradeInPage() {
|
|||
benchmark={imvBenchmark.data}
|
||||
isLoading={imvBenchmark.isPending}
|
||||
/>
|
||||
<CianValuationCard data={resultData.estimate.cian_valuation} />
|
||||
<HouseInfoCard
|
||||
houses={houses.data}
|
||||
isLoading={houses.isPending}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
"use client";
|
||||
|
||||
import type { CianValuationSummary } from "@/types/trade-in";
|
||||
|
||||
interface Props {
|
||||
data: CianValuationSummary | null | undefined;
|
||||
}
|
||||
|
||||
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) + " ₽";
|
||||
}
|
||||
|
||||
function Sparkline({ points }: { points: Array<{ date: string; price: number }> }) {
|
||||
if (points.length < 2) return null;
|
||||
const W = 240, H = 60, PAD = 4;
|
||||
const xs = points.map((_, i) => PAD + (i * (W - 2 * PAD)) / (points.length - 1));
|
||||
const prices = points.map((p) => p.price);
|
||||
const min = Math.min(...prices), max = Math.max(...prices);
|
||||
const range = max - min || 1;
|
||||
const ys = prices.map((p) => H - PAD - ((p - min) / range) * (H - 2 * PAD));
|
||||
const d = xs.map((x, i) => `${i === 0 ? "M" : "L"}${x},${ys[i]}`).join(" ");
|
||||
const firstDate = points[0].date.slice(0, 7); // "YYYY-MM"
|
||||
const lastDate = points[points.length - 1].date.slice(0, 7);
|
||||
return (
|
||||
<div className="cian-valuation__sparkline">
|
||||
<svg width={W} height={H} aria-hidden="true">
|
||||
<path d={d} fill="none" stroke="currentColor" strokeWidth={1.5} />
|
||||
</svg>
|
||||
<div className="cian-valuation__sparkline-dates">
|
||||
<span>{firstDate}</span>
|
||||
<span>{lastDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeBadge({
|
||||
pct,
|
||||
direction,
|
||||
}: {
|
||||
pct: number | null;
|
||||
direction: CianValuationSummary["chart_change_direction"];
|
||||
}) {
|
||||
if (pct == null || direction == null) return null;
|
||||
|
||||
const arrow = direction === "increase" ? "↑" : direction === "decrease" ? "↓" : "—";
|
||||
const colorClass =
|
||||
direction === "increase"
|
||||
? "cian-valuation__badge--up"
|
||||
: direction === "decrease"
|
||||
? "cian-valuation__badge--down"
|
||||
: "cian-valuation__badge--neutral";
|
||||
|
||||
return (
|
||||
<span className={`cian-valuation__badge ${colorClass}`}>
|
||||
{arrow} {Math.abs(pct).toFixed(1)}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function CianValuationCard({ data }: Props) {
|
||||
if (!data || data.chart.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="cian-valuation" aria-label="Оценка Cian">
|
||||
<header className="cian-valuation__head">
|
||||
<span className="cian-valuation__title">Оценка Cian</span>
|
||||
<ChangeBadge pct={data.chart_change_pct} direction={data.chart_change_direction} />
|
||||
</header>
|
||||
|
||||
<div className="cian-valuation__row">
|
||||
<div className="cian-valuation__metric">
|
||||
<div className="cian-valuation__metric-label">Продажа</div>
|
||||
<div className="cian-valuation__metric-value cian-valuation__metric-value--primary">
|
||||
{formatRub(data.sale_price_rub)}
|
||||
</div>
|
||||
</div>
|
||||
{data.rent_price_rub != null && (
|
||||
<div className="cian-valuation__metric">
|
||||
<div className="cian-valuation__metric-label">Аренда / мес</div>
|
||||
<div className="cian-valuation__metric-value">
|
||||
{formatRub(data.rent_price_rub)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Sparkline points={data.chart} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1162,6 +1162,90 @@
|
|||
color: #374151;
|
||||
}
|
||||
|
||||
/* Cian valuation card */
|
||||
|
||||
.cian-valuation {
|
||||
margin: 16px 0;
|
||||
padding: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.cian-valuation__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.cian-valuation__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.cian-valuation__badge {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 2px 7px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.cian-valuation__badge--up {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.cian-valuation__badge--down {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.cian-valuation__badge--neutral {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.cian-valuation__row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cian-valuation__metric-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.cian-valuation__metric-value {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.cian-valuation__metric-value--primary {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cian-valuation__sparkline {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.cian-valuation__sparkline-dates {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
width: 240px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* House info card */
|
||||
|
||||
.house-info-card {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,14 @@ export interface AnalogLot {
|
|||
distance_m: number | null; // расстояние до целевой квартиры в метрах
|
||||
}
|
||||
|
||||
export interface CianValuationSummary {
|
||||
sale_price_rub: number | null;
|
||||
rent_price_rub: number | null;
|
||||
chart: Array<{ date: string; price: number }>;
|
||||
chart_change_pct: number | null;
|
||||
chart_change_direction: 'increase' | 'decrease' | 'neutral' | null;
|
||||
}
|
||||
|
||||
export interface AggregatedEstimate {
|
||||
estimate_id: string; // UUID
|
||||
median_price_rub: number;
|
||||
|
|
@ -75,6 +83,8 @@ export interface AggregatedEstimate {
|
|||
house_type: HouseType | null;
|
||||
repair_state: RepairState | null;
|
||||
has_balcony: boolean | null;
|
||||
// ── Cian valuation surface ──
|
||||
cian_valuation?: CianValuationSummary | null;
|
||||
}
|
||||
|
||||
// ── Stage 4a/4b response types ──
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue