gendesign/tradein-mvp/frontend/src/components/trade-in/OfferCard.tsx
bot-backend c5d469da36
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 5s
CI / backend-tests (pull_request) Has been skipped
fix(tradein): remove hardcoded «Практика» from offer banner + comments
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.
2026-06-19 12:41:27 +03:00

210 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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)}%`;
}