Visible warn-banner trailing sentence no longer names the brand. Code comments in the file docstring updated to be brand-neutral. No logic or behaviour changed.
210 lines
10 KiB
TypeScript
210 lines
10 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* OfferCard — Секция 4 «Оффер» (commercial — Brusnika p.4 «формирование
|
||
* выкупной стоимости»). Показывает разбивку издержек самостоятельной продажи
|
||
* на фиксированных дефолтных ставках (median_price_rub + DEFAULT_BARGAIN_PCT).
|
||
*
|
||
* NB: сама ВЫКУПНАЯ цена (сколько платит девелопер) НЕ рассчитывается — это
|
||
* коммерческое решение девелопера/бренда (нет параметров haircut/маржи/срока).
|
||
* Здесь — illustrative сравнение издержек по типовым рыночным ставкам
|
||
* (см. offer-rates.ts), НЕ официальный оффер бренда.
|
||
*
|
||
* TODO(commercial): подставить реальные условия в DEFAULT_OFFER_RATES
|
||
* (offer-rates.ts) — единственное место для смены ставок.
|
||
*/
|
||
import { useMemo } from "react";
|
||
import type { AggregatedEstimate } from "@/types/trade-in";
|
||
import { API_BASE_URL } from "@/lib/api";
|
||
import {
|
||
DEFAULT_OFFER_RATES,
|
||
computeBreakdown,
|
||
} from "./offer-rates";
|
||
|
||
interface Props {
|
||
estimate: AggregatedEstimate;
|
||
/** #657 white-label: активный ?brand= slug — прокидывается в PDF endpoint. */
|
||
brandSlug?: string | null;
|
||
}
|
||
|
||
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.
|
||
const pdfQuery = brandSlug ? `?brand=${encodeURIComponent(brandSlug)}` : "";
|
||
|
||
// ── Разбивка на фиксированных дефолтах ──
|
||
const fixedPrice = Math.round(estimate.median_price_rub);
|
||
const breakdown = useMemo(
|
||
() => computeBreakdown(fixedPrice, DEFAULT_BARGAIN_PCT, DEFAULT_OFFER_RATES),
|
||
[fixedPrice],
|
||
);
|
||
return (
|
||
<article className="card" id="section-offer">
|
||
<div className="card-head">
|
||
<div>
|
||
<div className="section-kicker">Секция 4 · Оффер</div>
|
||
<h2>Формирование выкупной стоимости</h2>
|
||
</div>
|
||
<div className="card-meta">
|
||
<div>
|
||
Оценка по рынку:{" "}
|
||
<b className="mono">
|
||
{estimate.insufficient_data ? "—" : `${formatMln(estimate.median_price_rub)} ₽`}
|
||
</b>
|
||
</div>
|
||
<div style={{ marginTop: 4 }}>медиана цен в объявлениях аналогов</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Прайм-блок: ваша выгода ── */}
|
||
{/* intentional: «Trade-In» здесь = механика сделки, не имя бренда (rebrand #1079) */}
|
||
<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 и самостоятельной продажи">
|
||
<thead>
|
||
<tr>
|
||
<th>Статья расходов</th>
|
||
{/* intentional: «Trade-In» здесь = механика сделки, не имя бренда (rebrand #1079) */}
|
||
<th className="col-trade">Через Trade-In</th>
|
||
<th className="col-self">Самостоятельная продажа</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{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 className="col-self">
|
||
{fmtRub(breakdown.totalLo)} – {fmtRub(breakdown.totalHi)} ₽
|
||
</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
|
||
<div className="warn-banner">
|
||
<span className="ic">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||
<line x1="12" y1="9" x2="12" y2="13" />
|
||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||
</svg>
|
||
</span>
|
||
{/* intentional: «Trade-In» здесь = механика сделки, не имя бренда (rebrand #1079) */}
|
||
<div>
|
||
Издержки при самостоятельной продаже сопоставимы со скидкой{" "}
|
||
<b>{breakdown.totalPctMid.toFixed(0)}%</b> от цены объявления.{" "}
|
||
<b>Trade-In закрывает их полностью</b>, а цена в новостройке фиксируется
|
||
на момент оценки. Проценты и суммы — ориентировочные рыночные значения,
|
||
а не окончательный оффер.
|
||
</div>
|
||
</div>
|
||
|
||
<div className="advantages">
|
||
<div className="advantage">
|
||
<div className="adv-ic">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="7.5" cy="15.5" r="5.5" />
|
||
<path d="m21 2-9.6 9.6" />
|
||
<path d="m15.5 7.5 3 3L22 7l-3-3" />
|
||
</svg>
|
||
</div>
|
||
<h3>Экономия времени</h3>
|
||
<p>Берём на себя показы, переговоры и поиск покупателей. Менеджеру остаётся только подписать ДКП.</p>
|
||
</div>
|
||
<div className="advantage">
|
||
<div className="adv-ic">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||
<polyline points="9 22 9 12 15 12 15 22" />
|
||
</svg>
|
||
</div>
|
||
<h3>Юридическая безопасность</h3>
|
||
<p>Проверяем чистоту документов, ЕГРН, прописанных и обременения. Исключаем риски.</p>
|
||
</div>
|
||
<div className="advantage">
|
||
<div className="adv-ic">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<line x1="19" y1="5" x2="5" y2="19" />
|
||
<circle cx="6.5" cy="6.5" r="2.5" />
|
||
<circle cx="17.5" cy="17.5" r="2.5" />
|
||
</svg>
|
||
</div>
|
||
<h3>Фиксированная цена новостройки</h3>
|
||
<p>Сохраняем планировку в каталоге по цене на момент оценки — до закрытия сделки.</p>
|
||
</div>
|
||
<div className="advantage">
|
||
<div className="adv-ic">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="12" cy="12" r="10" />
|
||
<circle cx="12" cy="12" r="6" />
|
||
<circle cx="12" cy="12" r="2" />
|
||
</svg>
|
||
</div>
|
||
<h3>Гарантия цены</h3>
|
||
<p>Снимаем риск колебаний рынка — выкупная стоимость зафиксирована в оффере на 30 дней.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="offer-cta">
|
||
<div className="offer-cta-text">
|
||
<b>Готовы отправить оффер клиенту?</b>
|
||
<br />
|
||
PDF-отчёт + персональный оффер по выкупной цене — за 1 клик.
|
||
</div>
|
||
<div className="offer-cta-buttons">
|
||
<a
|
||
href={`${API_BASE_URL}/api/v1/trade-in/estimate/${estimate.estimate_id}/pdf${pdfQuery}`}
|
||
download={`trade-in-${estimate.estimate_id}.pdf`}
|
||
className="btn btn-ghost"
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||
<polyline points="7 10 12 15 17 10" />
|
||
<line x1="12" y1="15" x2="12" y2="3" />
|
||
</svg>
|
||
Скачать PDF
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function pct(frac: number): string {
|
||
return `${(frac * 100).toFixed(0)}%`;
|
||
}
|