All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m38s
Deploy Trade-In / deploy (push) Successful in 35s
Co-authored-by: bot-frontend <bot-frontend@gendsgn.local> Co-committed-by: bot-frontend <bot-frontend@gendsgn.local>
372 lines
14 KiB
TypeScript
372 lines
14 KiB
TypeScript
"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);
|
||
// #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<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" : ""}`}
|
||
title="Пересчёт ходит к источникам заново — дельта включает естественный разброс свежих объявлений, а не только эффект изменённого параметра."
|
||
>
|
||
{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">
|
||
{basePrice > 0 ? `исходная оценка ${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>
|
||
);
|
||
}
|