feat(tradein-ui): mock-disclaimer + progressive enrichment + UX polish (#517)
All checks were successful
Deploy Trade-In / build-frontend (push) Successful in 1m42s
Deploy Trade-In / deploy (push) Successful in 35s
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-backend (push) Has been skipped

This commit is contained in:
lekss361 2026-05-24 12:58:39 +00:00
parent a0d50bd282
commit 92d00ec874
3 changed files with 135 additions and 19 deletions

View file

@ -9,7 +9,7 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import "@/components/trade-in/trade-in.css";
import type { AggregatedEstimate, TradeInEstimateInput } from "@/types/trade-in";
import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState } from "@/types/trade-in";
import { useEstimateMutation, useEstimate } from "@/lib/trade-in-api";
import { EstimateForm } from "@/components/trade-in/EstimateForm";
import { Topbar } from "@/components/trade-in/Topbar";
@ -75,6 +75,12 @@ export default function TradeInPage() {
}
: null);
function handleResubmit(patch: { house_type?: HouseType; repair_state?: RepairState }) {
if (!resultData) return;
const enrichedInput: TradeInEstimateInput = { ...resultData.input, ...patch };
handleSubmit(enrichedInput);
}
const isPending = mutation.isPending;
return (
@ -149,7 +155,12 @@ export default function TradeInPage() {
{resultData ? (
<>
<HeroSummary estimate={resultData.estimate} input={resultData.input} />
<HeroSummary
estimate={resultData.estimate}
input={resultData.input}
onResubmit={handleResubmit}
isResubmitting={isPending}
/>
<IMVBenchmark
benchmark={imvBenchmark.data}
isLoading={imvBenchmark.isPending}

View file

@ -38,21 +38,7 @@ function fmtDate(iso: string | null): string {
export function DealsCard({ estimate }: Props) {
const deals = estimate.actual_deals;
if (deals.length === 0) {
return (
<article className="card">
<div className="card-head">
<div>
<div className="section-kicker">Секция 3 · Сделки</div>
<h2>Фактические сделки по аналогичным квартирам</h2>
</div>
</div>
<div className="card-body">
<p style={{ color: "var(--muted)", textAlign: "center", padding: "24px 0" }}>
Нет фактических сделок в радиусе 2 км за 12 месяцев.
</p>
</div>
</article>
);
return null;
}
// Сортируем по дате DESC, берем медиану и range

View file

@ -6,11 +6,14 @@
* HeroSummary Секция 1 «Сводка» из mockup tradein.html.
* Показывает медиану + достоверность CV + параметры объекта + 2 ценовых бара (объявления / сделки).
*/
import type { AggregatedEstimate, TradeInEstimateInput } from "@/types/trade-in";
import { useState } from "react";
import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState } from "@/types/trade-in";
interface Props {
estimate: AggregatedEstimate;
input: TradeInEstimateInput;
onResubmit: (patch: { house_type?: HouseType; repair_state?: RepairState }) => void;
isResubmitting?: boolean;
}
const HOUSE_TYPE_LABELS: Record<string, string> = {
@ -45,12 +48,30 @@ function calcCv(estimate: AggregatedEstimate): number {
return ((estimate.range_high_rub - estimate.range_low_rub) / m) * 100;
}
export function HeroSummary({ estimate, input }: Props) {
/** Returns true when address looks like a non-residential geocode fallback */
function isMockAddress(estimate: AggregatedEstimate): boolean {
const addr = estimate.target_address ?? "";
if (/^(Склад|Гараж|Подсобка|Нежилое),\s/i.test(addr)) return true;
if (estimate.confidence_explanation?.startsWith("address_not_geocoded")) return true;
return false;
}
export function HeroSummary({ estimate, input, onResubmit, isResubmitting = false }: 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;
const showMockDisclaimer = isMockAddress(estimate);
// Progressive enrichment state
const needsHouseType = estimate.house_type === null;
const needsRepairState = estimate.repair_state === null;
const showEnrichment =
(needsHouseType || needsRepairState) && estimate.confidence !== "low";
const [enrichHouseType, setEnrichHouseType] = useState<string>("");
const [enrichRepairState, setEnrichRepairState] = useState<string>("");
// Фото первого аналога с картинкой — вместо пустого серого плейсхолдера.
const heroPhoto = estimate.analogs.find((a) => a.photo_url)?.photo_url ?? null;
// Расчёт ширины для price bar (50% = середина): медиана внутри min/max
@ -68,9 +89,37 @@ export function HeroSummary({ estimate, input }: Props) {
const houseType = estimate.house_type ?? input.house_type;
const repairState = estimate.repair_state ?? input.repair_state;
const hasBalcony = estimate.has_balcony ?? input.has_balcony;
const estDaysOnMarket = estimate.est_days_on_market;
function handleEnrichSubmit(e: React.FormEvent) {
e.preventDefault();
const patch: { house_type?: HouseType; repair_state?: RepairState } = {};
if (enrichHouseType) patch.house_type = enrichHouseType as HouseType;
if (enrichRepairState) patch.repair_state = enrichRepairState as RepairState;
onResubmit(patch);
}
return (
<article className="card hero-card">
{showMockDisclaimer && (
<div
role="status"
aria-live="polite"
style={{
margin: "16px 16px 0",
padding: "12px 16px",
border: "1px solid var(--accent-2)",
background: "var(--accent-2-soft)",
color: "var(--fg)",
borderRadius: "var(--radius)",
fontSize: 13,
lineHeight: 1.4,
}}
>
Адрес распознан неточно оценка может быть приближённой.
Уточните адрес и пересчитайте для более точного результата.
</div>
)}
<div className="card-head">
<div>
<div className="section-kicker">Секция 1 · Сводка</div>
@ -161,6 +210,14 @@ export function HeroSummary({ estimate, input }: Props) {
<span className="k">Аналогов</span>
<span className="v mono">{estimate.n_analogs}</span>
</div>
<div className="meta-row">
<span className="k">Срок продажи</span>
{estDaysOnMarket !== null ? (
<span className="v mono">{estDaysOnMarket} дн.</span>
) : (
<span className="v" style={{ color: "var(--muted)" }}>нет данных</span>
)}
</div>
</div>
</div>
</div>
@ -277,6 +334,68 @@ export function HeroSummary({ estimate, input }: Props) {
</div>
</div>
)}
{showEnrichment && (
<div
style={{
margin: "16px 16px 0",
padding: 16,
border: "1px solid var(--border, #e5e7eb)",
background: "var(--surface-2, #f8fafc)",
borderRadius: "var(--radius, 8px)",
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
<p style={{ fontSize: 13, fontWeight: 500, margin: 0 }}>
Уточните для повышения точности оценки:
</p>
<form onSubmit={handleEnrichSubmit}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 10, marginBottom: 12 }}>
{needsHouseType && (
<select
className="control"
value={enrichHouseType}
onChange={(e) => setEnrichHouseType(e.target.value)}
aria-label="Тип дома"
style={{ flex: "1 1 160px", minWidth: 140 }}
>
<option value="">Тип дома</option>
<option value="panel">Панельный</option>
<option value="brick">Кирпичный</option>
<option value="monolith">Монолит</option>
<option value="monolith_brick">Монолит-кирпич</option>
<option value="other">Другое</option>
</select>
)}
{needsRepairState && (
<select
className="control"
value={enrichRepairState}
onChange={(e) => setEnrichRepairState(e.target.value)}
aria-label="Состояние ремонта"
style={{ flex: "1 1 160px", minWidth: 140 }}
>
<option value="">Состояние</option>
<option value="needs_repair">Требует ремонта</option>
<option value="standard">Стандартный</option>
<option value="good">Хороший</option>
<option value="excellent">Евроремонт</option>
</select>
)}
</div>
<button
type="submit"
className="btn btn-primary"
disabled={isResubmitting || (!enrichHouseType && !enrichRepairState)}
style={{ opacity: isResubmitting || (!enrichHouseType && !enrichRepairState) ? 0.55 : 1 }}
>
{isResubmitting ? "Пересчитываем…" : "Пересчитать"}
</button>
</form>
</div>
)}
</article>
);
}