feat(tradein-ui): mock-disclaimer + progressive enrichment + UX polish (#517)
This commit is contained in:
parent
a0d50bd282
commit
92d00ec874
3 changed files with 135 additions and 19 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue