feat(trade-in): OfferCard — убрать редактируемую цену + ползунок торга, разбивка на дефолтах #939
2 changed files with 7 additions and 130 deletions
|
|
@ -2,9 +2,8 @@
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OfferCard — Секция 4 «Оффер» (commercial — Brusnika p.4 «формирование
|
* OfferCard — Секция 4 «Оффер» (commercial — Brusnika p.4 «формирование
|
||||||
* выкупной стоимости»). Интерактивный КАЛЬКУЛЯТОР ВЫГОДЫ: web-преимущество
|
* выкупной стоимости»). Показывает разбивку издержек самостоятельной продажи
|
||||||
* над PDF — менеджер двигает цену в объявлении / % торга и сразу видит, как
|
* на фиксированных дефолтных ставках (median_price_rub + DEFAULT_BARGAIN_PCT).
|
||||||
* меняются издержки самостоятельной продажи и «ваша выгода» через trade-in.
|
|
||||||
*
|
*
|
||||||
* NB: сама ВЫКУПНАЯ цена (сколько платит девелопер) НЕ рассчитывается — это
|
* NB: сама ВЫКУПНАЯ цена (сколько платит девелопер) НЕ рассчитывается — это
|
||||||
* коммерческое решение «Практики» (нет параметров haircut/маржи/срока).
|
* коммерческое решение «Практики» (нет параметров haircut/маржи/срока).
|
||||||
|
|
@ -14,11 +13,10 @@
|
||||||
* TODO(commercial): подставить реальные условия «Практики» в DEFAULT_OFFER_RATES
|
* TODO(commercial): подставить реальные условия «Практики» в DEFAULT_OFFER_RATES
|
||||||
* (offer-rates.ts) — единственное место для смены ставок.
|
* (offer-rates.ts) — единственное место для смены ставок.
|
||||||
*/
|
*/
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo } 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 {
|
import {
|
||||||
BARGAIN_SLIDER,
|
|
||||||
DEFAULT_OFFER_RATES,
|
DEFAULT_OFFER_RATES,
|
||||||
computeBreakdown,
|
computeBreakdown,
|
||||||
} from "./offer-rates";
|
} from "./offer-rates";
|
||||||
|
|
@ -45,36 +43,12 @@ 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.
|
||||||
const pdfQuery = brandSlug ? `?brand=${encodeURIComponent(brandSlug)}` : "";
|
const pdfQuery = brandSlug ? `?brand=${encodeURIComponent(brandSlug)}` : "";
|
||||||
|
|
||||||
// ── Интерактивные входы ──
|
// ── Разбивка на фиксированных дефолтах ──
|
||||||
// asking median_price_rub — стартовая цена в объявлении (редактируемая).
|
const fixedPrice = Math.round(estimate.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(
|
const breakdown = useMemo(
|
||||||
() => computeBreakdown(askingPrice, bargainPct, DEFAULT_OFFER_RATES),
|
() => computeBreakdown(fixedPrice, DEFAULT_BARGAIN_PCT, DEFAULT_OFFER_RATES),
|
||||||
[askingPrice, bargainPct],
|
[fixedPrice],
|
||||||
);
|
);
|
||||||
|
|
||||||
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">
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
|
|
@ -93,55 +67,6 @@ export function OfferCard({ estimate, brandSlug }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Интерактивные контролы калькулятора ── */}
|
|
||||||
<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="offer-benefit">
|
||||||
<div className="ob-label">Ваша выгода через Trade-In</div>
|
<div className="ob-label">Ваша выгода через Trade-In</div>
|
||||||
|
|
|
||||||
|
|
@ -1184,52 +1184,6 @@
|
||||||
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 ── */
|
/* ── Prominent «Ваша выгода» block ── */
|
||||||
.offer-benefit {
|
.offer-benefit {
|
||||||
margin: 18px 22px 4px;
|
margin: 18px 22px 4px;
|
||||||
|
|
@ -1428,8 +1382,6 @@
|
||||||
.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; }
|
.offer-benefit .ob-value { font-size: 24px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue