"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(initialDraft); const [autoMode, setAutoMode] = useState(true); // Результат последнего what-if пересчёта (локальный, не трогает страницу). const [whatIfEstimate, setWhatIfEstimate] = useState(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(baseKey); // #770 finding #27: монотонный счётчик запросов — фильтрует out-of-order ответы // (медленный старый пересчёт мог перетереть свежий результат). const reqSeqRef = useRef(0); useEffect(() => { lastSentKeyRef.current = baseKey; }, [baseKey]); const recompute = useCallback( (d: Draft) => { const key = draftKey(d); if (key === lastSentKeyRef.current) return; // ничего не изменилось lastSentKeyRef.current = key; const seq = ++reqSeqRef.current; mutation.mutate(applyDraft(baseInput, d), { onSuccess: (est) => { // Применяем только результат последнего отправленного пересчёта. if (seq === reqSeqRef.current) 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) { setDraft((s) => ({ ...s, ...p })); } return (
Интерактив · Что-если

Поменяйте параметры — оценка пересчитается

{/* Состояние ремонта — segmented */}
Состояние ремонта
{REPAIR_OPTIONS.map((opt) => ( ))}
{/* Этаж — stepper + slider */}
Этаж{" "} {draft.floor ?? "—"} {totalFloors ? ` / ${totalFloors}` : ""}
patch({ floor: Number(e.target.value) })} aria-label="Этаж" />
{/* Площадь — numeric ± */}
Площадь м²
{ const v = Number(e.target.value); if (!Number.isNaN(v)) patch({ area_m2: v }); }} aria-label="Площадь м²" />
{/* Балкон toggle */}
{isPending ? ( Пересчитываем… ) : delta !== null && newPrice !== null ? ( <> 0 ? " up" : delta < 0 ? " down" : ""}`} title="Пересчёт ходит к источникам заново — дельта включает естественный разброс свежих объявлений, а не только эффект изменённого параметра." > {formatDeltaMln(delta)} {deltaPct !== null && Math.abs(deltaPct) >= 0.1 ? ` (${delta >= 0 ? "+" : "−"}${Math.abs(deltaPct).toFixed(1)}%)` : ""} новая оценка {formatMln(newPrice)} ₽ ) : ( {basePrice > 0 ? `исходная оценка ${formatMln(basePrice)} ₽` : "исходная оценка —"} )}
{!autoMode && ( )}
{mutation.error && (
{mutation.error.message}
)}
); }