fix(mera/b2c): снять с витрины обещание времени и подписать величины измеренным
All checks were successful
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 Trade-In / frontend-checks (pull_request) Successful in 57s
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI / openapi-codegen-check (pull_request) Has been skipped

«30 секунд» ушло из бейджа шага 1 как незамеренное, но осталось видимым
посетителю в четырёх других местах той же страницы: подпись под кнопкой формы,
CTA шапки, CTA блока возражений и липкая нижняя панель, которая висит на экране
всегда. Гейт витринных чисел этого не видел по построению — «сек» не было среди
единиц, а трёх файлов не было в списке сканируемых (обе дыры закрыты отдельно).
Замена — по смыслу: число полей формы (STEP1_FIELDS_LABEL) проверяется глазами,
и оба места берут его из landing-facts.ts, а не пишут у себя.

Медиана аналогов стояла под общей шапкой «пример по рынку Екатеринбурга», но
городом не ограничена: _ANALOGS_SQL идёт по всей таблице trade_in_estimates,
фильтр по city стоит только у экспозиции. Плюс Hero печатал у неё лишь размер
выборки и выбрасывал note «только расчёты, где аналоги нашлись» — отбор
выживших. Теперь у каждой величины своя подпись со своей границей и своя
оговорка рядом со значением; правило зафиксировано двумя проверками рендера
(обе краснеют на возврате к одной строке и на выброшенном note).

price_cut_share_pct считает долю ОБЪЯВЛЕНИЙ и только по Домклику — подпись
«столько продавцов снижают цену» приписывала величине единицу, которой её не
мерили. Один продавец ведёт несколько объявлений.

В ReportResult оставались три литерала «150 ₽» при живой SERVICE_PRICE_RUB:
цена обязана иметь один источник с офертой и политикой возврата.

Обещание «прогноз срока продажи» приведено к тому, что считается:
_estimate_days_on_market — медиана days_on_market аналогов, то есть сколько
похожие объявления УЖЕ висят. deals.days_on_market заполнен 0 раз из 108 623,
сверять прогноз срока не с чем (по этой же причине в ветке уже снято «±6 дн»).
This commit is contained in:
bot-backend 2026-08-29 20:28:10 +05:00
parent b17a2a310c
commit 2e82b0810f
16 changed files with 173 additions and 49 deletions

View file

@ -11,6 +11,7 @@ import { describe, expect, it } from "vitest";
import { AccuracyV3 } from "../_components/v3/AccuracyV3";
import { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3";
import { HeroV3 } from "../_components/v3/HeroV3";
import type { LandingStat, ShowcaseResponse } from "../public-api";
const stat = (value: number, sample_n: number | null, note: string): LandingStat => ({
@ -28,6 +29,16 @@ const STATS = {
price_cut_median_pct_per_month: stat(-2.17, 3018, "Медианное изменение цены за 30 дней среди снижавших"),
};
/** Hero печатает ещё и медиану аналогов — величину БЕЗ фильтра по городу. */
const HERO_STATS = {
...STATS,
analogs_median: stat(
14,
1123,
"Медиана числа аналогов на расчёт (только расчёты, где аналоги нашлись)",
),
};
const SHOWCASE: ShowcaseResponse = {
computed_at: "2026-08-29T03:00:00+00:00",
deals: [
@ -88,7 +99,35 @@ describe("витрина лэндинга v3 без данных", () => {
it("«Цена ошибки»: без /stats остаётся только цена — она из оферты, а не из замера", () => {
render(<CostOfErrorV3 stats={{}} />);
expect(screen.queryByText(/столько продавцов снижают цену/)).toBeNull();
expect(screen.queryByText(/столько объявлений на Домклике снижали цену/)).toBeNull();
expect(screen.getByText("150 ₽")).toBeTruthy();
});
/**
* Величина не должна стоять под подписью, к которой не привязана. Экспозиция
* считается с фильтром по городу (`_LISTING_AGE_SQL`), медиана аналогов по
* всей таблице `trade_in_estimates`; под общей шапкой «пример по рынку
* Екатеринбурга» вторая была бы подписана чужой границей. Проверяем не
* наличие двух строк, а СОДЕРЖИМОЕ каждой: слить их обратно в одну и
* оставить тест зелёным невозможно.
*/
it("HeroV3: аналоги подписаны своей границей и своей оговоркой, а не Екатеринбургом", () => {
render(<HeroV3 stats={HERO_STATS} />);
const ekbLine = screen.getByText(/Пример по рынку Екатеринбурга/).parentElement;
expect(ekbLine?.textContent).toMatch(/активное объявление висит\s+26\s+дн\./u);
expect(ekbLine?.textContent, "экспозиция и аналоги снова в одной строке").not.toMatch(/аналог/);
const allLine = screen.getByText(/По всем расчётам МЕРЫ/).parentElement;
expect(allLine?.textContent).toMatch(/медиана аналогов на расчёт/);
// Отбор выживших: расчёты без аналогов в знаменатель не попали.
expect(allLine?.textContent, "note бэкенда выброшен").toMatch(
/только расчёты, где аналоги нашлись/,
);
});
it("HeroV3: без /stats обе строки примера исчезают, а не показывают подпись без числа", () => {
render(<HeroV3 stats={{}} />);
expect(screen.queryByText(/Пример по рынку Екатеринбурга/)).toBeNull();
expect(screen.queryByText(/По всем расчётам МЕРЫ/)).toBeNull();
});
});

View file

@ -8,7 +8,7 @@ import { PublicLink } from "./PublicLink";
* (`v3/HeaderV3.tsx`).
*
* Из неё убраны навигация по секциям лэндинга, селектор города и CTA
* «Проверить за 30 секунд». Причина у всех трёх одна: человек уже ушёл с
* «Проверить квартиру». Причина у всех трёх одна: человек уже ушёл с
* лэндинга. Якоря вроде `#accuracy` тут просто не на что навести (секций нет
* на этих страницах), город выбирается прямо в форме проверки а второй
* селектор города в шапке означал бы два места, задающих одно значение.

View file

@ -23,11 +23,24 @@
* «возраст активного объявления», а НЕ макетное «срок продажи по дому»
* выборка цензурирована, честное имя величины важнее красоты.
*
* Возврат 150 при ненадёжном расчёте политика с /refund; в тизере она
* Возврат при ненадёжном расчёте политика с /refund; в тизере она
* упоминается той же строкой, что в макете, со ссылкой на полный текст.
*
* ЦЕНА ТОЛЬКО `SERVICE_PRICE_RUB`. Здесь оставались три литерала «150 »
* (плашка тизера, текст и строка возврата). Цена стоит в оферте и в политике
* возврата; вписанная в экран копия разъехалась бы с ними молча, и человек
* увидел бы одну цифру на витрине, а другую в документе, который его
* обязывает.
*
* СРОК. Строка тизера обещала «прогноз срока продажи»: платный расчёт отдаёт
* `est_days_on_market` медиану `days_on_market` аналогов, то есть сколько
* похожие объявления УЖЕ висят. `deals.days_on_market` заполнен 0 раз из
* 108 623, поэтому сверить прогноз срока не с чем и прогнозом эта величина
* не является. Названа тем, что считается, ровно как в пункте 5 выше про
* бесплатные плитки.
*/
import { SUPPORT_TELEGRAM_LABEL, SUPPORT_TELEGRAM_URL } from "../../content";
import { SERVICE_PRICE_RUB, SUPPORT_TELEGRAM_LABEL, SUPPORT_TELEGRAM_URL } from "../../content";
import type { CoverageVerdict } from "../../coverage-copy";
import styles from "../../landing-v3.module.css";
@ -129,7 +142,7 @@ function FreeCards({ verdict }: { verdict: CoverageVerdict }) {
const PAID_ROWS = [
{ label: "РЕКОМЕНДОВАННАЯ ЦЕНА", w: "60%" },
{ label: "ПРОГНОЗ СРОКА ПРОДАЖИ ПРИ ЭТОЙ ЦЕНЕ", w: "45%" },
{ label: "СКОЛЬКО УЖЕ ВИСЯТ ПОХОЖИЕ ОБЪЯВЛЕНИЯ", w: "45%" },
{ label: "ДИАПАЗОН И ПОЛОЖЕНИЕ НА РЫНКЕ", w: "70%" },
{ label: "АНАЛОГИ С АДРЕСАМИ И ЦЕНАМИ", w: "88%" },
] as const;
@ -139,7 +152,9 @@ function PaidTeaser() {
return (
<div className={styles.repPaid}>
<div className={styles.repPaidBar}>
<span className={styles.repPaidBarText}>ПОЛНЫЙ ОТЧЁТ · 150 </span>
<span className={styles.repPaidBarText}>
ПОЛНЫЙ ОТЧЁТ · {SERVICE_PRICE_RUB}&nbsp;
</span>
</div>
<div className={styles.repPaidGrid}>
<div className={styles.repPaidLeft}>
@ -159,17 +174,17 @@ function PaidTeaser() {
</div>
<div className={styles.repPaidRight}>
<div className={styles.repPaidTitle}>
Цена и срок продажи следующий шаг
Ваша цена следующий шаг
</div>
<p className={styles.repPaidText}>
Полный расчёт стоит 150&nbsp;: диапазон цены, цена за квадратный
метр и список квартир, на которых он построен. Один платёж, без
подписки. Приём оплаты сейчас подключается как только заработает,
кнопка появится здесь.
Полный расчёт стоит {SERVICE_PRICE_RUB}&nbsp;: диапазон цены, цена
за квадратный метр и список квартир, на которых он построен. Один
платёж, без подписки. Приём оплаты сейчас подключается как только
заработает, кнопка появится здесь.
</p>
<p className={styles.repPaidFine}>
Если надёжный расчёт не получится вернём 150&nbsp;:{" "}
<PublicLink to="refund">условия возврата</PublicLink>.
Если надёжный расчёт не получится вернём {SERVICE_PRICE_RUB}
&nbsp;: <PublicLink to="refund">условия возврата</PublicLink>.
</p>
</div>
</div>

View file

@ -9,6 +9,13 @@
* снижал»: одна подпись на две величины была бы подменой знаменателя.
* Цена `SERVICE_PRICE_RUB` (content.ts), тот же источник, что у оферты.
*
* ОБЪЯВЛЕНИЯ, А НЕ ПРОДАВЦЫ. `price_cut_share_pct` доля ОБЪЯВЛЕНИЙ, и
* только по Домклику (единственный источник, где триггер пишет стартовую
* цену, см. шапку `landing_stats.py`). Один продавец ведёт несколько
* объявлений, поэтому «столько продавцов снижают цену» приписывало величине
* единицу, которой её не мерили: подпись приведена к измеренному, а источник
* назван прямо в ней, а не только в `note` под плиткой.
*
* ЧЕГО ЗДЕСЬ БОЛЬШЕ НЕТ. Плитки «×2,4 дольше продаётся квартира с завышенной
* ценой»: замер даёт ×1,04 (79 дней против 76), и это уже с цензурой в пользу
* заявления. Её место заняла доля снижающих цену измеренная величина про то
@ -51,7 +58,7 @@ export function CostOfErrorV3({ stats }: { stats: LandingStats }) {
tiles.push({
key: "share",
value: share.text,
title: "столько продавцов снижают цену",
title: "столько объявлений на Домклике снижали цену",
text: [share.note, share.sample].filter(Boolean).join(" · "),
});
}

View file

@ -4,7 +4,7 @@
* FreeCheckCard карточка «Бесплатная проверка» на первом экране (макет v3,
* `МЕРА для себя v3.dc.html` строки ~129-170).
*
* ЧТО ОНА ДЕЛАЕТ СЕГОДНЯ. Проверяет пять полей и уводит на `/estimate`, где
* ЧТО ОНА ДЕЛАЕТ СЕГОДНЯ. Проверяет шесть полей и уводит на `/estimate`, где
* живёт сама проверка. Макетный сценарий «спиннер 1100&nbsp;мс готовый
* результат прямо в карточке» не воспроизводится и не будет: результату нужно
* место рядом с полями, а автокомплиту место под список; в карточке первого
@ -39,6 +39,11 @@
* не использует (см. `CoverageProbeInput` на бэке), но выбросить уже
* полученный ответ и спросить его второй раз на платном шаге хуже, чем
* донести его в `sessionStorage` до того шага.
*
* ПОДПИСЬ ПОД КНОПКОЙ. В ней стояло «30 СЕКУНД» замера времени заполнения
* формы нет и не было. Его место занял `STEP1_FIELDS_LABEL`: число полей
* проверяемый факт, и оно приезжает из `landing-facts.ts`, а не вписано сюда
* второй раз (иначе с бейджем шага 1 они разъехались бы молча).
*/
import { useId, useRef, useState } from "react";
@ -54,6 +59,7 @@ import {
SECONDARY_CITIES,
} from "../../content";
import { saveDraft } from "../../estimate-draft";
import { STEP1_FIELDS_LABEL } from "../../landing-facts";
import styles from "../../landing-v3.module.css";
/** Значение <option> «моего города нет в списке» (то же, что в v1-форме). */
@ -359,7 +365,7 @@ export function FreeCheckCard() {
Показать данные по моему дому
</button>
<p className={styles.heroSubmitCaption}>
БЕЗ РЕГИСТРАЦИИ · БЕЗ ЗВОНКОВ · 30 СЕКУНД
{`БЕЗ РЕГИСТРАЦИИ · БЕЗ ЗВОНКОВ · ${STEP1_FIELDS_LABEL.toUpperCase()}`}
</p>
{/* Живая область держим в DOM постоянно регион, добавленный в

View file

@ -32,9 +32,14 @@ export interface FreeResultV3Props {
analogs: string;
/** Подпись под числом объявлений. */
analogsLabel: string;
/** Средний срок продажи — крупная цифра второй плитки, напр. "47 дн.". */
/**
* Экспозиция похожих объявлений крупная цифра второй плитки, напр.
* "47 дн.". НЕ срок продажи: считается по тем, кто ещё висит, и снятие
* объявления не означает сделку (см. `median_listing_age_days` в
* public-api.ts).
*/
days: string;
/** Подпись под сроком продажи. */
/** Подпись под экспозицией. */
daysLabel: string;
/** Клик по «Проверить другую квартиру» — вернуть карточку к форме. */
onBack: () => void;
@ -72,7 +77,7 @@ export function FreeResultV3({
</div>
<div className={styles.heroResultOverlay}>
<p className={styles.heroResultOverlayTitle}>
Ваша цена и срок продажи готовы
Ваша цена готова
</p>
<button
type="button"

View file

@ -70,7 +70,7 @@ export function HeaderV3() {
Для бизнеса
</a>
<a href="#check" className={styles.hdrCta}>
Проверить за 30 секунд
Проверить квартиру
</a>
</div>
</div>

View file

@ -14,6 +14,20 @@
* конкретного человека. Цена `SERVICE_PRICE_RUB` (content.ts), один
* источник с офертой и политикой возврата.
*
* ДВЕ СТРОКИ ПРИМЕРА, А НЕ ОДНА. Екатеринбургом ограничена только экспозиция
* (`_LISTING_AGE_SQL` фильтрует `listings.city`); медиана аналогов считается
* по всей таблице `trade_in_estimates` без фильтра по городу. Стоя под общей
* шапкой «пример по рынку Екатеринбурга», она была бы подписана городом, к
* которому не привязана, поэтому у каждой величины своя подпись со своей
* границей. Рядом с аналогами печатается их `note` бэкенда («только расчёты,
* где аналоги нашлись»): это отбор выживших, расчёты без аналогов в
* знаменатель не попали, и без оговорки медиана читается выше, чем есть.
*
* СРОК. В тексте нет «узнаете срок продажи»: платный отчёт отдаёт
* `est_days_on_market` медиану `days_on_market` аналогов, то есть сколько
* похожие объявления УЖЕ висят (`deals.days_on_market` пуст, сверять прогноз
* срока не с чем). Обещание приведено к этой величине.
*
* ЧЕГО В ТЕКСТЕ БОЛЬШЕ НЕТ: обещания «каждый такой прогноз мы потом сверяем с
* фактом сделки». Контура сверки прогноза клиента с его сделкой в продукте не
* существует (`trade_in_leads` 4 строки без полей исхода), и обещать его
@ -53,12 +67,22 @@ export function HeroV3({ stats }: { stats: LandingStats }) {
digits: 0,
sampleWord: "объявлениям",
});
const marketExample = [
analogs && `медиана аналогов на расчёт — ${analogs.text}${analogs.sample ? ` (${analogs.sample})` : ""}`,
age && `активное объявление висит ${age.text}${age.sample ? ` (медиана ${age.sample})` : ""}`,
]
.filter((line): line is string => Boolean(line))
.join("; ");
const marketLines = [
age && {
key: "age",
label: "Пример по рынку Екатеринбурга, а не расчёт по вашему адресу:",
text: [`активное объявление висит ${age.text}`, age.sample && `медиана ${age.sample}`]
.filter(Boolean)
.join(" · "),
},
analogs && {
key: "analogs",
label: "По всем расчётам МЕРЫ, а не только по Екатеринбургу:",
text: [`медиана аналогов на расчёт — ${analogs.text}`, analogs.sample, analogs.note]
.filter(Boolean)
.join(" · "),
},
].filter((line): line is { key: string; label: string; text: string } => Boolean(line));
return (
<section
@ -99,8 +123,8 @@ export function HeroV3({ stats }: { stats: LandingStats }) {
</span>
<span className={styles.heroListText}>
<strong>Не зависнуть:</strong> узнаете, за сколько дней уйдёт
квартира по вашей цене
<strong>Не зависнуть:</strong> увидите, сколько времени уже
висят похожие квартиры рядом
</span>
</div>
<div className={styles.heroListItem}>
@ -133,20 +157,20 @@ export function HeroV3({ stats }: { stats: LandingStats }) {
За{" "}
<span className={styles.heroNoBreak}>{`${SERVICE_PRICE_RUB}`}</span>:
</strong>{" "}
ваша цена, срок продажи и аналоги, на которых построен расчёт
ваша цена, экспозиция похожих объявлений и аналоги, на которых
построен расчёт
</span>
</div>
{marketExample !== "" && (
<div className={styles.heroListItem}>
{marketLines.map((line) => (
<div key={line.key} className={styles.heroListItem}>
<span className={styles.heroListIcon} aria-hidden="true">
</span>
<span className={styles.heroListText}>
<strong>Пример по рынку Екатеринбурга, а не расчёт по вашему адресу:</strong>{" "}
{marketExample}
<strong>{line.label}</strong> {line.text}
</span>
</div>
)}
))}
</div>
{/* Покрытие подано неравномерно намеренно см. COVERED_CITIES в

View file

@ -135,7 +135,7 @@ export function ObjectionsV3() {
Что обычно спрашивают перед оплатой
</h2>
<PublicLink to="estimate" className={styles.objCtaBtn}>
Проверить за 30 секунд
Проверить квартиру
</PublicLink>
</div>
<div className={styles.objList}>

View file

@ -30,11 +30,11 @@ const STEPS_V3: readonly StepV3[] = [
},
{
title: "Мы собираем рынок вокруг",
text: "Похожие квартиры рядом: сколько их продаётся сейчас и за сколько дней они уходят.",
text: "Похожие квартиры рядом: сколько их продаётся сейчас и как давно они висят.",
},
{
title: "Вы получаете цену и срок",
text: "Конкретная цифра для объявления, прогноз срока продажи и все аналоги, на которых построен расчёт.",
title: "Вы получаете цену и картину рынка",
text: "Конкретная цифра для объявления, сколько уже висят похожие объявления и все аналоги, на которых построен расчёт.",
accent: true,
},
] as const;

View file

@ -14,6 +14,14 @@
* компоненте так `StickyCtaV3` остаётся самодостаточным независимо от
* того, куда именно в дерево страницы его вставят (ожидаемое место сразу
* после подвала, как Header/Footer в текущем `layout.tsx`).
*
* НА КНОПКЕ НЕТ СРОКА. Здесь стояло «Проверить за 30 секунд» замера времени
* заполнения формы нет и не было (та же причина, по которой «30 СЕКУНД» ушло
* из бейджа первого шага, см. `landing-facts.ts`). Панель висит на экране
* ВСЕГДА, поэтому именно с неё обещание, которого никто не мерил, читалось
* чаще всего. В тексте панели по той же причине нет и «срока продажи»:
* платный отчёт отдаёт медиану экспозиции похожих объявлений, а не прогноз
* срока (`_estimate_days_on_market`, см. HeroV3).
*/
import styles from "../../landing-v3.module.css";
@ -26,10 +34,10 @@ export function StickyCtaV3() {
<div className={styles.barSpacer} aria-hidden="true" />
<div className={styles.barRoot}>
<p className={styles.barText}>
Узнайте цену и срок продажи своей квартиры
Узнайте цену своей квартиры и что происходит с похожими рядом
</p>
<PublicLink to="estimate" className={styles.barBtn}>
Проверить за 30 секунд
Проверить квартиру
</PublicLink>
</div>
</>

View file

@ -13,6 +13,12 @@
* Цена `SERVICE_PRICE_RUB` (content.ts): один источник с офертой и политикой
* возврата. Раньше она приезжала сюда из плейсхолдерной плитки «цена ошибки»,
* и разъехаться с офертой могла молча.
*
* СОСТАВ ОТЧЁТА НАЗВАН ПО ФАКТУ. В перечне пути 1 стоял «срок»: платный отчёт
* действительно отдаёт `est_days_on_market`, но это медиана `days_on_market`
* аналогов сколько похожие объявления УЖЕ висят, а не срок до сделки
* (`deals.days_on_market` заполнен 0 раз из 108 623, сверять прогноз срока не
* с чем). Слово заменено на измеренную величину.
*/
import { SERVICE_PRICE_RUB, SUPPORT_TELEGRAM_URL } from "../../content";
@ -38,7 +44,8 @@ export function TwoPathsV3() {
<div className={styles.pathEyebrow}>ПУТЬ 1 · САМОСТОЯТЕЛЬНО</div>
<div className={styles.pathTitle}>{`Отчёт за ${SERVICE_PRICE_RUB}`}</div>
<p className={styles.pathBody}>
Цена, срок, аналоги, факторы. Дальше вы ведёте продажу сами на
Цена, экспозиция похожих объявлений, аналоги, факторы. Дальше вы
ведёте продажу сами на
руках все цифры, чтобы уверенно держать свою цену в переговорах.
</p>
<PublicLink to="estimate" className={styles.pathBtnLight}>

View file

@ -149,8 +149,8 @@ export const HOWTO = {
},
cta: {
eyebrow: "ЕСЛИ СЧИТАТЬ ВРУЧНУЮ НЕКОГДА",
title: "МЕРА делает всё это за 30 секунд",
body: `Мы находим аналоги, считаем медиану, применяем поправки и проверяем цену сроком продажи. Бесплатно покажем рынок вокруг, за ${SERVICE_PRICE_RUB} ₽ — вашу цену и прогноз.`,
title: "МЕРА делает всё это за вас",
body: `Мы находим аналоги, считаем медиану, применяем поправки и смотрим, сколько похожие объявления уже висят. Бесплатно покажем рынок вокруг, за ${SERVICE_PRICE_RUB} ₽ — вашу цену и аналоги, на которых она построена.`,
},
s5: {
id: "s5",

View file

@ -124,7 +124,7 @@ export default function HowToArticlePage() {
<div className={styles.artCtaTitle}>{HOWTO.cta.title}</div>
<p className={styles.artCtaBody}>{HOWTO.cta.body}</p>
<PublicLink to="estimate" className={styles.gameBtnAccent}>
Проверить за 30 секунд
Проверить квартиру
</PublicLink>
</div>

View file

@ -88,7 +88,7 @@ export default function ArticlesPage() {
</p>
</div>
<PublicLink to="estimate" className={styles.gameBtnAccent}>
Проверить за 30 секунд
Проверить квартиру
</PublicLink>
</div>
</main>

View file

@ -87,8 +87,21 @@ export const BACKTEST_CORRIDOR_LABEL = "±37 %";
export const BACKTEST_PERIOD_LABEL = "сделки с июня 2025 года";
/**
* Бейдж первого шага. Раньше здесь стояло «30 секунд» замера времени
* заполнения формы нет и не было. Число полей формы, наоборот, проверяется
* глазами в `FreeCheckCard.tsx`: адрес, площадь, комнаты, этаж, состояние.
* Бейдж первого шага И подпись под кнопкой формы (`FreeCheckCard.tsx`).
* Раньше в обоих местах стояло «30 секунд» замера времени заполнения формы
* нет и не было. Число полей формы, наоборот, проверяется глазами там же:
* город, адрес, площадь, комнаты, этаж, состояние.
*
* ШЕСТЬ, А НЕ ПЯТЬ. Пока эта подпись делалась, соседняя ветка вернула в форму
* ВЫБОР ГОРОДА (#2576: без него «Ленина, 1» давало жителю Нижнего Тагила цену
* по одноимённой улице в ЕКБ). Число полей не константа продукта, а счёт
* того, что стоит в разметке рядом; после слияния двух веток «5 полей» было бы
* ровно тем враньём на витрине, ради снятия которого подпись и заводилась.
* Меняется форма пересчитать здесь; проверяется по `heroFieldLabel` в
* `FreeCheckCard.tsx`.
*
* Оба места берут строку ОТСЮДА, а не пишут её у себя: «30 секунд» пережило
* снятие из бейджа ровно потому, что было вписано в четыре компонента
* независимо, и правка одного не касалась остальных.
*/
export const STEP1_FIELDS_LABEL = "5 полей";
export const STEP1_FIELDS_LABEL = "6 полей";