From ef7d2d897d4bd12fe10bc6b9b58140cc55c64736 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 30 May 2026 07:19:51 +0000 Subject: [PATCH] =?UTF-8?q?feat(tradein):=20interactive=20benefit=20calcul?= =?UTF-8?q?ator=20in=20OfferCard=20(=D0=91=D1=80=D1=83=D1=81=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=81=D0=B5=D0=BA=D1=86=D0=B8=D1=8F=204)=20(#68?= =?UTF-8?q?4)=20Co-authored-by:=20bot-backend=20=20Co-committed-by:=20bot-backend=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/trade-in/OfferCard.tsx | 231 +++++++++++------- .../src/components/trade-in/offer-rates.ts | 167 +++++++++++++ .../src/components/trade-in/trade-in.css | 79 ++++++ 3 files changed, 386 insertions(+), 91 deletions(-) create mode 100644 tradein-mvp/frontend/src/components/trade-in/offer-rates.ts diff --git a/tradein-mvp/frontend/src/components/trade-in/OfferCard.tsx b/tradein-mvp/frontend/src/components/trade-in/OfferCard.tsx index 77b9cdaa..8dca2ac3 100644 --- a/tradein-mvp/frontend/src/components/trade-in/OfferCard.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/OfferCard.tsx @@ -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( + Math.round(estimate.median_price_rub), + ); + const [bargainPct, setBargainPct] = useState(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) { + // Только цифры: пользователь может стереть/набрать новую цену. + 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 (
@@ -52,31 +84,71 @@ export function OfferCard({ estimate, brandSlug }: Props) {
- База расчёта: {baseMln.toFixed(2)} млн ₽ + Оценка по рынку: {formatMln(estimate.median_price_rub)} ₽
-
при стоимости квартиры в объявлении
+
медиана цен в объявлениях аналогов
- {/* STUB: выкупная цена ещё не считается (нужны коммерческие условия - «Практики»). Ниже — только illustrative сравнение издержек, не оффер. */} -
- Итоговая выкупная цена по trade-in (сколько получит клиент) - формируется индивидуально на основе ожидаемой цены сделки и коммерческих - условий — в этой версии не рассчитывается автоматически. Ниже — - ориентировочное сравнение издержек самостоятельной продажи. + {/* ── Интерактивные контролы калькулятора ── */} +
+
+ +
+ + +
+
≈ {askingMln.toFixed(2)} млн ₽
+
+ +
+ + setBargainPct(Number(e.target.value))} + aria-label="Доля торга с покупателем" + /> +
+ {pct(bargainPct)} ≈ {fmtRub(askingPrice * bargainPct)} ₽ +
+
+ + {isCustomPrice && ( + + )} +
+ + {/* ── Прайм-блок: ваша выгода ── */} +
+
Ваша выгода через Trade-In
+
+ {fmtRub(breakdown.totalLo)} – {fmtRub(breakdown.totalHi)} ₽ +
+
+ столько вы теряете при самостоятельной продаже — Trade-In берёт эти + издержки на себя +
@@ -88,57 +160,27 @@ export function OfferCard({ estimate, brandSlug }: Props) { - - - - - - - - - - - - - - - - - - - - - - - - - + {breakdown.rows.map((row) => ( + + + + + + ))} - - + + @@ -153,8 +195,11 @@ export function OfferCard({ estimate, brandSlug }: Props) {
- Издержки при самостоятельной продаже сопоставимы со скидкой {totalPct.toFixed(0)}% от рыночной цены.{" "} - Trade-In закрывает их полностью, а цена в новостройке фиксируется на момент оценки. + Издержки при самостоятельной продаже сопоставимы со скидкой{" "} + {breakdown.totalPctMid.toFixed(0)}% от цены объявления.{" "} + Trade-In закрывает их полностью, а цена в новостройке фиксируется + на момент оценки. Проценты и суммы — ориентировочные рыночные значения, + не официальный оффер «Практики».
@@ -228,3 +273,7 @@ export function OfferCard({ estimate, brandSlug }: Props) { ); } + +function pct(frac: number): string { + return `${(frac * 100).toFixed(0)}%`; +} diff --git a/tradein-mvp/frontend/src/components/trade-in/offer-rates.ts b/tradein-mvp/frontend/src/components/trade-in/offer-rates.ts new file mode 100644 index 00000000..1dd9b585 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/offer-rates.ts @@ -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; +} + +/** Фиксированная статья расходов (₽), диапазон lo–hi. 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 }, // 5–15% + realtor: { lo: 0.02, hi: 0.03 }, // 2–3% + 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); + + // Торг: диапазон рынка (lo–hi из конфига), но если пользователь двигает + // ползунок — показываем выбранное значение как «середину» с рыночной вилкой. + 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)}%`; +} 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..4f046fe2 100644 --- a/tradein-mvp/frontend/src/components/trade-in/trade-in.css +++ b/tradein-mvp/frontend/src/components/trade-in/trade-in.css @@ -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 ── */
-
Торг покупателей
-
5–15% от цены объявления
-
не применимо - {fmtRub(torgLo)} – {fmtRub(torgHi)} ₽ -
-
Услуги риелтора
-
2–3% от цены сделки
-
бесплатно - {fmtRub(realtorLo)} – {fmtRub(realtorHi)} ₽ -
-
Аренда жилья после сделки
-
1-к квартира на 3 месяца
-
бесплатно90 000 – 150 000 ₽
-
Юридическое сопровождение
-
проверка, договор, регистрация
-
бесплатноот 19 250 ₽
-
Расходы на рекламу
-
платные показы Циан / Авито, 3 мес
-
бесплатно4 000 – 36 000 ₽
+
{row.name}
+
{row.sub}
+
{row.tradeLabel} + {row.selfLo === row.selfHi + ? `${fmtRub(row.selfLo)} ₽` + : `${fmtRub(row.selfLo)} – ${fmtRub(row.selfHi)} ₽`} +
Общие финансовые потери— 0 ₽Общие потери при самостоятельной продаже0 ₽ · берём на себя - {fmtRub(totalLo)} – {fmtRub(totalHi)} ₽ + {fmtRub(breakdown.totalLo)} – {fmtRub(breakdown.totalHi)} ₽