- FastAPI backend: PostGIS estimator + 3 scrapers (Avito/Cian/Yandex)
- Next.js 15 frontend: tradein.html mockup design, basePath=/trade-in
- WeasyPrint PDF (Брусника-style 4-page report)
- Address autocomplete с typo-tolerance + 6 EKB presets
- Изолированный docker stack gendesign-tradein (отдельная postgres БД)
- Caddy inline routes: gendsgn.ru/trade-in/* и /trade-in/api/v1/*
- Forgejo Actions: .forgejo/workflows/deploy-tradein.yml (shell-based GHCR login)
- Триггер только по paths: tradein-mvp/** (не пересекается с deploy.yml)
- Образы: ghcr.io/lekss361/gendesign-tradein-{backend,frontend}:latest
Первый запуск на сервере (вручную, один раз):
- создать /opt/gendesign/tradein-mvp/.env.runtime (postgres pwd, contact email)
- docker network create gendesign_shared (если нет)
- docker compose -p gendesign-tradein up -d
- docker compose -p gendesign exec caddy caddy reload
255 lines
10 KiB
TypeScript
255 lines
10 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* HeroSummary — Секция 1 «Сводка» из mockup tradein.html.
|
||
* Показывает медиану + достоверность CV + параметры объекта + 2 ценовых бара (объявления / сделки).
|
||
*/
|
||
import type { AggregatedEstimate, TradeInEstimateInput } from "@/types/trade-in";
|
||
|
||
interface Props {
|
||
estimate: AggregatedEstimate;
|
||
input: TradeInEstimateInput;
|
||
}
|
||
|
||
const HOUSE_TYPE_LABELS: Record<string, string> = {
|
||
panel: "Панельный",
|
||
brick: "Кирпичный",
|
||
monolith: "Монолит",
|
||
monolith_brick: "Монолит-кирпич",
|
||
other: "Другое",
|
||
};
|
||
|
||
const REPAIR_LABELS: Record<string, string> = {
|
||
needs_repair: "Требует ремонта",
|
||
standard: "Стандартный",
|
||
good: "Хороший",
|
||
excellent: "Евроремонт",
|
||
};
|
||
|
||
const CONF_LABELS: Record<string, { txt: string; color: string }> = {
|
||
high: { txt: "высокая", color: "var(--success)" },
|
||
medium: { txt: "средняя", color: "var(--success)" },
|
||
low: { txt: "низкая", color: "var(--danger)" },
|
||
};
|
||
|
||
function formatMln(rub: number): string {
|
||
return `${(rub / 1_000_000).toFixed(2).replace(".", ",")} млн`;
|
||
}
|
||
|
||
function calcCv(estimate: AggregatedEstimate): number {
|
||
// CV = (P75 - P25) / median * 100 (наш range_high - range_low = P25-P75 пара)
|
||
const m = estimate.median_price_rub;
|
||
if (m === 0) return 0;
|
||
return ((estimate.range_high_rub - estimate.range_low_rub) / m) * 100;
|
||
}
|
||
|
||
export function HeroSummary({ estimate, input }: Props) {
|
||
const cv = calcCv(estimate);
|
||
const conf = CONF_LABELS[estimate.confidence] ?? CONF_LABELS.low;
|
||
const m = estimate.median_price_rub;
|
||
const lo = estimate.range_low_rub;
|
||
const hi = estimate.range_high_rub;
|
||
// Расчёт ширины для price bar (50% = середина): медиана внутри min/max
|
||
const span = hi - lo;
|
||
const medianPctRaw = span > 0 ? ((m - lo) / span) * 100 : 50;
|
||
const medianPct = Math.max(5, Math.min(95, medianPctRaw));
|
||
|
||
return (
|
||
<article className="card hero-card">
|
||
<div className="card-head">
|
||
<div>
|
||
<div className="section-kicker">Секция 1 · Сводка</div>
|
||
<h2>Анализ рынка и расчёт стоимости</h2>
|
||
</div>
|
||
<div className="card-meta">
|
||
<div>
|
||
Источник:{" "}
|
||
<b>
|
||
агрегация {estimate.sources_used.length}/7
|
||
</b>
|
||
</div>
|
||
<div style={{ marginTop: 4 }}>
|
||
Достоверность · <b style={{ color: conf.color }}>{conf.txt}</b> · CV {cv.toFixed(1)}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="hero-top">
|
||
<div className="hero-photo" role="img" aria-label="Фасад дома">
|
||
<div className="photo-meta">
|
||
{estimate.sources_used.length > 0
|
||
? `${estimate.sources_used[0]} · ${estimate.n_analogs} аналогов`
|
||
: "Нет фото"}
|
||
</div>
|
||
</div>
|
||
<div className="hero-meta">
|
||
<div className="hero-address">
|
||
{estimate.target_address ?? input.address}
|
||
</div>
|
||
<div className="hero-cad">
|
||
ЭТАЖ {input.floor}/{input.total_floors}
|
||
{input.year_built ? ` · ${input.year_built}` : ""}
|
||
</div>
|
||
|
||
<div className="meta-grid">
|
||
{input.year_built && (
|
||
<div className="meta-row">
|
||
<span className="k">Год постройки</span>
|
||
<span className="v mono">{input.year_built}</span>
|
||
</div>
|
||
)}
|
||
{input.house_type && (
|
||
<div className="meta-row">
|
||
<span className="k">Тип дома</span>
|
||
<span className="v">{HOUSE_TYPE_LABELS[input.house_type] ?? input.house_type}</span>
|
||
</div>
|
||
)}
|
||
<div className="meta-row">
|
||
<span className="k">Этаж</span>
|
||
<span className="v mono">
|
||
{input.floor} / {input.total_floors}
|
||
</span>
|
||
</div>
|
||
<div className="meta-row">
|
||
<span className="k">Площадь</span>
|
||
<span className="v mono">{input.area_m2} м²</span>
|
||
</div>
|
||
<div className="meta-row">
|
||
<span className="k">Планировка</span>
|
||
<span className="v">
|
||
{input.rooms === 0 ? "Студия" : `${input.rooms}-к`}, классическая
|
||
</span>
|
||
</div>
|
||
{input.repair_state && (
|
||
<div className="meta-row">
|
||
<span className="k">Состояние</span>
|
||
<span className="v">{REPAIR_LABELS[input.repair_state] ?? input.repair_state}</span>
|
||
</div>
|
||
)}
|
||
<div className="meta-row">
|
||
<span className="k">Балкон</span>
|
||
<span className="v">{input.has_balcony ? "есть" : "нет"}</span>
|
||
</div>
|
||
<div className="meta-row">
|
||
<span className="k">Аналогов</span>
|
||
<span className="v mono">{estimate.n_analogs}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="hero-bars">
|
||
<div className="bar-block">
|
||
<div className="bar-head">
|
||
<span className="bar-title">
|
||
Диапазон цен в объявлениях{" "}
|
||
<span style={{ color: "var(--muted-2)" }}>(без учёта ремонта)</span>
|
||
</span>
|
||
<span className="bar-cv">медиана · {formatMln(m)} ₽</span>
|
||
</div>
|
||
<div className="pricebar">
|
||
<div className="axis">
|
||
<div className="range" style={{ left: "5%", right: "5%" }} />
|
||
<div className="median" style={{ left: `${medianPct}%` }} />
|
||
</div>
|
||
<div className="endpoint" style={{ left: "5%" }}>
|
||
<div className="endpoint-val">{formatMln(lo)}</div>
|
||
<div className="endpoint-tick" />
|
||
</div>
|
||
<div className="endpoint" style={{ left: "95%" }}>
|
||
<div className="endpoint-val">{formatMln(hi)}</div>
|
||
<div className="endpoint-tick" />
|
||
</div>
|
||
<div className="median-label" style={{ left: `${medianPct}%`, top: -2 }}>
|
||
{formatMln(m)} ₽
|
||
</div>
|
||
<div className="exp left">{estimate.median_price_per_m2.toLocaleString("ru-RU")} ₽/м²</div>
|
||
<div className="exp right">{estimate.n_analogs} аналог.</div>
|
||
</div>
|
||
</div>
|
||
|
||
{estimate.actual_deals.length > 0 && (() => {
|
||
const dealsPrices = estimate.actual_deals.map((d) => d.price_rub);
|
||
const dLo = Math.min(...dealsPrices);
|
||
const dHi = Math.max(...dealsPrices);
|
||
const dM = dealsPrices.sort((a, b) => a - b)[Math.floor(dealsPrices.length / 2)];
|
||
const dSpan = dHi - dLo;
|
||
const dMedPct = dSpan > 0 ? Math.max(5, Math.min(95, ((dM - dLo) / dSpan) * 100)) : 50;
|
||
return (
|
||
<div className="bar-block">
|
||
<div className="bar-head">
|
||
<span className="bar-title">Диапазон цен по фактическим сделкам</span>
|
||
<span className="bar-cv">медиана · {formatMln(dM)} ₽</span>
|
||
</div>
|
||
<div className="pricebar">
|
||
<div className="axis">
|
||
<div
|
||
className="range"
|
||
style={{
|
||
left: "5%",
|
||
right: "5%",
|
||
background: "linear-gradient(90deg,var(--viz-3),var(--viz-4))",
|
||
}}
|
||
/>
|
||
<div className="median" style={{ left: `${dMedPct}%` }} />
|
||
</div>
|
||
<div className="endpoint" style={{ left: "5%" }}>
|
||
<div className="endpoint-val">{formatMln(dLo)}</div>
|
||
<div className="endpoint-tick" />
|
||
</div>
|
||
<div className="endpoint" style={{ left: "95%" }}>
|
||
<div className="endpoint-val">{formatMln(dHi)}</div>
|
||
<div className="endpoint-tick" />
|
||
</div>
|
||
<div className="median-label" style={{ left: `${dMedPct}%`, top: -2 }}>
|
||
{formatMln(dM)} ₽
|
||
</div>
|
||
<div className="exp left">{estimate.actual_deals.length} сделок</div>
|
||
<div className="exp right">Росреестр · ДомКлик</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
|
||
{estimate.confidence_explanation && (
|
||
<div className="hero-warnings">
|
||
<div className="warn-row">
|
||
<span className="ic">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="12" cy="12" r="10" />
|
||
<line x1="12" y1="8" x2="12" y2="12" />
|
||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||
</svg>
|
||
</span>
|
||
<div>{estimate.confidence_explanation}</div>
|
||
</div>
|
||
<div className="warn-row">
|
||
<span className="ic">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="12" cy="12" r="10" />
|
||
<line x1="12" y1="8" x2="12" y2="12" />
|
||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||
</svg>
|
||
</span>
|
||
<div>
|
||
Цены в объявлениях ≠ реальная сделка — разница <b>5–12%</b> по данным Росреестра.
|
||
</div>
|
||
</div>
|
||
<div className="warn-row">
|
||
<span className="ic">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="12" cy="12" r="10" />
|
||
<line x1="12" y1="8" x2="12" y2="12" />
|
||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||
</svg>
|
||
</span>
|
||
<div>
|
||
Самостоятельная продажа = до <b>15% потерь</b> на торге, риелторе, нотариусе и аренде.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</article>
|
||
);
|
||
}
|