From de66826b252f2f5d956bed5f0f94c58bbccf7e3c Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 16:35:36 +0500 Subject: [PATCH] =?UTF-8?q?feat(mera/b2c):=20=D1=80=D0=B5=D0=B7=D1=83?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D0=B0=D1=82=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B8=20=D0=B2=20=D0=BE=D0=B1=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=D0=B5=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B0=20(=D0=BC=D0=B0?= =?UTF-8?q?=D0=BA=D0=B5=D1=82=2029.08=20=C2=AB=D0=9C=D0=95=D0=A0=D0=90=20?= =?UTF-8?q?=D0=BE=D1=82=D1=87=D1=91=D1=82=C2=BB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Редизайн результата /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 --- .../_components/estimate/EstimateFlow.tsx | 57 ++-- .../_components/estimate/ReportResult.tsx | 214 +++++++++++++++ .../src/app/mera-public/landing-v3.module.css | 246 ++++++++++++++++++ .../mera-public/ui-preview/report/page.tsx | 83 ++++++ 4 files changed, 558 insertions(+), 42 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/estimate/ReportResult.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/ui-preview/report/page.tsx diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx index 491ed98b..d34a8918 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx @@ -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() { )} - {phase.kind === "result" && } + {phase.kind === "result" && ( + String(o.value) === rooms)?.label ?? null, + area: area || null, + floor: null, + }} + onEdit={() => setPhase(FORM)} + /> + )} ); } - -function CoverageResult({ verdict }: { verdict: CoverageVerdict }) { - return ( -
-

- {verdict.tone === "none" ? "ПРОВЕРИЛИ" : "ГОТОВО · БЕСПЛАТНАЯ ЧАСТЬ"} -

-

{verdict.title}

- - {verdict.tiles.length > 0 && ( -
- {verdict.tiles.map((tile) => ( -
-
{tile.value}
-
{tile.label}
-
- ))} -
- )} - - {verdict.lines.map((line) => ( -

- {line} -

- ))} - - {verdict.offerPaidStep && ( -
-

- Цена и срок продажи — следующий шаг -

-

- Полный расчёт стоит 150 ₽: диапазон цены, цена за квадратный - метр и список квартир, на которых он построен. Приём оплаты сейчас - подключается — как только заработает, кнопка появится здесь. -

-
- )} -
- ); -} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/estimate/ReportResult.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/estimate/ReportResult.tsx new file mode 100644 index 00000000..13dbf946 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/estimate/ReportResult.tsx @@ -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 ( +
+
+
+
БЕСПЛАТНАЯ ПРОВЕРКА
+

{object.address}

+
+
+ {( +
+
+ {chips.map((c, i) => ( + + {i > 0 && ( + + )} + {c} + + ))} + {chips.length === 0 && ( + параметры не указаны + )} +
+ +
+ )} +
+ ); +} + +/** Отказ «город не покрыт» / «дом не найден» (tone none). */ +function RefusalCard({ verdict }: { verdict: CoverageVerdict }) { + return ( +
+
ЧЕСТНЫЙ ОТВЕТ
+

{verdict.title}

+ {verdict.lines.map((line) => ( +

+ {line} +

+ ))} +

+ Продавать отчёт «на глазок» мы не будем — поэтому и кнопки оплаты здесь + нет. Если хотите, чтобы мы сообщили, когда данные появятся, — напишите в + поддержку:{" "} + + {SUPPORT_TELEGRAM_LABEL} + + . +

+
+ ); +} + +/** Бесплатные карточки (tone ok/thin) — числа и подписи из verdict.tiles. */ +function FreeCards({ verdict }: { verdict: CoverageVerdict }) { + return ( +
+ {verdict.tiles.map((tile) => ( +
+
✓ БЕСПЛАТНО
+
+ {tile.value} + {tile.label} +
+
+ ))} +
+ ); +} + +const PAID_ROWS = [ + { label: "РЕКОМЕНДОВАННАЯ ЦЕНА", w: "60%" }, + { label: "ПРОГНОЗ СРОКА ПРОДАЖИ ПРИ ЭТОЙ ЦЕНЕ", w: "45%" }, + { label: "ДИАПАЗОН И ПОЛОЖЕНИЕ НА РЫНКЕ", w: "70%" }, + { label: "АНАЛОГИ С АДРЕСАМИ И ЦЕНАМИ", w: "88%" }, +] as const; + +/** Тизер платной части (tone ok): shimmer-строки + честная правая панель. */ +function PaidTeaser() { + return ( +
+
+ ПОЛНЫЙ ОТЧЁТ · 150 ₽ +
+
+
+ {PAID_ROWS.map((row) => ( +
+
{row.label}
+
+
+
+ ))} +
+
+
+ Цена и срок продажи — следующий шаг +
+

+ Полный расчёт стоит 150 ₽: диапазон цены, цена за квадратный + метр и список квартир, на которых он построен. Один платёж, без + подписки. Приём оплаты сейчас подключается — как только заработает, + кнопка появится здесь. +

+

+ Если надёжный расчёт не получится — вернём 150 ₽:{" "} + условия возврата. +

+
+
+
+ ); +} + +export function ReportResult({ verdict, object, onEdit }: Props) { + return ( +
+ + {verdict.tone === "none" ? ( + + ) : ( + <> + {verdict.tiles.length > 0 && } + {verdict.lines.length > 0 && ( +
+ {verdict.lines.map((line) => ( +

{line}

+ ))} +
+ )} + {verdict.tone === "thin" && ( +
+
ЧЕСТНЫЙ ОТВЕТ
+

+ Для платного расчёта данных пока мало +

+

+ Прогноз на паре точек был бы гаданием, поэтому кнопки оплаты + здесь нет. Когда объявлений рядом станет больше, полный отчёт + откроется по этому же адресу. +

+
+ )} + {verdict.offerPaidStep && } + + )} +
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css index eb9cb4ca..367eb195 100644 --- a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css +++ b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css @@ -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; +} diff --git a/tradein-mvp/frontend/src/app/mera-public/ui-preview/report/page.tsx b/tradein-mvp/frontend/src/app/mera-public/ui-preview/report/page.tsx new file mode 100644 index 00000000..a01170a4 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/ui-preview/report/page.tsx @@ -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 ( +
+
+ {}} /> +
+
+ {}} /> +
+
+ {}} /> +
+
+ ); +}