feat(mera/b2c): результат проверки в облике отчёта (макет 29.08 «МЕРА отчёт»)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 59s

Редизайн результата /estimate по макету отчёта: шапка объекта (адрес + чипы
параметров + «Изменить данные»), две большие бесплатные карточки, тизер
платной части с shimmer-строками, честные состояния «мало данных» и «город
не покрыт» в стилистике «ЧЕСТНЫЙ ОТВЕТ».

Сознательные расхождения с макетом (задокументированы в шапке компонента):
- «✓ расчёт готов и ждёт вас» не воспроизводится — платного расчёта для
  анонима не существует (анонимный /estimate закрыт, платёжного контура
  нет, #2896); тизер честно называется «что будет в полном отчёте», вместо
  кнопки оплаты — прежняя формулировка «приём оплаты подключается»;
- бейджи МИР/СБП/VISA — за тем же TODO платёжного контура, что и в FooterV3;
- email-подписка «сообщим когда добавим» — нет хранения и согласия (#2895),
  вместо неё живой канал поддержки;
- «Расширить радиус до 3 км» — /coverage радиус не параметризует;
- подписи бесплатных плиток — запиненные честные из coverage-copy.

Возврат 150 ₽ — строкой из макета со ссылкой на /refund. Старый
CoverageResult удалён (один рендер результата, не два). Фикстурное превью
всех трёх тонов — /mera-public/ui-preview/report.

Проверено: tsc чисто, lint чисто, vitest 27 passed, скрин трёх состояний.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-08-29 16:35:36 +05:00
parent be7d7d0205
commit de66826b25
4 changed files with 558 additions and 42 deletions

View file

@ -37,6 +37,8 @@ import {
import type { AddressSuggestion } from "../../public-api";
import styles from "../../landing-v3.module.css";
import { ReportResult } from "./ReportResult";
/** Задержка перед запросом подсказок. Каждый вызов платный (DaData-тир). */
const SUGGEST_DEBOUNCE_MS = 300;
const SUGGEST_MIN_CHARS = 3;
@ -442,49 +444,20 @@ export function EstimateFlow() {
</div>
)}
{phase.kind === "result" && <CoverageResult verdict={phase.verdict} />}
{phase.kind === "result" && (
<ReportResult
verdict={phase.verdict}
object={{
address: query,
roomsLabel:
ROOM_OPTIONS.find((o) => String(o.value) === rooms)?.label ?? null,
area: area || null,
floor: null,
}}
onEdit={() => setPhase(FORM)}
/>
)}
</div>
</div>
);
}
function CoverageResult({ verdict }: { verdict: CoverageVerdict }) {
return (
<div className={styles.estResult}>
<p className={styles.heroResultEyebrow}>
{verdict.tone === "none" ? "ПРОВЕРИЛИ" : "ГОТОВО · БЕСПЛАТНАЯ ЧАСТЬ"}
</p>
<p className={styles.heroFormFeedbackTitle}>{verdict.title}</p>
{verdict.tiles.length > 0 && (
<div className={styles.heroResultGrid}>
{verdict.tiles.map((tile) => (
<div key={tile.label} className={styles.heroResultTile}>
<div className={styles.heroResultTileValue}>{tile.value}</div>
<div className={styles.heroResultTileLabel}>{tile.label}</div>
</div>
))}
</div>
)}
{verdict.lines.map((line) => (
<p key={line} className={styles.heroFormFeedbackText}>
{line}
</p>
))}
{verdict.offerPaidStep && (
<div className={styles.estPaidStep}>
<p className={styles.heroResultOverlayTitle}>
Цена и срок продажи следующий шаг
</p>
<p className={styles.heroFormFeedbackText}>
Полный расчёт стоит 150&nbsp;: диапазон цены, цена за квадратный
метр и список квартир, на которых он построен. Приём оплаты сейчас
подключается как только заработает, кнопка появится здесь.
</p>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,214 @@
/**
* ReportResult результат пробы покрытия в облике «отчёта» (макет 29.08,
* «МЕРА отчёт.dc.html»): шапка объекта с чипами параметров, бесплатные
* карточки, тизер платной части, честные отказы «город не покрыт» и
* «мало данных».
*
* ЧЕСТНЫЕ АДАПТАЦИИ МАКЕТА (каждая сознательное расхождение):
*
* 1. Макетная плашка « РАСЧЁТ ПО ЭТОЙ КВАРТИРЕ ГОТОВ» и « рассчитана» у
* shimmer-строк НЕ воспроизводятся: платного расчёта для анонима не
* существует (анонимный /estimate закрыт, платёжного контура нет #2896).
* Утверждать «готов и ждёт вас» было бы ложью. Вместо этого тизер честно
* называется «ЧТО БУДЕТ В ПОЛНОМ ОТЧЁТЕ», а вместо кнопки оплаты та же
* формулировка, что жила здесь раньше: «приём оплаты подключается».
* 2. Бейджи МИР/СБП/VISA из макета не рендерятся до платёжного контура
* ровно тот же TODO, что в FooterV3.
* 3. Email-подписки «сообщим, когда добавим город/данные» из макета нет:
* для неё нужны хранение и согласие (#2895), которых нет. Вместо неё
* живой канал поддержки (SUPPORT_TELEGRAM_URL).
* 4. Кнопка «Расширить радиус до 3 км» не рендерится: `/coverage` радиус не
* параметризует, а обещать перепроверку, которой не будет, нельзя.
* 5. Подписи бесплатных плиток из coverage-copy (запинены тестом): там
* «возраст активного объявления», а НЕ макетное «срок продажи по дому»
* выборка цензурирована, честное имя величины важнее красоты.
*
* Возврат 150 при ненадёжном расчёте политика с /refund; в тизере она
* упоминается той же строкой, что в макете, со ссылкой на полный текст.
*/
import { SUPPORT_TELEGRAM_LABEL, SUPPORT_TELEGRAM_URL } from "../../content";
import type { CoverageVerdict } from "../../coverage-copy";
import styles from "../../landing-v3.module.css";
import { PublicLink } from "../PublicLink";
export interface ReportObjectParams {
readonly address: string;
readonly roomsLabel: string | null;
readonly area: string | null;
readonly floor: string | null;
}
interface Props {
readonly verdict: CoverageVerdict;
readonly object: ReportObjectParams;
readonly onEdit: () => void;
}
function ObjectHeader({ object, onEdit }: { object: ReportObjectParams; onEdit: () => void }) {
const chips = [
object.roomsLabel,
object.area ? `${object.area} м²` : null,
object.floor ? `Этаж ${object.floor}` : null,
].filter((c): c is string => c !== null);
return (
<header className={styles.repHead}>
<div className={styles.repHeadTop}>
<div>
<div className={styles.repEyebrow}>БЕСПЛАТНАЯ ПРОВЕРКА</div>
<h2 className={styles.repAddress}>{object.address}</h2>
</div>
</div>
{(
<div className={styles.repChipsBar}>
<div className={styles.repChips}>
{chips.map((c, i) => (
<span key={c} className={styles.repChip}>
{i > 0 && (
<span className={styles.repChipDot} aria-hidden="true">
·
</span>
)}
{c}
</span>
))}
{chips.length === 0 && (
<span className={styles.repChipMuted}>параметры не указаны</span>
)}
</div>
<button type="button" onClick={onEdit} className={styles.repEditBtn}>
Изменить данные
</button>
</div>
)}
</header>
);
}
/** Отказ «город не покрыт» / «дом не найден» (tone none). */
function RefusalCard({ verdict }: { verdict: CoverageVerdict }) {
return (
<div className={styles.repRefusal}>
<div className={styles.repRefusalEyebrow}>ЧЕСТНЫЙ ОТВЕТ</div>
<h3 className={styles.repRefusalTitle}>{verdict.title}</h3>
{verdict.lines.map((line) => (
<p key={line} className={styles.repRefusalText}>
{line}
</p>
))}
<p className={styles.repRefusalText}>
Продавать отчёт «на глазок» мы не будем поэтому и кнопки оплаты здесь
нет. Если хотите, чтобы мы сообщили, когда данные появятся, напишите в
поддержку:{" "}
<a href={SUPPORT_TELEGRAM_URL} target="_blank" rel="noreferrer">
{SUPPORT_TELEGRAM_LABEL}
</a>
.
</p>
</div>
);
}
/** Бесплатные карточки (tone ok/thin) — числа и подписи из verdict.tiles. */
function FreeCards({ verdict }: { verdict: CoverageVerdict }) {
return (
<div className={styles.repFreeGrid}>
{verdict.tiles.map((tile) => (
<div key={tile.label} className={styles.repFreeCard}>
<div className={styles.repFreeEyebrow}> БЕСПЛАТНО</div>
<div className={styles.repFreeRow}>
<span className={styles.repFreeValue}>{tile.value}</span>
<span className={styles.repFreeLabel}>{tile.label}</span>
</div>
</div>
))}
</div>
);
}
const PAID_ROWS = [
{ label: "РЕКОМЕНДОВАННАЯ ЦЕНА", w: "60%" },
{ label: "ПРОГНОЗ СРОКА ПРОДАЖИ ПРИ ЭТОЙ ЦЕНЕ", w: "45%" },
{ label: "ДИАПАЗОН И ПОЛОЖЕНИЕ НА РЫНКЕ", w: "70%" },
{ label: "АНАЛОГИ С АДРЕСАМИ И ЦЕНАМИ", w: "88%" },
] as const;
/** Тизер платной части (tone ok): shimmer-строки + честная правая панель. */
function PaidTeaser() {
return (
<div className={styles.repPaid}>
<div className={styles.repPaidBar}>
<span className={styles.repPaidBarText}>ПОЛНЫЙ ОТЧЁТ · 150 </span>
</div>
<div className={styles.repPaidGrid}>
<div className={styles.repPaidLeft}>
{PAID_ROWS.map((row) => (
<div key={row.label}>
<div className={styles.repPaidRowLabel}>{row.label}</div>
<div className={styles.repPaidRowLine}>
<span
className={styles.repShimmer}
style={{ width: row.w }}
aria-hidden="true"
/>
<span className={styles.repPaidRowChip}>в отчёте</span>
</div>
</div>
))}
</div>
<div className={styles.repPaidRight}>
<div className={styles.repPaidTitle}>
Цена и срок продажи следующий шаг
</div>
<p className={styles.repPaidText}>
Полный расчёт стоит 150&nbsp;: диапазон цены, цена за квадратный
метр и список квартир, на которых он построен. Один платёж, без
подписки. Приём оплаты сейчас подключается как только заработает,
кнопка появится здесь.
</p>
<p className={styles.repPaidFine}>
Если надёжный расчёт не получится вернём 150&nbsp;:{" "}
<PublicLink to="refund">условия возврата</PublicLink>.
</p>
</div>
</div>
</div>
);
}
export function ReportResult({ verdict, object, onEdit }: Props) {
return (
<div className={styles.repRoot}>
<ObjectHeader object={object} onEdit={onEdit} />
{verdict.tone === "none" ? (
<RefusalCard verdict={verdict} />
) : (
<>
{verdict.tiles.length > 0 && <FreeCards verdict={verdict} />}
{verdict.lines.length > 0 && (
<div className={styles.repNotes}>
{verdict.lines.map((line) => (
<p key={line}>{line}</p>
))}
</div>
)}
{verdict.tone === "thin" && (
<div className={styles.repRefusal}>
<div className={styles.repRefusalEyebrow}>ЧЕСТНЫЙ ОТВЕТ</div>
<h3 className={styles.repRefusalTitle}>
Для платного расчёта данных пока мало
</h3>
<p className={styles.repRefusalText}>
Прогноз на паре точек был бы гаданием, поэтому кнопки оплаты
здесь нет. Когда объявлений рядом станет больше, полный отчёт
откроется по этому же адресу.
</p>
</div>
)}
{verdict.offerPaidStep && <PaidTeaser />}
</>
)}
</div>
);
}

View file

@ -2164,3 +2164,249 @@
font-size: 17.5px;
line-height: 1.3;
}
/* --- Отчёт (макет 29.08): шапка объекта, бесплатные карточки, тизер ------- */
.repRoot {
display: flex;
flex-direction: column;
gap: 20px;
}
.repHead {
display: flex;
flex-direction: column;
gap: 16px;
}
.repHeadTop {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
flex-wrap: wrap;
}
.repEyebrow {
margin-bottom: 10px;
font-family: var(--b2c-font-mono);
font-size: 11px;
letter-spacing: 0.22em;
color: var(--b2c-accent-text);
}
.repAddress {
margin: 0;
font-size: clamp(24px, 3.4vw, 34px);
line-height: 1.1;
letter-spacing: -0.02em;
font-weight: 800;
overflow-wrap: anywhere;
}
.repChipsBar {
display: flex;
align-items: center;
gap: 16px 28px;
flex-wrap: wrap;
padding: 16px 22px;
background: var(--b2c-surface);
border: 1px solid var(--b2c-line);
border-radius: 10px;
}
.repChips {
display: flex;
gap: 10px 14px;
flex-wrap: wrap;
font-size: 14.5px;
font-weight: 600;
}
.repChip {
display: inline-flex;
gap: 14px;
}
.repChipDot {
color: var(--b2c-line);
}
.repChipMuted {
color: var(--b2c-muted);
font-weight: 500;
}
.repEditBtn {
margin-left: auto;
padding: 0;
border: none;
background: none;
color: var(--b2c-accent-text);
font-family: var(--b2c-font-sans);
font-weight: 700;
font-size: 13.5px;
white-space: nowrap;
cursor: pointer;
}
.repEditBtn:hover {
color: var(--b2c-ink);
}
.repRefusal {
max-width: 720px;
padding: clamp(24px, 4vw, 40px);
background: var(--b2c-surface);
border: 1px solid var(--b2c-line);
border-radius: 14px;
}
.repRefusalEyebrow {
margin-bottom: 14px;
font-family: var(--b2c-font-mono);
font-size: 10px;
letter-spacing: 0.16em;
color: var(--b2c-muted);
}
.repRefusalTitle {
margin: 0 0 12px;
font-size: clamp(20px, 2.6vw, 27px);
line-height: 1.2;
letter-spacing: -0.015em;
font-weight: 800;
}
.repRefusalText {
margin: 0 0 14px;
font-size: 15px;
line-height: 1.65;
color: var(--b2c-muted);
text-wrap: pretty;
}
.repRefusalText:last-child {
margin-bottom: 0;
}
.repFreeGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
gap: 16px;
}
.repFreeCard {
padding: clamp(22px, 3vw, 30px);
background: var(--b2c-surface);
border: 1px solid var(--b2c-line);
border-radius: 12px;
}
.repFreeEyebrow {
margin-bottom: 12px;
font-family: var(--b2c-font-mono);
font-size: 10px;
letter-spacing: 0.16em;
color: var(--b2c-accent-text);
}
.repFreeRow {
display: flex;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
}
.repFreeValue {
font-size: clamp(32px, 4.4vw, 46px);
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.repFreeLabel {
font-size: 14.5px;
color: var(--b2c-muted);
max-width: 30ch;
}
.repNotes p {
margin: 0 0 8px;
font-size: 14px;
line-height: 1.6;
color: var(--b2c-muted);
}
.repNotes p:last-child {
margin-bottom: 0;
}
.repPaid {
overflow: hidden;
background: var(--b2c-surface);
border: 1px solid var(--b2c-line);
border-radius: 14px;
}
.repPaidBar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 12px 24px;
background: var(--b2c-surface-soft);
}
.repPaidBarText {
font-family: var(--b2c-font-mono);
font-size: 10px;
letter-spacing: 0.16em;
color: var(--b2c-accent-text);
}
.repPaidGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
}
.repPaidLeft {
display: flex;
flex-direction: column;
gap: 18px;
padding: clamp(22px, 3.5vw, 34px);
}
.repPaidRowLabel {
margin-bottom: 8px;
font-family: var(--b2c-font-mono);
font-size: 9.5px;
letter-spacing: 0.14em;
color: var(--b2c-muted);
}
.repPaidRowLine {
display: flex;
align-items: center;
gap: 12px;
}
.repShimmer {
display: inline-block;
height: 26px;
border-radius: 6px;
background: linear-gradient(90deg, #eaf0f1 25%, #f4f7f8 50%, #eaf0f1 75%);
background-size: 200% 100%;
animation: repShimmer 1.8s linear infinite;
}
@keyframes repShimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
@media (prefers-reduced-motion: reduce) {
.repShimmer { animation: none; }
}
.repPaidRowChip {
font-family: var(--b2c-font-mono);
font-size: 10.5px;
color: var(--b2c-accent-text);
white-space: nowrap;
border: 1px solid var(--b2c-line);
border-radius: 4px;
padding: 3px 8px;
}
.repPaidRight {
display: flex;
flex-direction: column;
justify-content: center;
gap: 14px;
padding: clamp(22px, 3.5vw, 34px);
border-left: 1px solid var(--b2c-line-soft);
background: var(--b2c-page-bg);
}
.repPaidTitle {
font-weight: 800;
font-size: clamp(18px, 2.1vw, 22px);
line-height: 1.3;
}
.repPaidText {
margin: 0;
font-size: 14.5px;
line-height: 1.6;
color: var(--b2c-muted);
}
.repPaidFine {
margin: 0;
font-size: 13px;
line-height: 1.55;
color: #9fb0b4;
}

View file

@ -0,0 +1,83 @@
"use client";
/**
* Фикстурное превью ReportResult все три тона вердикта на одной странице.
*
* Зачем: результат пробы покрытия зависит от живого бэкенда за публичным
* периметром Caddy локально его не воспроизвести, не подделывая секрет
* периметра. Превью рендерит компонент на фикстурах (та же конвенция, что
* `app/ui-preview/estimate` у B2B-части: NEXT_PUBLIC_ENABLE_PREVIEW=1, иначе
* notFound; в прод-сборке страница недостижима и в Caddy-allowlist публичного
* домена не входит).
*/
import { notFound } from "next/navigation";
import { ReportResult } from "../../_components/estimate/ReportResult";
import type { CoverageVerdict } from "../../coverage-copy";
const ENABLED = process.env.NEXT_PUBLIC_ENABLE_PREVIEW === "1";
const OK: CoverageVerdict = {
tone: "ok",
title: "Данных достаточно",
lines: [
"Проверили по вашему дому и окружению в радиусе 1 км.",
],
tiles: [
{ value: "14", label: "похожих квартир продаётся в радиусе 1 км" },
{ value: "47 дн.", label: "медианный возраст активного объявления" },
],
offerPaidStep: true,
};
const THIN: CoverageVerdict = {
tone: "thin",
title: "Похожие квартиры рядом есть, но их мало",
lines: ["Нашли 2 похожие квартиры в радиусе 1 км."],
tiles: [{ value: "2", label: "похожие квартиры продаются в радиусе 1 км" }],
offerPaidStep: false,
};
const NONE: CoverageVerdict = {
tone: "none",
title: "Ваш город мы пока не оцениваем",
lines: [
"Чтобы прогноз был честным, нужен объём данных по сделкам и объявлениям, которого в этом городе у нас пока нет.",
],
tiles: [],
offerPaidStep: false,
};
const OBJECT = {
address: "Екатеринбург, ул. Инженерная, 14",
roomsLabel: "2 комнаты",
area: "54,2",
floor: null,
};
export default function ReportPreviewPage() {
if (!ENABLED) notFound();
return (
<main
style={{
display: "flex",
flexDirection: "column",
gap: 48,
maxWidth: 960,
margin: "0 auto",
padding: "32px 20px 96px",
}}
>
<div>
<ReportResult verdict={OK} object={OBJECT} onEdit={() => {}} />
</div>
<div>
<ReportResult verdict={THIN} object={OBJECT} onEdit={() => {}} />
</div>
<div>
<ReportResult verdict={NONE} object={OBJECT} onEdit={() => {}} />
</div>
</main>
);
}