feat(tradein): «Что-если» interactive recompute on estimate result
Compact «Что-если» panel above HeroSummary: tweak repair state / floor / area / balcony → estimate updates live (the web advantage over a static PDF). Reuses useEstimateMutation (POST /trade-in/estimate), debounced 700ms + skip-if-unchanged, «Авто-пересчёт» toggle + manual «Пересчитать» (respects scraper/DaData cost). Keeps baseline for diffing, shows delta (+X млн ₽ / %) + «Сбросить»; works for fresh and restored-by-id estimates. Strict TS (tsc clean), brand-themed, responsive. Refs #657
This commit is contained in:
parent
f43d593cf6
commit
412d931ab9
3 changed files with 537 additions and 0 deletions
|
|
@ -26,6 +26,7 @@ import { ListingsCard } from "@/components/trade-in/ListingsCard";
|
||||||
import { DealsCard } from "@/components/trade-in/DealsCard";
|
import { DealsCard } from "@/components/trade-in/DealsCard";
|
||||||
import { StreetDealsCard } from "@/components/trade-in/StreetDealsCard";
|
import { StreetDealsCard } from "@/components/trade-in/StreetDealsCard";
|
||||||
import { OfferCard } from "@/components/trade-in/OfferCard";
|
import { OfferCard } from "@/components/trade-in/OfferCard";
|
||||||
|
import { WhatIfPanel } from "@/components/trade-in/WhatIfPanel";
|
||||||
import { TestPresets } from "@/components/trade-in/TestPresets";
|
import { TestPresets } from "@/components/trade-in/TestPresets";
|
||||||
import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api";
|
import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api";
|
||||||
import { useQuota } from "@/lib/useQuota";
|
import { useQuota } from "@/lib/useQuota";
|
||||||
|
|
@ -115,6 +116,29 @@ export default function TradeInPage() {
|
||||||
}
|
}
|
||||||
: null);
|
: 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 }) {
|
function handleResubmit(patch: { house_type?: HouseType; repair_state?: RepairState }) {
|
||||||
if (!resultData) return;
|
if (!resultData) return;
|
||||||
const enrichedInput: TradeInEstimateInput = { ...resultData.input, ...patch };
|
const enrichedInput: TradeInEstimateInput = { ...resultData.input, ...patch };
|
||||||
|
|
@ -202,6 +226,12 @@ export default function TradeInPage() {
|
||||||
|
|
||||||
{resultData ? (
|
{resultData ? (
|
||||||
<>
|
<>
|
||||||
|
{whatIfReady && whatIfInput && (
|
||||||
|
<WhatIfPanel
|
||||||
|
baseEstimate={resultData.estimate}
|
||||||
|
baseInput={whatIfInput}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<HeroSummary
|
<HeroSummary
|
||||||
estimate={resultData.estimate}
|
estimate={resultData.estimate}
|
||||||
input={resultData.input}
|
input={resultData.input}
|
||||||
|
|
|
||||||
364
tradein-mvp/frontend/src/components/trade-in/WhatIfPanel.tsx
Normal file
364
tradein-mvp/frontend/src/components/trade-in/WhatIfPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1883,3 +1883,146 @@
|
||||||
background: var(--accent-2-soft);
|
background: var(--accent-2-soft);
|
||||||
color: var(--accent-2);
|
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); } }
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue