feat(tradein): interactive benefit calculator in OfferCard (Брусника секция 4) (#684)
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 1m32s
Deploy Trade-In / deploy (push) Successful in 35s
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 1m32s
Deploy Trade-In / deploy (push) Successful in 35s
Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
This commit is contained in:
parent
f43d593cf6
commit
ef7d2d897d
3 changed files with 386 additions and 91 deletions
|
|
@ -2,21 +2,26 @@
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OfferCard — Секция 4 «Оффер» (commercial — Brusnika p.4 «формирование
|
* OfferCard — Секция 4 «Оффер» (commercial — Brusnika p.4 «формирование
|
||||||
* выкупной стоимости»).
|
* выкупной стоимости»). Интерактивный КАЛЬКУЛЯТОР ВЫГОДЫ: web-преимущество
|
||||||
|
* над PDF — менеджер двигает цену в объявлении / % торга и сразу видит, как
|
||||||
|
* меняются издержки самостоятельной продажи и «ваша выгода» через trade-in.
|
||||||
*
|
*
|
||||||
* STUB / DEFER (#648 IA redesign): сама ВЫКУПНАЯ цена (сколько девелопер
|
* NB: сама ВЫКУПНАЯ цена (сколько платит девелопер) НЕ рассчитывается — это
|
||||||
* платит клиенту за квартиру в trade-in) пока НЕ рассчитывается — это
|
* коммерческое решение «Практики» (нет параметров haircut/маржи/срока).
|
||||||
* коммерческое решение, требующее условий «Практики» (haircut к ожидаемой
|
* Здесь — illustrative сравнение издержек по типовым рыночным ставкам
|
||||||
* цене сделки, маржа, срок фиксации). Не выдумываем число.
|
* (см. offer-rates.ts), НЕ официальный оффер «Практики».
|
||||||
*
|
*
|
||||||
* TODO(#648 commercial): когда «Практика» даст коммерческие параметры —
|
* TODO(commercial): подставить реальные условия «Практики» в DEFAULT_OFFER_RATES
|
||||||
* добавить блок «Выкупная стоимость = ожидаемая цена сделки − выкупной
|
* (offer-rates.ts) — единственное место для смены ставок.
|
||||||
* дисконт N%», feeding от estimate.expected_sold_price_rub. До этого карточка
|
|
||||||
* показывает только illustrative сравнение издержек самостоятельной продажи
|
|
||||||
* (типовые рыночные ставки, НЕ оффер «Практики») + преимущества + PDF CTA.
|
|
||||||
*/
|
*/
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
import type { AggregatedEstimate } from "@/types/trade-in";
|
import type { AggregatedEstimate } from "@/types/trade-in";
|
||||||
import { API_BASE_URL } from "@/lib/api";
|
import { API_BASE_URL } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
BARGAIN_SLIDER,
|
||||||
|
DEFAULT_OFFER_RATES,
|
||||||
|
computeBreakdown,
|
||||||
|
} from "./offer-rates";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
estimate: AggregatedEstimate;
|
estimate: AggregatedEstimate;
|
||||||
|
|
@ -28,20 +33,47 @@ function fmtRub(v: number): string {
|
||||||
return Math.round(v).toLocaleString("ru-RU");
|
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) {
|
export function OfferCard({ estimate, brandSlug }: Props) {
|
||||||
// PDF endpoint (api/v1/trade_in.py) уже honor'ит ?brand= для white-label.
|
// PDF endpoint (api/v1/trade_in.py) уже honor'ит ?brand= для white-label.
|
||||||
// Без бренда query пустой → стандартный PDF.
|
|
||||||
const pdfQuery = brandSlug ? `?brand=${encodeURIComponent(brandSlug)}` : "";
|
const pdfQuery = brandSlug ? `?brand=${encodeURIComponent(brandSlug)}` : "";
|
||||||
const basePrice = estimate.median_price_rub;
|
|
||||||
const baseMln = basePrice / 1_000_000;
|
// ── Интерактивные входы ──
|
||||||
// Расчёт издержек самостоятельной продажи (от рыночной цены)
|
// asking median_price_rub — стартовая цена в объявлении (редактируемая).
|
||||||
const torgLo = Math.round(basePrice * 0.05);
|
const [askingPrice, setAskingPrice] = useState<number>(
|
||||||
const torgHi = Math.round(basePrice * 0.15);
|
Math.round(estimate.median_price_rub),
|
||||||
const realtorLo = Math.round((basePrice - torgHi) * 0.02);
|
);
|
||||||
const realtorHi = Math.round((basePrice - torgLo) * 0.03);
|
const [bargainPct, setBargainPct] = useState<number>(DEFAULT_BARGAIN_PCT);
|
||||||
const totalLo = torgLo + realtorLo + 90_000 + 19_250 + 4_000;
|
|
||||||
const totalHi = torgHi + realtorHi + 150_000 + 50_000 + 36_000;
|
// ── LIVE-пересчёт: входы → разбивка издержек (pure-функция в offer-rates.ts) ──
|
||||||
const totalPct = (((totalLo + totalHi) / 2) / basePrice) * 100;
|
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 (
|
return (
|
||||||
<article className="card" id="section-offer">
|
<article className="card" id="section-offer">
|
||||||
|
|
@ -52,31 +84,71 @@ export function OfferCard({ estimate, brandSlug }: Props) {
|
||||||
</div>
|
</div>
|
||||||
<div className="card-meta">
|
<div className="card-meta">
|
||||||
<div>
|
<div>
|
||||||
База расчёта: <b className="mono">{baseMln.toFixed(2)} млн ₽</b>
|
Оценка по рынку: <b className="mono">{formatMln(estimate.median_price_rub)} ₽</b>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 4 }}>при стоимости квартиры в объявлении</div>
|
<div style={{ marginTop: 4 }}>медиана цен в объявлениях аналогов</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* STUB: выкупная цена ещё не считается (нужны коммерческие условия
|
{/* ── Интерактивные контролы калькулятора ── */}
|
||||||
«Практики»). Ниже — только illustrative сравнение издержек, не оффер. */}
|
<div className="offer-calc-controls">
|
||||||
<div
|
<div className="occ-field">
|
||||||
role="note"
|
<label className="occ-label" htmlFor="occ-price">
|
||||||
style={{
|
Цена в объявлении
|
||||||
margin: "0 22px 4px",
|
<span className="occ-hint">за сколько выставляете квартиру</span>
|
||||||
padding: "10px 14px",
|
</label>
|
||||||
border: "1px dashed var(--border-strong, var(--border))",
|
<div className="occ-price-input control-with-suffix">
|
||||||
background: "var(--surface-2)",
|
<input
|
||||||
borderRadius: "var(--radius)",
|
id="occ-price"
|
||||||
fontSize: 12,
|
className="control mono"
|
||||||
lineHeight: 1.5,
|
inputMode="numeric"
|
||||||
color: "var(--muted)",
|
value={askingPrice === 0 ? "" : fmtRub(askingPrice)}
|
||||||
}}
|
onChange={onPriceInput}
|
||||||
>
|
aria-label="Цена в объявлении, рублей"
|
||||||
Итоговая <b>выкупная цена</b> по trade-in (сколько получит клиент)
|
/>
|
||||||
формируется индивидуально на основе ожидаемой цены сделки и коммерческих
|
<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>
|
</div>
|
||||||
|
|
||||||
<table className="offer-tbl" aria-label="Сравнение trade-in и самостоятельной продажи">
|
<table className="offer-tbl" aria-label="Сравнение trade-in и самостоятельной продажи">
|
||||||
|
|
@ -88,57 +160,27 @@ export function OfferCard({ estimate, brandSlug }: Props) {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
{breakdown.rows.map((row) => (
|
||||||
<td>
|
<tr key={row.key}>
|
||||||
<div className="item-name">Торг покупателей</div>
|
<td>
|
||||||
<div className="item-sub">5–15% от цены объявления</div>
|
<div className="item-name">{row.name}</div>
|
||||||
</td>
|
<div className="item-sub">{row.sub}</div>
|
||||||
<td className="col-trade">не применимо</td>
|
</td>
|
||||||
<td className="col-self">
|
<td className="col-trade">{row.tradeLabel}</td>
|
||||||
{fmtRub(torgLo)} – {fmtRub(torgHi)} ₽
|
<td className="col-self">
|
||||||
</td>
|
{row.selfLo === row.selfHi
|
||||||
</tr>
|
? `${fmtRub(row.selfLo)} ₽`
|
||||||
<tr>
|
: `${fmtRub(row.selfLo)} – ${fmtRub(row.selfHi)} ₽`}
|
||||||
<td>
|
</td>
|
||||||
<div className="item-name">Услуги риелтора</div>
|
</tr>
|
||||||
<div className="item-sub">2–3% от цены сделки</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>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Общие финансовые потери</td>
|
<td>Общие потери при самостоятельной продаже</td>
|
||||||
<td className="col-trade">— 0 ₽</td>
|
<td className="col-trade">0 ₽ · берём на себя</td>
|
||||||
<td className="col-self">
|
<td className="col-self">
|
||||||
{fmtRub(totalLo)} – {fmtRub(totalHi)} ₽
|
{fmtRub(breakdown.totalLo)} – {fmtRub(breakdown.totalHi)} ₽
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
|
|
@ -153,8 +195,11 @@ export function OfferCard({ estimate, brandSlug }: Props) {
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
Издержки при самостоятельной продаже сопоставимы со скидкой <b>{totalPct.toFixed(0)}%</b> от рыночной цены.{" "}
|
Издержки при самостоятельной продаже сопоставимы со скидкой{" "}
|
||||||
<b>Trade-In закрывает их полностью</b>, а цена в новостройке фиксируется на момент оценки.
|
<b>{breakdown.totalPctMid.toFixed(0)}%</b> от цены объявления.{" "}
|
||||||
|
<b>Trade-In закрывает их полностью</b>, а цена в новостройке фиксируется
|
||||||
|
на момент оценки. Проценты и суммы — ориентировочные рыночные значения,
|
||||||
|
не официальный оффер «Практики».
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -228,3 +273,7 @@ export function OfferCard({ estimate, brandSlug }: Props) {
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pct(frac: number): string {
|
||||||
|
return `${(frac * 100).toFixed(0)}%`;
|
||||||
|
}
|
||||||
|
|
|
||||||
167
tradein-mvp/frontend/src/components/trade-in/offer-rates.ts
Normal file
167
tradein-mvp/frontend/src/components/trade-in/offer-rates.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Фиксированная статья расходов (₽), диапазон 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)}%`;
|
||||||
|
}
|
||||||
|
|
@ -1067,6 +1067,82 @@
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
OFFER Section (4)
|
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 {
|
table.offer-tbl {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
|
@ -1236,6 +1312,9 @@
|
||||||
.offer-cta-buttons .btn { flex: 1; }
|
.offer-cta-buttons .btn { flex: 1; }
|
||||||
.card-head { flex-direction: column; align-items: flex-start; }
|
.card-head { flex-direction: column; align-items: flex-start; }
|
||||||
.card-head .card-meta { text-align: left; }
|
.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 ── */
|
/* ── Stage 4b: IMV benchmark + house info card ── */
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue