diff --git a/tradein-mvp/frontend/src/app/page.tsx b/tradein-mvp/frontend/src/app/page.tsx index 16ff3f9c..13f615b4 100644 --- a/tradein-mvp/frontend/src/app/page.tsx +++ b/tradein-mvp/frontend/src/app/page.tsx @@ -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 && ( + + )} = [ + { 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); + 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) { + 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" : ""}`} + > + {formatDeltaMln(delta)} + {deltaPct !== null && Math.abs(deltaPct) >= 0.1 + ? ` (${delta >= 0 ? "+" : "−"}${Math.abs(deltaPct).toFixed(1)}%)` + : ""} + + + новая оценка {formatMln(newPrice)} ₽ + + + ) : ( + + исходная оценка {formatMln(basePrice)} ₽ + + )} +
+ +
+ {!autoMode && ( + + )} + +
+
+ + {mutation.error && ( +
{mutation.error.message}
+ )} +
+ ); +} diff --git a/tradein-mvp/frontend/src/components/trade-in/trade-in.css b/tradein-mvp/frontend/src/components/trade-in/trade-in.css index 7a9c6778..1ed58201 100644 --- a/tradein-mvp/frontend/src/components/trade-in/trade-in.css +++ b/tradein-mvp/frontend/src/components/trade-in/trade-in.css @@ -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); } }