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 «формирование
|
||||
* выкупной стоимости»).
|
||||
* выкупной стоимости»). Интерактивный КАЛЬКУЛЯТОР ВЫГОДЫ: 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">5–15% от цены объявления</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">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>
|
||||
{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)}%`;
|
||||
}
|
||||
|
|
|
|||
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)
|
||||
============================================================ */
|
||||
|
||||
/* ── 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 ── */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue