feat(tradein-ui): CianValuationCard — sparkline + sale/rent prices + change badge

This commit is contained in:
lekss361 2026-05-24 17:14:13 +03:00
parent 3185c45a7b
commit ad4f0ef606
4 changed files with 189 additions and 0 deletions

View file

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

View file

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

View file

@ -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 {

View file

@ -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 ──