feat(tradein): interactive benefit calculator in OfferCard (Брусника секция 4) #684

Merged
bot-reviewer merged 1 commit from feat/tradein-tradein-calculator into main 2026-05-30 07:19:52 +00:00
3 changed files with 386 additions and 91 deletions

View file

@ -2,21 +2,26 @@
/**
* OfferCard Секция 4 «Оффер» (commercial Brusnika p.4 «формирование
* выкупной стоимости»).
* выкупной стоимости»). Интерактивный КАЛЬКУЛЯТОР ВЫГОДЫ: web-преимущество
* над PDF менеджер двигает цену в объявлении / % торга и сразу видит, как
* меняются издержки самостоятельной продажи и «ваша выгода» через trade-in.
*
* STUB / DEFER (#648 IA redesign): сама ВЫКУПНАЯ цена (сколько девелопер
* платит клиенту за квартиру в trade-in) пока НЕ рассчитывается это
* коммерческое решение, требующее условий «Практики» (haircut к ожидаемой
* цене сделки, маржа, срок фиксации). Не выдумываем число.
* NB: сама ВЫКУПНАЯ цена (сколько платит девелопер) НЕ рассчитывается это
* коммерческое решение «Практики» (нет параметров haircut/маржи/срока).
* Здесь illustrative сравнение издержек по типовым рыночным ставкам
* (см. offer-rates.ts), НЕ официальный оффер «Практики».
*
* TODO(#648 commercial): когда «Практика» даст коммерческие параметры
* добавить блок «Выкупная стоимость = ожидаемая цена сделки выкупной
* дисконт N%», feeding от estimate.expected_sold_price_rub. До этого карточка
* показывает только illustrative сравнение издержек самостоятельной продажи
* (типовые рыночные ставки, НЕ оффер «Практики») + преимущества + PDF CTA.
* TODO(commercial): подставить реальные условия «Практики» в DEFAULT_OFFER_RATES
* (offer-rates.ts) единственное место для смены ставок.
*/
import { useMemo, useState } from "react";
import type { AggregatedEstimate } from "@/types/trade-in";
import { API_BASE_URL } from "@/lib/api";
import {
BARGAIN_SLIDER,
DEFAULT_OFFER_RATES,
computeBreakdown,
} from "./offer-rates";
interface Props {
estimate: AggregatedEstimate;
@ -28,20 +33,47 @@ function fmtRub(v: number): string {
return Math.round(v).toLocaleString("ru-RU");
}
function formatMln(rub: number): string {
return `${(rub / 1_000_000).toFixed(2).replace(".", ",")} млн`;
}
/** Середина дефолтного диапазона торга — стартовое значение ползунка. */
const DEFAULT_BARGAIN_PCT =
(DEFAULT_OFFER_RATES.bargain.lo + DEFAULT_OFFER_RATES.bargain.hi) / 2;
export function OfferCard({ estimate, brandSlug }: Props) {
// PDF endpoint (api/v1/trade_in.py) уже honor'ит ?brand= для white-label.
// Без бренда query пустой → стандартный PDF.
const pdfQuery = brandSlug ? `?brand=${encodeURIComponent(brandSlug)}` : "";
const basePrice = estimate.median_price_rub;
const baseMln = basePrice / 1_000_000;
// Расчёт издержек самостоятельной продажи (от рыночной цены)
const torgLo = Math.round(basePrice * 0.05);
const torgHi = Math.round(basePrice * 0.15);
const realtorLo = Math.round((basePrice - torgHi) * 0.02);
const realtorHi = Math.round((basePrice - torgLo) * 0.03);
const totalLo = torgLo + realtorLo + 90_000 + 19_250 + 4_000;
const totalHi = torgHi + realtorHi + 150_000 + 50_000 + 36_000;
const totalPct = (((totalLo + totalHi) / 2) / basePrice) * 100;
// ── Интерактивные входы ──
// asking median_price_rub — стартовая цена в объявлении (редактируемая).
const [askingPrice, setAskingPrice] = useState<number>(
Math.round(estimate.median_price_rub),
);
const [bargainPct, setBargainPct] = useState<number>(DEFAULT_BARGAIN_PCT);
// ── LIVE-пересчёт: входы → разбивка издержек (pure-функция в offer-rates.ts) ──
const breakdown = useMemo(
() => computeBreakdown(askingPrice, bargainPct, DEFAULT_OFFER_RATES),
[askingPrice, bargainPct],
);
const askingMln = askingPrice / 1_000_000;
function onPriceInput(e: React.ChangeEvent<HTMLInputElement>) {
// Только цифры: пользователь может стереть/набрать новую цену.
const digits = e.target.value.replace(/\D/g, "");
setAskingPrice(digits === "" ? 0 : Number(digits));
}
function resetPrice() {
setAskingPrice(Math.round(estimate.median_price_rub));
setBargainPct(DEFAULT_BARGAIN_PCT);
}
const isCustomPrice =
Math.round(estimate.median_price_rub) !== askingPrice ||
bargainPct !== DEFAULT_BARGAIN_PCT;
return (
<article className="card" id="section-offer">
@ -52,31 +84,71 @@ export function OfferCard({ estimate, brandSlug }: Props) {
</div>
<div className="card-meta">
<div>
База расчёта: <b className="mono">{baseMln.toFixed(2)} млн </b>
Оценка по рынку: <b className="mono">{formatMln(estimate.median_price_rub)} </b>
</div>
<div style={{ marginTop: 4 }}>при стоимости квартиры в объявлении</div>
<div style={{ marginTop: 4 }}>медиана цен в объявлениях аналогов</div>
</div>
</div>
{/* STUB: выкупная цена ещё не считается (нужны коммерческие условия
«Практики»). Ниже только illustrative сравнение издержек, не оффер. */}
<div
role="note"
style={{
margin: "0 22px 4px",
padding: "10px 14px",
border: "1px dashed var(--border-strong, var(--border))",
background: "var(--surface-2)",
borderRadius: "var(--radius)",
fontSize: 12,
lineHeight: 1.5,
color: "var(--muted)",
}}
>
Итоговая <b>выкупная цена</b> по trade-in (сколько получит клиент)
формируется индивидуально на основе ожидаемой цены сделки и коммерческих
условий в этой версии не рассчитывается автоматически. Ниже
ориентировочное сравнение издержек самостоятельной продажи.
{/* ── Интерактивные контролы калькулятора ── */}
<div className="offer-calc-controls">
<div className="occ-field">
<label className="occ-label" htmlFor="occ-price">
Цена в объявлении
<span className="occ-hint">за сколько выставляете квартиру</span>
</label>
<div className="occ-price-input control-with-suffix">
<input
id="occ-price"
className="control mono"
inputMode="numeric"
value={askingPrice === 0 ? "" : fmtRub(askingPrice)}
onChange={onPriceInput}
aria-label="Цена в объявлении, рублей"
/>
<span className="suffix"></span>
</div>
<div className="occ-sub"> {askingMln.toFixed(2)} млн </div>
</div>
<div className="occ-field">
<label className="occ-label" htmlFor="occ-bargain">
Торг с покупателем
<span className="occ-hint">типично {pct(DEFAULT_OFFER_RATES.bargain.lo)}{pct(DEFAULT_OFFER_RATES.bargain.hi)}</span>
</label>
<input
id="occ-bargain"
className="occ-slider"
type="range"
min={BARGAIN_SLIDER.min}
max={BARGAIN_SLIDER.max}
step={BARGAIN_SLIDER.step}
value={bargainPct}
onChange={(e) => setBargainPct(Number(e.target.value))}
aria-label="Доля торга с покупателем"
/>
<div className="occ-sub">
<b className="mono">{pct(bargainPct)}</b> {fmtRub(askingPrice * bargainPct)}
</div>
</div>
{isCustomPrice && (
<button type="button" className="occ-reset" onClick={resetPrice}>
Сбросить к оценке
</button>
)}
</div>
{/* ── Прайм-блок: ваша выгода ── */}
<div className="offer-benefit">
<div className="ob-label">Ваша выгода через Trade-In</div>
<div className="ob-value mono">
{fmtRub(breakdown.totalLo)} {fmtRub(breakdown.totalHi)}
</div>
<div className="ob-sub">
столько вы теряете при самостоятельной продаже Trade-In берёт эти
издержки на себя
</div>
</div>
<table className="offer-tbl" aria-label="Сравнение trade-in и самостоятельной продажи">
@ -88,57 +160,27 @@ export function OfferCard({ estimate, brandSlug }: Props) {
</tr>
</thead>
<tbody>
<tr>
<td>
<div className="item-name">Торг покупателей</div>
<div className="item-sub">515% от цены объявления</div>
</td>
<td className="col-trade">не применимо</td>
<td className="col-self">
{fmtRub(torgLo)} {fmtRub(torgHi)}
</td>
</tr>
<tr>
<td>
<div className="item-name">Услуги риелтора</div>
<div className="item-sub">23% от цены сделки</div>
</td>
<td className="col-trade">бесплатно</td>
<td className="col-self">
{fmtRub(realtorLo)} {fmtRub(realtorHi)}
</td>
</tr>
<tr>
<td>
<div className="item-name">Аренда жилья после сделки</div>
<div className="item-sub">1-к квартира на 3 месяца</div>
</td>
<td className="col-trade">бесплатно</td>
<td className="col-self">90 000 150 000 </td>
</tr>
<tr>
<td>
<div className="item-name">Юридическое сопровождение</div>
<div className="item-sub">проверка, договор, регистрация</div>
</td>
<td className="col-trade">бесплатно</td>
<td className="col-self">от 19 250 </td>
</tr>
<tr>
<td>
<div className="item-name">Расходы на рекламу</div>
<div className="item-sub">платные показы Циан / Авито, 3 мес</div>
</td>
<td className="col-trade">бесплатно</td>
<td className="col-self">4 000 36 000 </td>
</tr>
{breakdown.rows.map((row) => (
<tr key={row.key}>
<td>
<div className="item-name">{row.name}</div>
<div className="item-sub">{row.sub}</div>
</td>
<td className="col-trade">{row.tradeLabel}</td>
<td className="col-self">
{row.selfLo === row.selfHi
? `${fmtRub(row.selfLo)}`
: `${fmtRub(row.selfLo)} ${fmtRub(row.selfHi)}`}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td>Общие финансовые потери</td>
<td className="col-trade"> 0 </td>
<td>Общие потери при самостоятельной продаже</td>
<td className="col-trade">0 · берём на себя</td>
<td className="col-self">
{fmtRub(totalLo)} {fmtRub(totalHi)}
{fmtRub(breakdown.totalLo)} {fmtRub(breakdown.totalHi)}
</td>
</tr>
</tfoot>
@ -153,8 +195,11 @@ export function OfferCard({ estimate, brandSlug }: Props) {
</svg>
</span>
<div>
Издержки при самостоятельной продаже сопоставимы со скидкой <b>{totalPct.toFixed(0)}%</b> от рыночной цены.{" "}
<b>Trade-In закрывает их полностью</b>, а цена в новостройке фиксируется на момент оценки.
Издержки при самостоятельной продаже сопоставимы со скидкой{" "}
<b>{breakdown.totalPctMid.toFixed(0)}%</b> от цены объявления.{" "}
<b>Trade-In закрывает их полностью</b>, а цена в новостройке фиксируется
на момент оценки. Проценты и суммы ориентировочные рыночные значения,
не официальный оффер «Практики».
</div>
</div>
@ -228,3 +273,7 @@ export function OfferCard({ estimate, brandSlug }: Props) {
</article>
);
}
function pct(frac: number): string {
return `${(frac * 100).toFixed(0)}%`;
}

View file

@ -0,0 +1,167 @@
/**
* offer-rates.ts illustrative market rates feeding the Trade-In BENEFIT
* CALCULATOR (OfferCard, Секция 4 «Формирование выкупной стоимости»).
*
* DEFAULTS = generic РФ-market rates (по образцу отчёта Brusnika p.4),
* НЕ официальный оффер «Практики». Это illustrative сравнение издержек
* самостоятельной продажи проценты/суммы ориентировочные.
*
* TODO(commercial): заменить значения ниже на реальные коммерческие условия
* «Практики» (точные % торга/риелтора, фактическая стоимость аренды/юр.
* сопровождения/рекламы). Это единственное место для правки ставок
* компонент и расчёт берут всё отсюда.
*/
/** Ставка-диапазон в долях от базы (0.05 = 5%). */
export interface PctRange {
lo: number;
hi: number;
}
/** Фиксированная статья расходов (₽), диапазон lohi. lo == hi → одно число. */
export interface RubRange {
lo: number;
hi: number;
}
export interface OfferRates {
/** Торг покупателей — доля от цены в объявлении. */
bargain: PctRange;
/** Услуги риелтора — доля от цены сделки (после торга). */
realtor: PctRange;
/** Аренда жилья на время поиска/сделки (₽ за весь срок). */
rentAfterDeal: RubRange;
/** Юридическое сопровождение (₽). */
legal: RubRange;
/** Реклама / платные показы за период экспозиции (₽). */
advertising: RubRange;
/** Срок, на который заложены аренда/реклама (для подписей). */
periodMonths: number;
}
/**
* DEFAULTS типовые рыночные ставки (Brusnika p.4). НЕ оффер «Практики».
* TODO(commercial): replace with Praktika terms.
*/
export const DEFAULT_OFFER_RATES: OfferRates = {
bargain: { lo: 0.05, hi: 0.15 }, // 515%
realtor: { lo: 0.02, hi: 0.03 }, // 23%
rentAfterDeal: { lo: 90_000, hi: 150_000 }, // ~3 мес
legal: { lo: 19_250, hi: 50_000 }, // «от ~19k»
advertising: { lo: 4_000, hi: 36_000 }, // платные показы, 3 мес
periodMonths: 3,
};
/** Граница ползунка «торг» (доля), чтобы менеджер мог подвигать в пределах рынка. */
export const BARGAIN_SLIDER = {
min: 0,
max: 0.2,
step: 0.01,
} as const;
/** Одна строка таблицы издержек после расчёта от введённой цены. */
export interface CostRow {
key: string;
/** Название статьи. */
name: string;
/** Подпись (как считается). */
sub: string;
/** Стоимость при самостоятельной продаже, ₽. */
selfLo: number;
selfHi: number;
/** Подпись в колонке Trade-In (что берёт сервис на себя). */
tradeLabel: string;
}
export interface OfferBreakdown {
rows: CostRow[];
/** Сумма всех издержек самостоятельной продажи. */
totalLo: number;
totalHi: number;
/** Средние потери как % от базовой цены (для баннера). */
totalPctMid: number;
}
/**
* computeBreakdown pure-функция: цена в объявлении + ставка торга разбивка
* издержек. Используется компонентом для LIVE-пересчёта при вводе.
*
* @param askingPrice цена квартиры в объявлении (), редактируемая пользователем
* @param bargainPct фактическая доля торга (0.10 = 10%), из ползунка
* @param rates конфиг ставок (по умолчанию DEFAULT_OFFER_RATES)
*/
export function computeBreakdown(
askingPrice: number,
bargainPct: number,
rates: OfferRates = DEFAULT_OFFER_RATES,
): OfferBreakdown {
const base = Math.max(0, askingPrice);
// Торг: диапазон рынка (lohi из конфига), но если пользователь двигает
// ползунок — показываем выбранное значение как «середину» с рыночной вилкой.
const bargainLoPct = Math.min(bargainPct, rates.bargain.lo);
const bargainHiPct = Math.max(bargainPct, rates.bargain.hi);
const bargainLo = Math.round(base * bargainLoPct);
const bargainHi = Math.round(base * bargainHiPct);
// Риелтор: % от цены ПОСЛЕ торга (то, что реально получит продавец).
const afterBargainHi = base - bargainLo; // меньший торг → больше база
const afterBargainLo = base - bargainHi; // больший торг → меньше база
const realtorLo = Math.round(afterBargainLo * rates.realtor.lo);
const realtorHi = Math.round(afterBargainHi * rates.realtor.hi);
const period = `${rates.periodMonths} мес`;
const rows: CostRow[] = [
{
key: "bargain",
name: "Торг покупателей",
sub: `${pct(bargainLoPct)}${pct(bargainHiPct)} от цены объявления`,
selfLo: bargainLo,
selfHi: bargainHi,
tradeLabel: "не применимо",
},
{
key: "realtor",
name: "Услуги риелтора",
sub: `${pct(rates.realtor.lo)}${pct(rates.realtor.hi)} от цены сделки`,
selfLo: realtorLo,
selfHi: realtorHi,
tradeLabel: "бесплатно",
},
{
key: "rent",
name: "Аренда жилья после сделки",
sub: `съём на время поиска, ${period}`,
selfLo: rates.rentAfterDeal.lo,
selfHi: rates.rentAfterDeal.hi,
tradeLabel: "бесплатно",
},
{
key: "legal",
name: "Юридическое сопровождение",
sub: "проверка, договор, регистрация",
selfLo: rates.legal.lo,
selfHi: rates.legal.hi,
tradeLabel: "бесплатно",
},
{
key: "ads",
name: "Расходы на рекламу",
sub: `платные показы Циан / Авито, ${period}`,
selfLo: rates.advertising.lo,
selfHi: rates.advertising.hi,
tradeLabel: "бесплатно",
},
];
const totalLo = rows.reduce((s, r) => s + r.selfLo, 0);
const totalHi = rows.reduce((s, r) => s + r.selfHi, 0);
const totalPctMid = base > 0 ? (((totalLo + totalHi) / 2) / base) * 100 : 0;
return { rows, totalLo, totalHi, totalPctMid };
}
function pct(frac: number): string {
return `${(frac * 100).toFixed(0)}%`;
}

View file

@ -1067,6 +1067,82 @@
/* ============================================================
OFFER Section (4)
============================================================ */
/* ── Interactive benefit-calculator controls ── */
.offer-calc-controls {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 20px;
align-items: end;
padding: 18px 22px;
background: var(--surface-2);
border-bottom: 1px solid var(--border);
}
.occ-field { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.occ-label {
font-size: 12px;
font-weight: 600;
color: var(--fg);
display: flex;
flex-direction: column;
gap: 2px;
}
.occ-label .occ-hint {
font-size: 11px;
font-weight: 400;
color: var(--muted-2);
}
.occ-price-input { width: 100%; }
.occ-sub { font-size: 11px; color: var(--muted); }
.occ-sub b { color: var(--fg); font-weight: 600; }
.occ-slider {
width: 100%;
margin: 8px 0 2px;
accent-color: var(--accent);
cursor: pointer;
}
.occ-reset {
align-self: end;
font-size: 12px;
padding: 9px 14px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--muted);
cursor: pointer;
white-space: nowrap;
}
.occ-reset:hover { color: var(--fg); border-color: var(--accent); }
/* ── Prominent «Ваша выгода» block ── */
.offer-benefit {
margin: 18px 22px 4px;
padding: 18px 22px;
border-radius: var(--radius-lg);
background: var(--success-soft);
border: 1px solid color-mix(in oklch, var(--success) 35%, var(--border));
}
.offer-benefit .ob-label {
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--success);
font-weight: 600;
}
.offer-benefit .ob-value {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--success);
margin: 4px 0 6px;
font-variant-numeric: tabular-nums;
}
.offer-benefit .ob-sub {
font-size: 12px;
color: var(--fg-2);
line-height: 1.5;
}
table.offer-tbl {
width: 100%;
border-collapse: collapse;
@ -1236,6 +1312,9 @@
.offer-cta-buttons .btn { flex: 1; }
.card-head { flex-direction: column; align-items: flex-start; }
.card-head .card-meta { text-align: left; }
.offer-calc-controls { grid-template-columns: 1fr; gap: 16px; }
.occ-reset { align-self: stretch; text-align: center; }
.offer-benefit .ob-value { font-size: 24px; }
}
/* ── Stage 4b: IMV benchmark + house info card ── */