feat(tradein): «Что-если» interactive recompute on estimate result #686

Merged
bot-reviewer merged 1 commit from feat/tradein-what-if into main 2026-05-30 07:35:16 +00:00
3 changed files with 537 additions and 0 deletions

View file

@ -26,6 +26,7 @@ import { ListingsCard } from "@/components/trade-in/ListingsCard";
import { DealsCard } from "@/components/trade-in/DealsCard";
import { StreetDealsCard } from "@/components/trade-in/StreetDealsCard";
import { OfferCard } from "@/components/trade-in/OfferCard";
import { WhatIfPanel } from "@/components/trade-in/WhatIfPanel";
import { TestPresets } from "@/components/trade-in/TestPresets";
import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api";
import { useQuota } from "@/lib/useQuota";
@ -115,6 +116,29 @@ export default function TradeInPage() {
}
: null);
// Надёжный input для «Что-если»: предпочитаем поля самой оценки (есть и при
// restore по ?id=, где resultData.input — stub с нулями), откатываемся на input.
// address/rooms обязательны для payload — берём из estimate, иначе из input.
const whatIfInput: TradeInEstimateInput | null = resultData
? {
...resultData.input,
address: resultData.estimate.target_address ?? resultData.input.address,
rooms: resultData.estimate.rooms ?? resultData.input.rooms,
area_m2: resultData.estimate.area_m2 ?? resultData.input.area_m2,
floor: resultData.estimate.floor ?? resultData.input.floor,
total_floors: resultData.estimate.total_floors ?? resultData.input.total_floors,
year_built: resultData.estimate.year_built ?? resultData.input.year_built,
house_type: resultData.estimate.house_type ?? resultData.input.house_type,
repair_state: resultData.estimate.repair_state ?? resultData.input.repair_state,
has_balcony: resultData.estimate.has_balcony ?? resultData.input.has_balcony,
}
: null;
// Гейтим панель: нужен валидный адрес + площадь, иначе recompute-payload невалиден.
const whatIfReady =
whatIfInput !== null &&
whatIfInput.address.trim().length >= 3 &&
whatIfInput.area_m2 > 10;
function handleResubmit(patch: { house_type?: HouseType; repair_state?: RepairState }) {
if (!resultData) return;
const enrichedInput: TradeInEstimateInput = { ...resultData.input, ...patch };
@ -202,6 +226,12 @@ export default function TradeInPage() {
{resultData ? (
<>
{whatIfReady && whatIfInput && (
<WhatIfPanel
baseEstimate={resultData.estimate}
baseInput={whatIfInput}
/>
)}
<HeroSummary
estimate={resultData.estimate}
input={resultData.input}

View file

@ -0,0 +1,364 @@
"use client";
/**
* WhatIfPanel «Что-если» интерактивный пересчёт оценки на результате.
* Web-преимущество над статичным PDF: продавец/агент крутит ключевые
* value-drivers (ремонт, этаж, площадь, балкон) и видит, как меняется оценка,
* не перезаполняя форму. Demo wow: «а если сделать ремонт вот +X».
*
* Использует ТОТ ЖЕ POST /api/v1/trade-in/estimate (useEstimateMutation) не
* параллельный API-путь. Запрос дебаунсится (~700 мс) и стреляет только если
* значение реально изменилось относительно последнего отправленного.
* Baseline (исходная оценка) держится снаружи и не затрагивается панель
* показывает ДЕЛЬТУ к нему и не клобберит остальные карточки страницы.
*
* Каждый estimate ходит в скраперы/DaData, поэтому: debounce + skip-if-unchanged
* + ручная кнопка «Пересчитать». Авто-режим включён по умолчанию (autoMode),
* но переключается чекбоксом «Авто-пересчёт».
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
AggregatedEstimate,
RepairState,
TradeInEstimateInput,
} from "@/types/trade-in";
import { useEstimateMutation } from "@/lib/trade-in-api";
interface Props {
/** Исходная оценка-baseline — диффим относительно неё. */
baseEstimate: AggregatedEstimate;
/** Исходный input — основа для патчей (модифицируем копию). */
baseInput: TradeInEstimateInput;
}
const REPAIR_OPTIONS: ReadonlyArray<{ value: RepairState; label: string }> = [
{ value: "needs_repair", label: "Требует ремонта" },
{ value: "standard", label: "Жилое" },
{ value: "good", label: "Хороший" },
{ value: "excellent", label: "Евроремонт" },
];
const DEBOUNCE_MS = 700;
/** Цена-итог, по которой считаем дельту: ожидаемая сделка, иначе медиана. */
function headlinePrice(e: AggregatedEstimate): number {
const sold = e.expected_sold_price_rub;
return typeof sold === "number" && sold > 0 ? sold : e.median_price_rub;
}
function formatMln(rub: number): string {
return `${(rub / 1_000_000).toFixed(2).replace(".", ",")} млн`;
}
function formatDeltaMln(rub: number): string {
const sign = rub > 0 ? "+" : rub < 0 ? "" : "";
const abs = Math.abs(rub);
return `${sign}${(abs / 1_000_000).toFixed(2).replace(".", ",")} млн ₽`;
}
interface Draft {
repair_state: RepairState | "";
floor: number | null;
area_m2: number;
has_balcony: boolean;
}
function draftFromInput(input: TradeInEstimateInput, est: AggregatedEstimate): Draft {
return {
repair_state: (est.repair_state ?? input.repair_state ?? "") as RepairState | "",
floor: est.floor ?? input.floor ?? null,
area_m2: est.area_m2 ?? input.area_m2 ?? 0,
has_balcony: est.has_balcony ?? input.has_balcony ?? false,
};
}
/** Сериализуемый ключ драфта — для skip-if-unchanged сравнения. */
function draftKey(d: Draft): string {
return `${d.repair_state}|${d.floor ?? ""}|${d.area_m2}|${d.has_balcony ? 1 : 0}`;
}
function applyDraft(base: TradeInEstimateInput, d: Draft): TradeInEstimateInput {
const out: TradeInEstimateInput = {
...base,
area_m2: d.area_m2,
floor: d.floor,
has_balcony: d.has_balcony,
};
if (d.repair_state) out.repair_state = d.repair_state;
return out;
}
export function WhatIfPanel({ baseEstimate, baseInput }: Props) {
const initialDraft = useMemo(
() => draftFromInput(baseInput, baseEstimate),
[baseInput, baseEstimate],
);
const [draft, setDraft] = useState<Draft>(initialDraft);
const [autoMode, setAutoMode] = useState(true);
// Результат последнего what-if пересчёта (локальный, не трогает страницу).
const [whatIfEstimate, setWhatIfEstimate] = useState<AggregatedEstimate | null>(null);
const mutation = useEstimateMutation();
// Сброс при смене baseline (новая оценка из формы / restore по ?id=).
useEffect(() => {
setDraft(initialDraft);
setWhatIfEstimate(null);
}, [initialDraft]);
const baseKey = useMemo(() => draftKey(initialDraft), [initialDraft]);
const currentKey = draftKey(draft);
const isDirty = currentKey !== baseKey;
const totalFloors = baseEstimate.total_floors ?? baseInput.total_floors ?? null;
const floorMax = totalFloors && totalFloors > 0 ? totalFloors : 100;
// Ключ последнего ОТПРАВЛЕННОГО драфта — чтобы не дёргать бэкенд на тех же значениях.
const lastSentKeyRef = useRef<string>(baseKey);
useEffect(() => {
lastSentKeyRef.current = baseKey;
}, [baseKey]);
const recompute = useCallback(
(d: Draft) => {
const key = draftKey(d);
if (key === lastSentKeyRef.current) return; // ничего не изменилось
lastSentKeyRef.current = key;
mutation.mutate(applyDraft(baseInput, d), {
onSuccess: (est) => setWhatIfEstimate(est),
});
},
[baseInput, mutation],
);
// Debounced auto-recompute — только когда autoMode и draft реально отличается.
useEffect(() => {
if (!autoMode) return;
if (currentKey === lastSentKeyRef.current) return;
const t = setTimeout(() => recompute(draft), DEBOUNCE_MS);
return () => clearTimeout(t);
// currentKey покрывает все поля draft; recompute стабилен по baseInput.
}, [autoMode, currentKey, draft, recompute]);
function reset() {
setDraft(initialDraft);
setWhatIfEstimate(null);
lastSentKeyRef.current = baseKey;
}
const isPending = mutation.isPending;
// Активный результат для дельты: what-if если он отражает текущий драфт.
const shownEstimate =
isDirty && whatIfEstimate ? whatIfEstimate : null;
const basePrice = headlinePrice(baseEstimate);
const newPrice = shownEstimate ? headlinePrice(shownEstimate) : null;
const delta = newPrice !== null ? newPrice - basePrice : null;
const deltaPct = delta !== null && basePrice > 0 ? (delta / basePrice) * 100 : null;
function patch(p: Partial<Draft>) {
setDraft((s) => ({ ...s, ...p }));
}
return (
<article className="card whatif-card">
<div className="whatif-head">
<div>
<div className="section-kicker">Интерактив · Что-если</div>
<h3 className="whatif-title">Поменяйте параметры оценка пересчитается</h3>
</div>
<label className="whatif-auto" title="Автоматически пересчитывать после паузы">
<input
type="checkbox"
checked={autoMode}
onChange={(e) => setAutoMode(e.target.checked)}
/>
<span>Авто-пересчёт</span>
</label>
</div>
<div className="whatif-body">
{/* Состояние ремонта — segmented */}
<div className="whatif-field">
<span className="whatif-label">Состояние ремонта</span>
<div className="whatif-segmented" role="group" aria-label="Состояние ремонта">
{REPAIR_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={`whatif-seg${draft.repair_state === opt.value ? " is-active" : ""}`}
aria-pressed={draft.repair_state === opt.value}
onClick={() => patch({ repair_state: opt.value })}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="whatif-row">
{/* Этаж — stepper + slider */}
<div className="whatif-field">
<span className="whatif-label">
Этаж{" "}
<span className="whatif-hint">
{draft.floor ?? "—"}
{totalFloors ? ` / ${totalFloors}` : ""}
</span>
</span>
<div className="whatif-stepper">
<button
type="button"
className="whatif-step-btn"
aria-label="Этаж ниже"
disabled={(draft.floor ?? 1) <= 1}
onClick={() => patch({ floor: Math.max(1, (draft.floor ?? 1) - 1) })}
>
</button>
<input
className="whatif-slider"
type="range"
min={1}
max={floorMax}
value={draft.floor ?? 1}
onChange={(e) => patch({ floor: Number(e.target.value) })}
aria-label="Этаж"
/>
<button
type="button"
className="whatif-step-btn"
aria-label="Этаж выше"
disabled={(draft.floor ?? 1) >= floorMax}
onClick={() => patch({ floor: Math.min(floorMax, (draft.floor ?? 1) + 1) })}
>
+
</button>
</div>
</div>
{/* Площадь — numeric ± */}
<div className="whatif-field">
<span className="whatif-label">
Площадь <span className="whatif-hint">м²</span>
</span>
<div className="whatif-stepper">
<button
type="button"
className="whatif-step-btn"
aria-label="Меньше площадь"
disabled={draft.area_m2 <= 11}
onClick={() =>
patch({ area_m2: Math.max(11, Math.round((draft.area_m2 - 1) * 10) / 10) })
}
>
</button>
<input
className="control mono whatif-num"
type="number"
step="0.5"
min={11}
max={499}
value={draft.area_m2}
onChange={(e) => {
const v = Number(e.target.value);
if (!Number.isNaN(v)) patch({ area_m2: v });
}}
aria-label="Площадь м²"
/>
<button
type="button"
className="whatif-step-btn"
aria-label="Больше площадь"
disabled={draft.area_m2 >= 499}
onClick={() =>
patch({ area_m2: Math.min(499, Math.round((draft.area_m2 + 1) * 10) / 10) })
}
>
+
</button>
</div>
</div>
</div>
{/* Балкон toggle */}
<label className="whatif-balcony">
<input
type="checkbox"
checked={draft.has_balcony}
onChange={(e) => patch({ has_balcony: e.target.checked })}
/>
<span>Есть балкон / лоджия</span>
</label>
</div>
<div className="whatif-foot">
<div className="whatif-result" aria-live="polite">
{isPending ? (
<span className="whatif-loading">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
style={{ animation: "ti-spin 1s linear infinite" }}
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Пересчитываем
</span>
) : delta !== null && newPrice !== null ? (
<>
<span
className={`whatif-delta${delta > 0 ? " up" : delta < 0 ? " down" : ""}`}
>
{formatDeltaMln(delta)}
{deltaPct !== null && Math.abs(deltaPct) >= 0.1
? ` (${delta >= 0 ? "+" : ""}${Math.abs(deltaPct).toFixed(1)}%)`
: ""}
</span>
<span className="whatif-newprice mono">
новая оценка {formatMln(newPrice)}
</span>
</>
) : (
<span className="whatif-baseline mono">
исходная оценка {formatMln(basePrice)}
</span>
)}
</div>
<div className="whatif-actions">
{!autoMode && (
<button
type="button"
className="btn btn-primary whatif-recalc"
disabled={!isDirty || isPending}
style={{ opacity: !isDirty || isPending ? 0.55 : 1 }}
onClick={() => recompute(draft)}
>
Пересчитать
</button>
)}
<button
type="button"
className="btn btn-ghost"
disabled={!isDirty && whatIfEstimate === null}
onClick={reset}
>
Сбросить
</button>
</div>
</div>
{mutation.error && (
<div className="whatif-error">{mutation.error.message}</div>
)}
</article>
);
}

View file

@ -1883,3 +1883,146 @@
background: var(--accent-2-soft);
color: var(--accent-2);
}
/* «Что-если» интерактивный пересчёт (web-преимущество над PDF)
Компактная панель над HeroSummary: segmented ремонт + stepper/slider этаж +
numeric площадь + балкон. Делает тот же POST /estimate (debounced) и диффит
к baseline. Все цвета через brand-токены (работает в .brand-active). */
.whatif-card {
border: 1px solid var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.whatif-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 16px 22px 10px;
}
.whatif-title {
font-size: 15px;
font-weight: 600;
margin: 2px 0 0;
color: var(--fg);
}
.whatif-auto {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--muted);
cursor: pointer;
white-space: nowrap;
}
.whatif-auto input { width: 14px; height: 14px; cursor: pointer; }
.whatif-body {
padding: 4px 22px 14px;
display: flex;
flex-direction: column;
gap: 14px;
}
.whatif-field { display: flex; flex-direction: column; gap: 6px; }
.whatif-label {
font-size: 12px;
font-weight: 600;
color: var(--fg-2);
display: flex;
align-items: baseline;
gap: 6px;
}
.whatif-hint { font-size: 11px; font-weight: 400; color: var(--muted); }
.whatif-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.whatif-segmented {
display: flex;
gap: 4px;
background: var(--surface-2);
padding: 4px;
border-radius: var(--radius);
border: 1px solid var(--border);
flex-wrap: wrap;
}
.whatif-seg {
flex: 1 1 auto;
min-width: 88px;
padding: 7px 8px;
font-size: 12px;
font-weight: 500;
border: 1px solid transparent;
border-radius: calc(var(--radius) - 4px);
background: transparent;
color: var(--fg-2);
cursor: pointer;
transition: background .12s, color .12s, border-color .12s;
}
.whatif-seg:hover { background: var(--surface); }
.whatif-seg.is-active {
background: var(--accent);
color: var(--surface);
border-color: var(--accent);
}
.whatif-stepper { display: flex; align-items: center; gap: 8px; }
.whatif-step-btn {
flex: 0 0 auto;
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 18px;
line-height: 1;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--accent);
cursor: pointer;
}
.whatif-step-btn:hover:not(:disabled) { background: var(--accent-soft); }
.whatif-step-btn:disabled { opacity: .4; cursor: not-allowed; }
.whatif-slider { flex: 1 1 auto; accent-color: var(--accent); cursor: pointer; }
.whatif-num { width: 84px; text-align: center; padding: 6px 8px; }
.whatif-balcony {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--fg);
cursor: pointer;
}
.whatif-balcony input { width: 14px; height: 14px; cursor: pointer; }
.whatif-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 14px 22px;
border-top: 1px solid var(--border);
background: var(--surface-2);
border-radius: 0 0 var(--radius) var(--radius);
}
.whatif-result { display: flex; flex-direction: column; gap: 2px; min-height: 34px; justify-content: center; }
.whatif-delta { font-size: 18px; font-weight: 700; color: var(--muted); }
.whatif-delta.up { color: var(--success); }
.whatif-delta.down { color: var(--danger); }
.whatif-newprice { font-size: 12px; color: var(--muted); }
.whatif-baseline { font-size: 13px; color: var(--fg-2); }
.whatif-loading { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; color: var(--muted); }
.whatif-actions { display: flex; align-items: center; gap: 8px; }
.whatif-recalc { padding: 8px 16px; }
.whatif-error {
margin: 0 22px 16px;
padding: 10px 12px;
background: var(--danger-soft);
border: 1px solid var(--danger);
border-radius: 6px;
color: var(--danger);
font-size: 12px;
}
@media (max-width: 560px) {
.whatif-row { grid-template-columns: 1fr; }
}
@keyframes ti-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }