diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts
new file mode 100644
index 00000000..6e34f1ab
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts
@@ -0,0 +1,242 @@
+/**
+ * Гейт #2904 (переписан 29.08.2026): ни одна величина не выходит на витрину
+ * без источника.
+ *
+ * ЧТО ОХРАНЯЛ ПРЕДЫДУЩИЙ ГЕЙТ. Он следил за парой «плейсхолдеры на странице ↔
+ * `noindex` в layout.tsx»: пока числа из дизайн-макета живы, страницу нельзя
+ * открыть наружу. Плейсхолдеров больше нет — числа заменены замеренными
+ * (`/api/public/mera/stats`, `/showcase`) либо сняты вместе с блоком, — и
+ * второй тест того гейта («ни один плейсхолдер не найден — зонд сломан»)
+ * покраснел бы ПО ПОСТРОЕНИЮ ровно за честную работу. Правило пришлось
+ * сформулировать заново.
+ *
+ * ЧТО ОХРАНЯЕТ ЭТОТ. Не наличие плейсхолдеров, а происхождение чисел:
+ *
+ * 1. в компонентах лэндинга v3 нет вписанных руками величин — число
+ * приходит либо из ручки (`formatStat`/`/showcase`), либо из
+ * `landing-facts.ts`, где рядом с ним лежат выборка и оговорки;
+ * 2. каждая запись `landing-facts.ts` заполнена: значение, размер выборки,
+ * источник, оговорки — пустых полей быть не может;
+ * 3. сам сканер проверен на образцах: он обязан найти вписанное число и не
+ * обязан ругаться на честный код. Без этого пункта правило (1) стало бы
+ * зелёным по построению при первой же опечатке в регулярке;
+ * 4. блок `robots` в layout.tsx на месте. Снятие `noindex` — решение
+ * владельца, но оно должно быть осознанным, а не побочным.
+ *
+ * ЧЕГО ГЕЙТ НЕ ДЕЛАЕТ. Он не сканирует `articles/articles-content.ts`: числа
+ * статьи — это связный текст, у каждого блока которого стоит собственное поле
+ * `source`/`footnote` с датой и выборкой замера (см. шапку того файла). Ловить
+ * их регуляркой значило бы требовать переписать статью в константы; правило
+ * (2) там не поможет, а правило (1) дало бы шум вместо охраны.
+ */
+import { readdirSync, readFileSync } from "node:fs";
+import { join, relative, resolve } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+import { BACKTEST, type MeasuredValue } from "../landing-facts";
+
+const ROOT = join(__dirname, "..");
+
+function read(rel: string): string {
+ return readFileSync(join(ROOT, rel), "utf8");
+}
+
+/**
+ * Корень витрины и каталог её компонентов.
+ *
+ * КОРЕНЬ — `page.tsx`, а не `v3/page.tsx`: 29.08.2026 превью стало корнем,
+ * каталог `v3/` удалён. Путь тут один и используется и для скана, и для
+ * разбора импортов ниже; разъехаться половинки не могут, а `read()` на
+ * несуществующем файле падает громко, а не молча пропускает проверку.
+ */
+const LANDING_ROOT = "page.tsx";
+const V3_DIR = "_components/v3";
+
+/**
+ * Что сканируем. Список НЕ пишется руками: он выводится из каталога
+ * компонентов витрины плюс сам корень страницы. Захардкоженный перечень уже
+ * отстал однажды — страница рендерила пятнадцать компонентов, в списке стояло
+ * одиннадцать, и «возражения» (естественное место для «в 9 из 10 случаев»)
+ * оказались вне охраны. Каталог отстать не может: файл, положенный рядом с
+ * остальными, попадает под гейт тем же коммитом, которым появился.
+ *
+ * Компонент, положенный ВНЕ каталога, каталогом не ловится — за это отвечает
+ * отдельная проверка «всё, что импортирует корень, попало в список».
+ */
+const V3_SOURCES = [
+ LANDING_ROOT,
+ ...readdirSync(join(ROOT, V3_DIR))
+ .filter((name) => /\.tsx?$/u.test(name))
+ .sort()
+ .map((name) => `${V3_DIR}/${name}`),
+];
+
+/**
+ * Формы, в которых величина попадает на экран. Ищутся ИМЕННО они, а не любые
+ * цифры: `useState(0)`, `slice(0, 3)` и `toFixed(2)` — это код, а «14,5 %»,
+ * «42 700», «±6 дн.» и «×2,4» — утверждения о рынке.
+ *
+ * ЧЕГО СКАНЕР НЕ ЛОВИЛ ДО 29.08.2026. Ревьюер вписал в DealsTickerV3 четыре
+ * маркетинговых утверждения, и гейт остался зелёным: он проверял ФОРМУ записи,
+ * а формы были соседние. «42700» — разделитель тысяч не пробелом, а никак;
+ * «квартир»/«городах» — единица вне короткого списка; «в 2 раза» — кратность
+ * словами вместо «×2»; «9 из 10» — доля словами. Последнее особенно дорого:
+ * «42 города РФ» стояло отдельной строкой в таблице расхождений СТАРОГО гейта,
+ * то есть новый пропускал ровно тот класс вранья, ради которого заводился.
+ */
+const MEASUREMENT_SHAPES: readonly { rx: RegExp; what: string }[] = [
+ { rx: /\d[\s ]\d{3}/u, what: "число с разделителем тысяч (42 700)" },
+ { rx: /(? rx.test(code)).map(({ what }) => what);
+}
+
+describe("гейт витринных чисел (#2904)", () => {
+ it.each(V3_SOURCES)("в %s нет чисел, вписанных руками", (rel) => {
+ const found = handwrittenValues(read(rel));
+ expect(found, [
+ `в ${rel} найдена величина, вписанная в код: ${found.join(", ")}.`,
+ "Витринное число обязано приходить из данных — formatStat() по /stats,",
+ "строки /showcase — либо из landing-facts.ts, где у него есть выборка,",
+ "источник и оговорки. Нет источника — блок не рендерится.",
+ ].join(" ")).toEqual([]);
+ });
+
+ it.each(Object.entries(BACKTEST))("величина %s в landing-facts заполнена целиком", (key, value: MeasuredValue) => {
+ expect(value.text.trim(), `${key}: пустое значение`).not.toBe("");
+ expect(Number.isInteger(value.sampleN) && value.sampleN > 0, `${key}: выборка не указана`).toBe(true);
+ expect(value.source.trim().length, `${key}: не сказано, откуда число`).toBeGreaterThan(20);
+ expect(value.caveats.length, `${key}: нет ни одной оговорки`).toBeGreaterThan(0);
+ for (const caveat of value.caveats) {
+ expect(caveat.trim(), `${key}: пустая оговорка`).not.toBe("");
+ }
+ });
+
+ it("сканер видит вписанное число и молчит на честном коде — иначе он проходил бы вхолостую", () => {
+ // Контроль на инструмент. Слева — ровно то, что гейт обязан ловить
+ // (величины из прежнего marketing-v3.ts), справа — код, который в
+ // компонентах есть и ругаться на который нельзя.
+ for (const bad of [
+ "
СРЕДНЯЯ ОШИБКА — 1,8%
",
+ "
{`42 700 проверок`}
",
+ "const days = '±6 дн.';",
+ "
×2,4 дольше продаётся
",
+ "
150 ₽
",
+ "",
+ // Четыре строки ревьюера. Он вписал их в DealsTickerV3, и гейт остался
+ // зелёным 17/17: каждая проходила МИМО прежних шести форм. Они здесь не
+ // ради полноты — без них правило «сканер проверен на образцах» повторяло
+ // то, что сканер и так знает, то есть покрытия не расширяло.
+ "
Точность 9 из 10 прогнозов
",
+ "
Проверено более 42700 квартир по всей России
",
+ "
МЕРА в 2 раза точнее риелтора
",
+ "
Работаем в 42 городах
",
+ ]) {
+ expect(handwrittenValues(bad), `сканер не увидел вписанное число: ${bad}`).not.toEqual([]);
+ }
+ for (const good of [
+ 'const MIN_ROUNDS = 3;\nconst [idx, setIdx] = useState(0);',
+ 'deals.slice(0, 3).map(toGameRound)',
+ 'const STEP_MLN = 0.05;\nMath.ceil(v * 2) / 2',
+ '
',
+ '/* в макете стояло 1,8% и 42 700 — снято */\nconst x = 1;',
+ // Живые строки из компонентов: счётчик берётся из длины массива, а не
+ // вписан. Расширяя список единиц, легко начать ругаться и на них.
+ 'ДАННЫЕ ПО {OBLAST_CITIES.length} ГОРОДАМ: {names}',
+ 'РАУНД {Math.min(idx + 1, rounds.length)} ИЗ {rounds.length}',
+ '`Показано ${count(s.written)} строк из ${count(s.eligible)} годных`',
+ ]) {
+ expect(handwrittenValues(good), `сканер ругается на честный код: ${good}`).toEqual([]);
+ }
+ });
+
+ /**
+ * Список сканируемых файлов обязан покрывать то, что реально рендерит
+ * корень. Каталог `_components/v3` ловит соседей автоматически; эта проверка
+ * ловит компонент, положенный ВНЕ каталога и подключённый к странице, —
+ * случай, при котором каталожный вывод отстал бы так же, как отставал
+ * захардкоженный список.
+ */
+ it("каждый компонент, который импортирует корень страницы, попал в список сканируемых", () => {
+ // Корень лежит в самом `mera-public/`, поэтому его относительные импорты
+ // разрешаются от ROOT. Пока страница жила в `v3/`, здесь стоял `v3` —
+ // после переезда тот же путь дал бы `_components/` мимо каталога, и
+ // проверка молча перестала бы что-либо находить.
+ const pageDir = resolve(ROOT);
+ const rendered = [...read(LANDING_ROOT).matchAll(/from\s+"(\.[^"]+)"/gu)]
+ .map(([, spec]) => relative(ROOT, resolve(pageDir, spec)).split("\\").join("/"))
+ .filter((rel) => rel.startsWith("_components/"));
+
+ expect(rendered.length, "в корне не нашлось ни одного импорта — зонд сломан").toBeGreaterThan(0);
+ const missed = rendered.filter((rel) => !V3_SOURCES.some((src) => src === rel || src === `${rel}.tsx` || src === `${rel}.ts`));
+ expect(missed, [
+ `корень рендерит ${missed.join(", ")}, а гейт эти файлы не читает.`,
+ "Компоненты витрины живут в _components/v3 — оттуда список набирается сам.",
+ ].join(" ")).toEqual([]);
+ });
+
+ it("плейсхолдерные экспорты не вернулись под новым именем", () => {
+ for (const rel of V3_SOURCES) {
+ expect(stripComments(read(rel)), `${rel} снова тянет *_PLACEHOLDER`).not.toMatch(/_PLACEHOLDER\b/);
+ }
+ });
+
+ /**
+ * НАЛИЧИЯ СЛОВА `robots` НЕДОСТАТОЧНО. Проверка на подстроку остаётся
+ * зелёной при `index: true` — то есть ровно тогда, когда страница уходит в
+ * индекс. Поэтому сверяется значение.
+ *
+ * ПРОВЕРЯЕТСЯ ПАРА, А НЕ ОДИН ФАЙЛ. Пока витрина жила на `v3/page.tsx`, у
+ * неё был собственный `export const metadata` и флаг стоял в двух местах —
+ * обоих и требовали. С переездом в корень второй экземпляр снят намеренно
+ * (см. шапку `page.tsx`): метаданные держит только layout. Требовать
+ * `noindex` в `page.tsx` теперь значило бы требовать вернуть дубль, а просто
+ * убрать проверку — оставить дыру: страничные `metadata` в Next
+ * ПЕРЕКРЫВАЮТ layout, и `index: true` на уровне корня погасил бы флаг, не
+ * тронув файла, за которым тут следят. Отсюда две половины: в layout стоит
+ * noindex, а корень своих метаданных не заводит вовсе.
+ */
+ const NOINDEX = /robots:\s*\{\s*index:\s*false\s*,\s*follow:\s*false\s*\}/u;
+
+ it("в layout.tsx стоит noindex, а не просто слово robots", () => {
+ expect(
+ read("layout.tsx"),
+ "layout.tsx: витрина открыта поисковикам — маршрут наружу ещё не открыт",
+ ).toMatch(NOINDEX);
+ });
+
+ it("корень не заводит своих metadata — иначе noindex из layout перекрыт незаметно", () => {
+ expect(
+ stripComments(read(LANDING_ROOT)),
+ `${LANDING_ROOT}: появился export const metadata — он перекрывает layout, и noindex надо проверять здесь же`,
+ ).not.toMatch(/export\s+const\s+metadata\b/u);
+ });
+});
diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx
new file mode 100644
index 00000000..ed59741d
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx
@@ -0,0 +1,133 @@
+/**
+ * Проверка «нет данных — нет блока» на самой разметке.
+ *
+ * Гейт `landing-numbers-gate` следит, что числа не вписаны руками; здесь
+ * проверяется вторая половина правила — что при отсутствии величины блок
+ * ИСЧЕЗАЕТ, а не показывает ноль, прочерк или подпись без числа. Отказ ручки
+ * `/stats` в проде выглядит именно так: пустой объект.
+ */
+import { render, screen } from "@testing-library/react";
+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 => ({
+ value,
+ sample_n,
+ note,
+ computed_at: "2026-08-29T03:00:00+00:00",
+});
+
+const STATS = {
+ estimates_total: stat(1123, 1123, "Расчётов сделано в системе"),
+ estimates_period_days: stat(62, 1123, "Дней между первым и последним расчётом"),
+ listing_age_median_days: stat(26, 25943, "Медианная ЭКСПОЗИЦИЯ активного объявления"),
+ price_cut_share_pct: stat(48.1, 6276, "Доля объявлений, снижавших цену"),
+ 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: [
+ {
+ district: "Юго-Западный",
+ rooms: 2,
+ area_m2: 52,
+ floor: 3,
+ total_floors: 9,
+ deal_quarter: "II кв. 2026",
+ predicted_rub: 5210000,
+ fact_rub: 5180000,
+ err_pct: 0.58,
+ n_analogs: 12,
+ note: "Прогноз посчитан по активным объявлениям на дату пересчёта",
+ },
+ ],
+ stats: {
+ considered: 4000,
+ priced: 900,
+ no_prediction: 3100,
+ incomplete: 12,
+ eligible: 888,
+ written: 20,
+ with_district: 17,
+ rejection_rule: "Строка не попадает на витрину, только если данных нет",
+ },
+};
+
+describe("витрина лэндинга v3 без данных", () => {
+ it("«Точность»: с данными показывает величины вместе с выборкой и коридором", () => {
+ render();
+ expect(screen.getByText("14,5 %")).toBeTruthy();
+ expect(screen.getByText(/коридор шириной ±37 %/)).toBeTruthy();
+ expect(screen.getByText("325 из 327")).toBeTruthy();
+ expect(screen.getByText(/по 25 943 объявлениям/)).toBeTruthy();
+ // Подпись витрины: сколько рассмотрено и по какому правилу отсеяно.
+ expect(screen.getByText(/рассмотрено сделок: 4 000/)).toBeTruthy();
+ });
+
+ it("«Точность»: пустой /stats снимает плитки и таблицу, а не обнуляет их", () => {
+ const { container } = render();
+ expect(screen.queryByText("расчётов сделано")).toBeNull();
+ expect(screen.queryByText("медианная экспозиция активного объявления")).toBeNull();
+ expect(container.querySelector('[role="table"]')).toBeNull();
+ expect(container.textContent).not.toMatch(/[—-]\s*дн\./);
+ // Бэктест не зависит от ручки — он остаётся вместе со своими оговорками.
+ expect(screen.getByText("14,5 %")).toBeTruthy();
+ });
+
+ it("«Цена ошибки»: у двух величин РАЗНЫЕ выборки, и обе подписаны", () => {
+ render();
+ expect(screen.getByText("-2,17 %")).toBeTruthy();
+ expect(screen.getByText(/по 3 018 объявлениям/)).toBeTruthy();
+ expect(screen.getByText("48,1 %")).toBeTruthy();
+ expect(screen.getByText(/по 6 276 объявлениям/)).toBeTruthy();
+ });
+
+ it("«Цена ошибки»: без /stats остаётся только цена — она из оферты, а не из замера", () => {
+ render();
+ expect(screen.queryByText(/столько объявлений на Домклике снижали цену/)).toBeNull();
+ expect(screen.getByText("150 ₽")).toBeTruthy();
+ });
+
+ /**
+ * Величина не должна стоять под подписью, к которой не привязана. Экспозиция
+ * считается с фильтром по городу (`_LISTING_AGE_SQL`), медиана аналогов — по
+ * всей таблице `trade_in_estimates`; под общей шапкой «пример по рынку
+ * Екатеринбурга» вторая была бы подписана чужой границей. Проверяем не
+ * наличие двух строк, а СОДЕРЖИМОЕ каждой: слить их обратно в одну и
+ * оставить тест зелёным невозможно.
+ */
+ it("HeroV3: аналоги подписаны своей границей и своей оговоркой, а не Екатеринбургом", () => {
+ render();
+ 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();
+ expect(screen.queryByText(/Пример по рынку Екатеринбурга/)).toBeNull();
+ expect(screen.queryByText(/По всем расчётам МЕРЫ/)).toBeNull();
+ });
+});
diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/marketing-placeholders-gate.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/marketing-placeholders-gate.test.ts
deleted file mode 100644
index db5ad391..00000000
--- a/tradein-mvp/frontend/src/app/mera-public/__tests__/marketing-placeholders-gate.test.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-/**
- * Гейт #2904: снять `noindex` можно только вместе с плейсхолдерными числами.
- *
- * В `marketing-v3.ts` лежат величины из дизайн-макета, НЕ подтверждённые замером:
- *
- * | здесь | факт по замеру |
- * | ошибка 1,8% | 13,2% медиана |%| (бэктест n=277), held-out 16,1% |
- * | 83% в диапазон | 81,95%, но коридор шириной 74% от точки |
- * | ±6 дн по сроку | сверки срока с фактом НЕ СУЩЕСТВУЕТ |
- * | 42 700 проверок за год | 1 084 оценки всего (база живёт 2,5 месяца) |
- * | 42 города РФ | 6 городов области, полное покрытие — 1 (ЕКБ) |
- *
- * Пока страница закрыта `robots: { index: false }`, эти строки никому не видны, и
- * держать их допустимо. Публикация же недостоверных характеристик ПЛАТНОЙ услуги
- * подпадает под ст. 5 ФЗ «О рекламе» и ЗоЗПП.
- *
- * До сих пор это правило жило ТОЛЬКО в комментарии модуля. Правило, записанное
- * словами, не мешает снять одну строку в layout.tsx и выкатить страницу — поэтому
- * гейт здесь механический: он краснеет ровно в тот момент, когда noindex исчезает,
- * а плейсхолдеры остаются.
- *
- * Тест НЕ запрещает открывать страницу. Он требует, чтобы открытие шло вместе с
- * заменой чисел на вычисляемые (или их снятием) — то есть чтобы решение было
- * осознанным, а не побочным следствием правки метаданных.
- */
-import { readFileSync } from "node:fs";
-import { join } from "node:path";
-
-import { describe, expect, it } from "vitest";
-
-const ROOT = join(__dirname, "..");
-
-function read(rel: string): string {
- return readFileSync(join(ROOT, rel), "utf8");
-}
-
-/**
- * Компоненты лэндинга v3, которые могут ссылаться на плейсхолдеры.
- *
- * САМОЙ СТРАНИЦЫ (`page.tsx`) ЗДЕСЬ НЕТ, И ЭТО НЕ ПРОПУСК. 29.08.2026 лэндинг
- * переехал из `v3/page.tsx` в корень, и путь в этом списке обновили вслед за
- * файлом — с обоснованием «иначе строка перестала бы что-либо сканировать».
- * Обоснование звучало как проверенный факт, но проверено не было: ни в
- * `page.tsx`, ни в прежнем `v3/page.tsx` нет ни одного имени из
- * `PLACEHOLDER_EXPORTS` — страница только собирает секции из импортов, а числа
- * живут в самих секциях. То есть строка не давала покрытия ни до переезда, ни
- * после, и держать её значило держать ложное ощущение охвата. Появится величина
- * прямо в разметке страницы — строку сюда вернуть (и тогда она будет что-то
- * значить).
- */
-const V3_SOURCES = [
- "_components/v3/HeroV3.tsx",
- "_components/v3/AccuracyV3.tsx",
- "_components/v3/FreeCheckCard.tsx",
- "_components/v3/FreeResultV3.tsx",
- // Макет 29.08: лента и игра тоже стоят на реквизитных величинах.
- "_components/v3/DealsTickerV3.tsx",
- "_components/v3/GuessGameV3.tsx",
- // Раздел статей (29.08) — плитки §4 статьи берут те же плейсхолдеры. Без
- // этой строки гейт остался бы зелёным при снятии noindex: он сканировал бы
- // только лэндинг, а величины уже живут и на статье.
- "articles/articles-content.ts",
-];
-
-const PLACEHOLDER_EXPORTS = [
- "ACCURACY_PLACEHOLDER",
- "COST_OF_ERROR_PLACEHOLDER",
- "FREE_SAMPLE_PLACEHOLDER",
- "PROOF_ROWS_PLACEHOLDER",
- "GAME_ROUNDS_PLACEHOLDER",
-];
-
-function placeholdersInUse(): string[] {
- const used: string[] = [];
- for (const rel of V3_SOURCES) {
- let src: string;
- try {
- src = read(rel);
- } catch {
- continue; // файл переименован/удалён — не повод падать
- }
- for (const name of PLACEHOLDER_EXPORTS) {
- if (src.includes(name) && !used.includes(name)) used.push(name);
- }
- }
- return used;
-}
-
-function noindexIsOn(): boolean {
- const layout = read("layout.tsx").replace(/\s+/g, " ");
- return layout.includes("robots: { index: false");
-}
-
-describe("гейт noindex ↔ плейсхолдерные числа (#2904)", () => {
- it("пока плейсхолдеры на странице — noindex обязан стоять", () => {
- const used = placeholdersInUse();
- if (used.length === 0) {
- // Числа заменены вычисляемыми — гейт больше не нужен, открывать можно.
- return;
- }
- expect(noindexIsOn(), [
- `лэндинг v3 всё ещё использует непроверенные величины: ${used.join(", ")},`,
- "но `robots: { index: false }` в layout.tsx снят.",
- "Перед открытием страницы наружу каждая цифра обязана быть заменена",
- "вычисляемой либо снята — см. шапку marketing-v3.ts и issue #2904.",
- ].join(" ")).toBe(true);
- });
-
- it("сам гейт видит и плейсхолдеры, и noindex — иначе он проходил бы вхолостую", () => {
- // Контроль на инструмент: если оба зонда перестанут находить своё
- // (переименование файлов, смена формы записи), проверка выше станет
- // зелёной по построению и перестанет что-либо охранять.
- expect(placeholdersInUse().length, "ни один плейсхолдер не найден в компонентах v3 — зонд сломан").toBeGreaterThan(0);
- expect(read("layout.tsx"), "layout.tsx не содержит блока robots — зонд сломан").toContain("robots");
- });
-
- it("расхождение «заявлено против замера» остаётся в шапке модуля", () => {
- // Таблица — единственное место, где зафиксировано, НАСКОЛЬКО числа расходятся
- // с фактом. Потеряется она — и следующий читатель решит, что цифры проверены.
- const mod = read("marketing-v3.ts");
- expect(mod).toContain("13,2%");
- expect(mod).toContain("НЕ СУЩЕСТВУЕТ");
- expect(mod).toContain("1 084");
- });
-});
diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/public-api-stats.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-api-stats.test.ts
new file mode 100644
index 00000000..86c63e22
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-api-stats.test.ts
@@ -0,0 +1,205 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import {
+ fetchLandingStats,
+ fetchShowcase,
+ formatStat,
+ type LandingStat,
+ type LandingStats,
+} from "../public-api";
+
+/**
+ * Гейт честной деградации серверного слоя витрины.
+ *
+ * Проверяется не «функция что-то вернула», а ровно то, ради чего слой написан:
+ * при любом сбое на витрину не попадает НИ ОДНОГО числа, которого никто не
+ * измерял. Кейсы двусторонние — рядом с провальным входом стоит успешный,
+ * иначе тест зеленел бы и на функции `() => null`.
+ *
+ * Фальсификация (проделана вручную 29.08.2026): вернуть из `formatStat` при
+ * отсутствии величины `{ text: "0", sample: null, ... }` вместо `null` —
+ * краснеют «ключа нет», «пустой ответ», «сеть легла», «500»; вернуть из
+ * `fetchLandingStats` при сбое заглушку с нулями — краснеют те же четыре.
+ */
+
+/**
+ * Intl.NumberFormat("ru-RU") разделяет разряды НЕРАЗРЫВНЫМ пробелом, и наша
+ * единица приклеивается таким же — сравнивать с обычным пробелом в литерале
+ * нельзя. Нормализуем, чтобы ожидания в тесте читались глазами.
+ */
+const norm = (s: string | null | undefined) => s?.replace(/[ ]/g, " ").replace(/−/g, "-");
+
+const OK: LandingStats = {
+ estimates_total: {
+ value: 1123,
+ sample_n: 1123,
+ note: "оценок за период",
+ computed_at: "2026-08-29T03:00:00+00:00",
+ },
+};
+
+function mockFetch(impl: () => Promise) {
+ vi.stubGlobal("fetch", vi.fn(impl));
+}
+
+/** Ответ ручки с произвольным статусом и телом. */
+function response(status: number, body: unknown): Response {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ json: async () => body,
+ } as unknown as Response;
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("fetchLandingStats: сбой не превращается в число", () => {
+ it("исправная ручка отдаёт метрику вместе с выборкой", async () => {
+ mockFetch(async () => response(200, OK));
+ const out = formatStat(await fetchLandingStats(), "estimates_total");
+ expect(norm(out?.text)).toBe("1 123");
+ expect(norm(out?.sample)).toBe("n = 1 123");
+ expect(out?.note).toBe("оценок за период");
+ expect(out?.computedAt).toBe("2026-08-29T03:00:00+00:00");
+ });
+
+ it("пустой объект — величины нет, а не ноль", async () => {
+ mockFetch(async () => response(200, {}));
+ const stats = await fetchLandingStats();
+ expect(stats).toEqual({});
+ expect(formatStat(stats, "estimates_total")).toBeNull();
+ });
+
+ it("нужного ключа в ответе нет — величины нет", async () => {
+ mockFetch(async () => response(200, OK));
+ expect(formatStat(await fetchLandingStats(), "deals_total_12m")).toBeNull();
+ });
+
+ it("сеть легла — пустой набор, а не подстановка", async () => {
+ mockFetch(() => Promise.reject(new TypeError("fetch failed")));
+ const stats = await fetchLandingStats();
+ expect(stats).toEqual({});
+ expect(formatStat(stats, "estimates_total")).toBeNull();
+ });
+
+ it("500 — пустой набор, а не подстановка", async () => {
+ mockFetch(async () => response(500, { detail: "boom" }));
+ const stats = await fetchLandingStats();
+ expect(stats).toEqual({});
+ expect(formatStat(stats, "estimates_total")).toBeNull();
+ });
+
+ it("200 с не-JSON телом — пустой набор", async () => {
+ mockFetch(
+ async () =>
+ ({
+ ok: true,
+ status: 200,
+ json: async () => {
+ throw new SyntaxError("Unexpected token < in JSON");
+ },
+ }) as unknown as Response,
+ );
+ expect(await fetchLandingStats()).toEqual({});
+ });
+
+ it("страница не падает: функция не бросает ни на одном виде сбоя", async () => {
+ const failures = [
+ () => Promise.reject(new TypeError("fetch failed")),
+ async () => response(503, null),
+ async () => response(200, null),
+ ];
+ for (const impl of failures) {
+ mockFetch(impl);
+ await expect(fetchLandingStats()).resolves.toBeDefined();
+ }
+ });
+});
+
+describe("formatStat: n нельзя потерять, ноль нельзя выдумать", () => {
+ const stat = (over: Partial): LandingStats => ({
+ m: {
+ value: 48.1,
+ sample_n: 6276,
+ note: null,
+ computed_at: "2026-08-29T03:00:00+00:00",
+ ...over,
+ },
+ });
+
+ it("число едет вместе с подписью выборки", () => {
+ const out = formatStat(stat({}), "m", { unit: "%", digits: 1, sampleWord: "объявлениям" });
+ expect(norm(out?.text)).toBe("48,1 %");
+ expect(norm(out?.sample)).toBe("по 6 276 объявлениям");
+ });
+
+ it("отрицательное значение не теряет знак", () => {
+ const out = formatStat(stat({ value: -2.17, sample_n: 3018 }), "m", { digits: 2 });
+ expect(norm(out?.text)).toBe("-2,17");
+ expect(norm(out?.sample)).toBe("n = 3 018");
+ });
+
+ it("value = null — величины нет", () => {
+ expect(formatStat(stat({ value: null }), "m")).toBeNull();
+ });
+
+ it("пустая строка — величины нет", () => {
+ expect(formatStat(stat({ value: " " }), "m")).toBeNull();
+ });
+
+ it("sample_n = null — величина есть, подписи нет (но не выдуманный ноль)", () => {
+ const out = formatStat(stat({ sample_n: null }), "m");
+ expect(norm(out?.text)).toBe("48,1");
+ expect(out?.sample).toBeNull();
+ });
+
+ it("ноль как ИЗМЕРЕННАЯ величина показывается — это не то же, что отсутствие", () => {
+ expect(norm(formatStat(stat({ value: 0, sample_n: 12 }), "m")?.text)).toBe("0");
+ });
+});
+
+describe("fetchShowcase: нечего показать — секции нет", () => {
+ const SHOWCASE = {
+ computed_at: "2026-08-29T03:00:00+00:00",
+ deals: [
+ {
+ district: "Пионерский",
+ rooms: 2,
+ area_m2: 54.3,
+ floor: 5,
+ total_floors: 9,
+ deal_quarter: "II квартал 2026",
+ predicted_rub: 7_100_000,
+ fact_rub: 7_250_000,
+ err_pct: -2.1,
+ n_analogs: 14,
+ note: "",
+ },
+ ],
+ stats: null,
+ };
+
+ it("строки есть — витрина едет целиком", async () => {
+ mockFetch(async () => response(200, SHOWCASE));
+ const out = await fetchShowcase();
+ expect(out?.deals).toHaveLength(1);
+ expect(out?.deals[0].district).toBe("Пионерский");
+ });
+
+ it("пустой список — null, а не витрина из нуля строк", async () => {
+ mockFetch(async () => response(200, { computed_at: null, deals: [], stats: null }));
+ expect(await fetchShowcase()).toBeNull();
+ });
+
+ it("500 — null", async () => {
+ mockFetch(async () => response(500, {}));
+ expect(await fetchShowcase()).toBeNull();
+ });
+
+ it("сеть легла — null, без исключения", async () => {
+ mockFetch(() => Promise.reject(new TypeError("fetch failed")));
+ await expect(fetchShowcase()).resolves.toBeNull();
+ });
+});
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/InnerHeader.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/InnerHeader.tsx
index 398f140a..be973f8f 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/InnerHeader.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/InnerHeader.tsx
@@ -8,7 +8,7 @@ import { PublicLink } from "./PublicLink";
* (`v3/HeaderV3.tsx`).
*
* Из неё убраны навигация по секциям лэндинга, селектор города и CTA
- * «Проверить за 30 секунд». Причина у всех трёх одна: человек уже ушёл с
+ * «Проверить квартиру». Причина у всех трёх одна: человек уже ушёл с
* лэндинга. Якоря вроде `#accuracy` тут просто не на что навести (секций нет
* на этих страницах), город выбирается прямо в форме проверки — а второй
* селектор города в шапке означал бы два места, задающих одно значение.
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
index 13dbf946..8e9d73c4 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/estimate/ReportResult.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/estimate/ReportResult.tsx
@@ -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 (
- ПОЛНЫЙ ОТЧЁТ · 150 ₽
+
+ ПОЛНЫЙ ОТЧЁТ · {SERVICE_PRICE_RUB} ₽
+
@@ -159,17 +174,17 @@ function PaidTeaser() {
- Цена и срок продажи — следующий шаг
+ Ваша цена — следующий шаг
- Полный расчёт стоит 150 ₽: диапазон цены, цена за квадратный
- метр и список квартир, на которых он построен. Один платёж, без
- подписки. Приём оплаты сейчас подключается — как только заработает,
- кнопка появится здесь.
+ Полный расчёт стоит {SERVICE_PRICE_RUB} ₽: диапазон цены, цена
+ за квадратный метр и список квартир, на которых он построен. Один
+ платёж, без подписки. Приём оплаты сейчас подключается — как только
+ заработает, кнопка появится здесь.
- Если надёжный расчёт не получится — вернём 150 ₽:{" "}
- условия возврата.
+ Если надёжный расчёт не получится — вернём {SERVICE_PRICE_RUB}
+ ₽: условия возврата.
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx
index a240140b..0a3be53d 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx
@@ -1,21 +1,27 @@
/**
- * AccuracyV3 — «Точность»: KPI-плитки + таблица сверок прогноза с фактом
- * сделки (макет v3, ~строки 231-283, id="accuracy"). Серверный компонент.
+ * AccuracyV3 — «Точность»: KPI-плитки + таблица «прогноз против цены сделки»
+ * (макет v3, ~строки 231-283, id="accuracy"). Серверный компонент.
*
- * KPI-значения — из `ACCURACY_PLACEHOLDER` (marketing-v3.ts), не хардкод: это
- * плейсхолдерные маркетинговые величины, гейтящиеся `noindex` до подтверждения
- * замером (см. шапку файла).
+ * ОТКУДА ЧИСЛА. Три первых плитки — разовая сверка прогноза с ценой ДКП
+ * (`landing-facts.ts`, там же источник и оговорки). Экспозиция и число
+ * расчётов — `/stats`, каждая плитка рендерится только если величина пришла:
+ * недоступная ручка снимает плитку, а не показывает ноль. Таблица — `/showcase`
+ * (реальные сделки Росреестра против прогноза), и вместе со строками едет
+ * подпись: сколько сделок рассмотрено, сколько годных не поместилось и по
+ * какому правилу отсеяно остальное.
*
- * Таблица сверок — `PROOF_ROWS_PLACEHOLDER`. В самом макете под неё не было
- * заполнено ни одной реальной строки (только `{{ r.addr }}`-шаблон), поэтому
- * строки — витринный пример формата отчёта, а не факт. Ровно поэтому подпись
- * под таблицей ЗАМЕНЕНА: в макете там было «ДАННЫЕ ОБЕЗЛИЧЕНЫ · …» — фраза,
- * прямо утверждающая, что за строками стоят настоящие обезличенные сделки.
- * Мы этого утверждать не можем (строки придуманы для примера), поэтому подпись
- * честно называет таблицу иллюстрацией формата, а вторую половину исходной
- * фразы («"выше средней цены" — разница со средней ценой сделки по похожим
- * квартирам в районе») сохраняет — она объясняет, ЧТО значит колонка, а не
- * заявляет что-то о происхождении данных.
+ * ТРИ ПЛИТКИ БЭКТЕСТА ИДУТ КОМПЛЕКТОМ И ПОРОЗНЬ НЕ ПОКАЗЫВАЮТСЯ. «88 % в
+ * коридоре» без ширины коридора (±37 %) и без того, что уверенность расчёта
+ * низкая у 325 из 327, — это три разных способа выглядеть точнее, чем есть.
+ * Поэтому ширина коридора стоит в ПОДПИСИ к самой плитке, а не отдельной
+ * строкой мелким шрифтом, и оговорки рендерятся тут же, под сеткой.
+ *
+ * ЧЕГО ЗДЕСЬ НЕТ. Плитки «точность по сроку продажи»: `deals.days_on_market`
+ * заполнен 0 раз из 108 623 — сверять прогноз срока не с чем. На её месте
+ * стоит медианная ЭКСПОЗИЦИЯ активного объявления, названная своим именем:
+ * это «сколько висит», а не «за сколько продаётся», и знака ± у неё нет.
+ * Счётчик «из них сверено» тоже снят — контура сверки прогноза с фактом
+ * сделки клиента не существует.
*
* Таблица размечена ARIA role="table/row/columnheader/cell" (а не
- Мы сверяем каждый прогноз с реальной сделкой
+ Мы сверили прогноз с ценой сделки — вот что вышло
- {`Когда квартира продана, мы возвращаемся к своему прогнозу и считаем расхождение. Вот сводка за ${ACCURACY_PERIOD_LABEL}.`}
+ {`Сверка прогноза с ценой ДКП Росреестра: ${BACKTEST_PERIOD_LABEL}, ${BACKTEST.priceError.sampleN} сделок.`}
+ {estimates
+ ? ` Расчёты в системе живут в другом окне: ${estimates.text}${
+ period ? ` за ${period.text}` : ""
+ }.`
+ : ""}
- {KPI_TILES.map((tile) => (
-
+ {tiles.map((tile) => (
+
{tile.value}
{tile.label}
{tile.note}
))}
-
-
- {ACCURACY_PLACEHOLDER.checks}
-
-
проверок за год
-
- {ACCURACY_PLACEHOLDER.checksNote}
-
-
+ {estimates && (
+
+
{estimates.text}
+
расчётов сделано
+
+ {[estimates.note, period ? `окно наблюдения — ${period.text}` : null]
+ .filter(Boolean)
+ .join(" · ")}
+
+
+ )}
-
-
- Объект
- Мера сказала
- Факт сделки
- Расхождение
-
- Выше средней цены по району
-
-
- {PROOF_ROWS_PLACEHOLDER.map((row) => (
-
-
- {row.addr}
- {row.meta}
-
-
- {row.said}
-
-
- {row.fact}
-
-
- {row.err}
-
-
-
-
{row.gain}
-
{row.gainPct}
-
-
-
+
+ {caveats.map((line) => (
+
{line}
))}
-
-
- Таблица иллюстрирует формат отчёта, это не список конкретных сделок.
- «Выше средней цены» — разница со средней ценой сделки по похожим
- квартирам в районе.
-
+ {showcaseStats
+ ? `Показано ${count(showcaseStats.written)} строк из ${count(showcaseStats.eligible)} годных, рассмотрено сделок: ${count(showcaseStats.considered)}. Район известен у ${count(showcaseStats.with_district)} из показанных. ${showcaseStats.rejection_rule}`
+ : "Подпись прогона не пришла — из чего отобраны строки, сказать нечем."}
+
+
{deals[0].note}
+ >
+ )}
);
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx
index b7281015..c90201f6 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx
@@ -1,18 +1,74 @@
/**
- * CostOfErrorV3 — «Цена ошибки»: три плитки про стоимость неверной цены
+ * CostOfErrorV3 — «Цена ошибки»: плитки про стоимость неверной цены
* (макет v3, ~строки 376-400, без id). Серверный компонент.
*
- * Значения и подписи — из `COST_OF_ERROR_PLACEHOLDER` (marketing-v3.ts),
- * заголовок цитирует `SERVICE_PRICE_RUB` (content.ts) — тот же источник
- * правды, что и у цены отчёта во всём остальном лэндинге, а не отдельный
- * литерал «150».
+ * ОТКУДА ЧИСЛА. Обе рыночные величины — `/stats`, и у них РАЗНЫЕ знаменатели:
+ * доля снижавших считается по всем наблюдаемым объявлениям, а медиана
+ * просадки — только по тем, кто снижал. Поэтому размер выборки печатается у
+ * каждой плитки отдельно, а в заголовке плитки прямо сказано «среди тех, кто
+ * снижал»: одна подпись на две величины была бы подменой знаменателя.
+ * Цена — `SERVICE_PRICE_RUB` (content.ts), тот же источник, что у оферты.
+ *
+ * ОБЪЯВЛЕНИЯ, А НЕ ПРОДАВЦЫ. `price_cut_share_pct` — доля ОБЪЯВЛЕНИЙ, и
+ * только по Домклику (единственный источник, где триггер пишет стартовую
+ * цену, см. шапку `landing_stats.py`). Один продавец ведёт несколько
+ * объявлений, поэтому «столько продавцов снижают цену» приписывало величине
+ * единицу, которой её не мерили: подпись приведена к измеренному, а источник
+ * назван прямо в ней, а не только в `note` под плиткой.
+ *
+ * ЧЕГО ЗДЕСЬ БОЛЬШЕ НЕТ. Плитки «×2,4 дольше продаётся квартира с завышенной
+ * ценой»: замер даёт ×1,04 (79 дней против 76), и это уже с цензурой в пользу
+ * заявления. Её место заняла доля снижающих цену — измеренная величина про то
+ * же самое: рынок не соглашается с завышенной ценой.
*/
import { SERVICE_PRICE_RUB } from "../../content";
-import { COST_OF_ERROR_PLACEHOLDER } from "../../marketing-v3";
+import { formatStat, type LandingStats } from "../../public-api";
import styles from "../../landing-v3.module.css";
-export function CostOfErrorV3() {
+interface CostTile {
+ readonly key: string;
+ readonly value: string;
+ readonly title: string;
+ readonly text: string;
+}
+
+export function CostOfErrorV3({ stats }: { stats: LandingStats }) {
+ const drop = formatStat(stats, "price_cut_median_pct_per_month", {
+ unit: "%",
+ digits: 2,
+ sampleWord: "объявлениям",
+ });
+ const share = formatStat(stats, "price_cut_share_pct", {
+ unit: "%",
+ digits: 1,
+ sampleWord: "объявлениям",
+ });
+
+ const tiles: CostTile[] = [];
+ if (drop) {
+ tiles.push({
+ key: "drop",
+ value: drop.text,
+ title: "медианное изменение цены за месяц — среди тех, кто снижал",
+ text: [drop.note, drop.sample].filter(Boolean).join(" · "),
+ });
+ }
+ if (share) {
+ tiles.push({
+ key: "share",
+ value: share.text,
+ title: "столько объявлений на Домклике снижали цену",
+ text: [share.note, share.sample].filter(Boolean).join(" · "),
+ });
+ }
+ tiles.push({
+ key: "price",
+ value: `${SERVICE_PRICE_RUB} ₽`,
+ title: "стоит узнать правильную цену заранее",
+ text: "Один платёж. Без подписки. Отчёт остаётся у вас по ссылке.",
+ });
+
return (
@@ -21,16 +77,16 @@ export function CostOfErrorV3() {
ПОЧЕМУ ЭТО ВАЖНО
- {`${SERVICE_PRICE_RUB} ₽ против двух месяцев вашей жизни`}
+ {`${SERVICE_PRICE_RUB} ₽ против двух месяцев вашей жизни`}
- {COST_OF_ERROR_PLACEHOLDER.map((tile, index) => {
- const dark = index === COST_OF_ERROR_PLACEHOLDER.length - 1;
+ {tiles.map((tile, index) => {
+ const dark = index === tiles.length - 1;
return (
@@ -27,23 +35,20 @@ export function DealsTickerV3() {
ПРОГНОЗ → ФАКТ
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx
index 789793b4..bd045ed1 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx
@@ -4,7 +4,7 @@
* FreeCheckCard — карточка «Бесплатная проверка» на первом экране (макет v3,
* `МЕРА для себя v3.dc.html` строки ~129-170).
*
- * ЧТО ОНА ДЕЛАЕТ СЕГОДНЯ. Проверяет пять полей и уводит на `/estimate`, где
+ * ЧТО ОНА ДЕЛАЕТ СЕГОДНЯ. Проверяет шесть полей и уводит на `/estimate`, где
* живёт сама проверка. Макетный сценарий «спиннер 1100 мс → готовый
* результат прямо в карточке» не воспроизводится и не будет: результату нужно
* место рядом с полями, а автокомплиту — место под список; в карточке первого
@@ -15,8 +15,8 @@
* Раздел «шаг 1 из 2» / полоска прогресса / ярлык рынка (`{{ cityMarket }}`,
* `{{ stepLabel }}`) из макета сюда сознательно НЕ перенесены: они требуют
* состояния города, которым владеет шапка сайта — вне участка этой задачи
- * («первый экран: герой и карточка»), и вносили бы числовые литералы (33%,
- * 66%, 100%), которых нет ни в макете-константах, ни в `marketing-v3.ts`.
+ * («первый экран: герой и карточка»), и вносили бы числовые литералы (доли
+ * прогресса), которых нет ни в макете-константах, ни в замерах.
*
* Ветка результата ПОСТРОЕНА (`FreeResultV3.tsx`), но не подключена — см.
* TODO у `PUBLIC_ESTIMATE_ENABLED` ниже и JSDoc в `FreeResultV3.tsx`.
@@ -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";
/** Значение
- Ваша цена и срок продажи готовы
+ Ваша цена готова
Оплата подключается вместе с открытием сервиса — пока недоступна.
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx
index a5a4a74d..fb1367f4 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx
@@ -4,32 +4,35 @@
* GuessGameV3 — мини-игра «Сыграйте против МЕРЫ на реальных сделках»
* (макет 29.08, секция `#game` «V3 — игра "Угадай цену"»).
*
- * Клиентский компонент: три раунда «слайдер → ответ → сверка», итоговый экран
- * со средней ошибкой игрока против ошибки МЕРЫ. Раунды — реквизит из
- * `GAME_ROUNDS_PLACEHOLDER` (см. шапку marketing-v3.ts: контура сверки
- * «прогноз → факт» в продукте нет, «реальные сделки» здесь — макетные, и
- * страница закрыта noindex до появления вычисляемых).
+ * Раунды — РЕАЛЬНЫЕ сделки из `/showcase` (родитель готовит их через
+ * `toGameRound`): характеристики объекта, прогноз МЕРЫ и цена ДКП. Раньше
+ * здесь стояли три придуманных квартиры с придуманными адресами, а фраза «на
+ * реальных сделках» была неправдой. Сделок мало — игру не рендерит родитель:
+ * играть на одной строке под заголовком «три квартиры» нельзя.
*
- * Ошибка игрока считается медианой |ответ − факт| / факт по трём раундам —
- * та же метрика, которой описана ошибка МЕРЫ (GAME_MERA_ERROR_LABEL).
- * Финальная CTA ведёт на `/estimate` (решение StickyCtaV3 вместо макетной
- * модалки).
+ * ЧЕГО В КАРТОЧКЕ БОЛЬШЕ НЕТ: точки «ОБЪЕКТ НА КАРТЕ». Координат в данных
+ * витрины нет вовсе (улица известна у 2.7% сделок), а точка на карте — это
+ * утверждение о конкретном месте. Фон-сетка осталась декорацией, объект
+ * подписан тем, что известно: район (или прямое «район не указан»), комнаты,
+ * площадь, этаж, квартал сделки.
+ *
+ * Ошибка игрока считается медианой |ответ − факт| / факт по сыгранным раундам
+ * — той же формулой, что и ошибка МЕРЫ на этих же сделках (`err_pct` витрины).
+ * Обе величины меряют одно и то же на одной выборке, поэтому их и можно
+ * ставить рядом.
*/
import { useState } from "react";
-import {
- GAME_MERA_ERROR_LABEL,
- GAME_ROUNDS_PLACEHOLDER,
-} from "../../marketing-v3";
import styles from "../../landing-v3.module.css";
+import type { GameRound } from "./deal-view";
import { PublicLink } from "../PublicLink";
type Phase = "playing" | "revealed" | "finished";
-const fmtMln = (v: number) =>
- `${v.toFixed(2).replace(".", ",")} млн ₽`;
+const fmtMln = (v: number) => `${v.toFixed(2).replace(".", ",")} млн ₽`;
+const fmtPct = (share: number) => `${(share * 100).toFixed(1).replace(".", ",")}%`;
function median(xs: number[]): number {
const s = [...xs].sort((a, b) => a - b);
@@ -37,8 +40,7 @@ function median(xs: number[]): number {
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
}
-export function GuessGameV3() {
- const rounds = GAME_ROUNDS_PLACEHOLDER;
+export function GuessGameV3({ rounds }: { rounds: readonly GameRound[] }) {
const [idx, setIdx] = useState(0);
const [phase, setPhase] = useState("playing");
const [guess, setGuess] = useState(rounds[0].start);
@@ -68,12 +70,12 @@ export function GuessGameV3() {
setPhase("playing");
};
- const userErrPct = errors.length
- ? `${(median(errors) * 100).toFixed(1).replace(".", ",")}%`
- : "—";
- const roundErrPct = errors.length
- ? `${(errors[errors.length - 1] * 100).toFixed(1).replace(".", ",")}%`
- : "—";
+ const userErrPct = errors.length ? fmtPct(median(errors)) : "—";
+ const roundErrPct = errors.length ? fmtPct(errors[errors.length - 1]) : "—";
+ // Ошибка МЕРЫ — по ТЕМ ЖЕ раундам, что сыграл человек, а не общая цифра
+ // бэктеста: сравнивать медиану по трём сделкам с медианой по тремстам
+ // значило бы сопоставлять две разные величины.
+ const meraErrPct = fmtPct(median(rounds.map((round) => Math.abs(round.mera - round.fact) / round.fact)));
return (
@@ -85,8 +87,8 @@ export function GuessGameV3() {
Сыграйте против МЕРЫ на реальных сделках
- Три квартиры, которые уже проданы. Назовите свою цифру — потом
- увидите, что сказала МЕРА и чем всё закончилось.
+ Квартиры, которые уже проданы: данные Росреестра. Назовите свою
+ цифру — потом увидите, что сказала МЕРА и чем всё закончилось.
@@ -96,26 +98,14 @@ export function GuessGameV3() {
-
-
-
-
- ОБЪЕКТ НА КАРТЕ · {r.district}
+ {`ОБЪЕКТ · ${r.districtLabel.toUpperCase()}`}