From f612401202f37df8a8c2c5ae2cb9e4c63930e1d6 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 19:43:01 +0500 Subject: [PATCH 1/4] =?UTF-8?q?feat(mera-public):=20=D1=81=D0=B5=D1=80?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BD=D1=8B=D0=B9=20=D1=81=D0=BB=D0=BE=D0=B9?= =?UTF-8?q?=20=D0=B2=D0=B8=D1=82=D1=80=D0=B8=D0=BD=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D1=87=D0=B8=D1=81=D0=B5=D0=BB=20=D0=BB=D1=8D=D0=BD=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Загрузка /api/public/mera/stats и /showcase для серверных секций лэндинга: типы обеих ручек, честная деградация и форматирование величины вместе с размером выборки. Любой сбой (сервис недоступен, 500, не-JSON, пустой ответ, нет ключа) даёт «величины нет», а не ноль: /stats отдаёт пустой набор, /showcase — null, и ни одна из функций не бросает, чтобы отказ ручки стоил блока, а не всей страницы. Число выходит наружу только через formatStat — вместе с sample_n и note, поэтому потерять размер выборки по дороге в JSX нельзя. Адрес серверного fetch — BACKEND_URL, тот же путь, которым Next уже ходит в backend (rewrites в next.config.ts, 'internal SSR' в prod-компоуз), поэтому без /trade-in: префикс добавляет Caddy для браузера, не бэкенд. Кэш — revalidate 3600: пересчёт ночной, no-store жёг бы rate-limit ручки на всех посетителей, кэш без срока показывал бы вчерашнее до деплоя. Тесты двусторонние и фальсифицированы: подстановка нуля вместо null роняет 5 из 17, пустая витрина вместо null — 3 из 17. --- .../__tests__/public-api-stats.test.ts | 205 ++++++++++++++++++ .../src/app/mera-public/public-api.ts | 177 +++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/public-api-stats.test.ts 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/public-api.ts b/tradein-mvp/frontend/src/app/mera-public/public-api.ts index 84651541..48236aea 100644 --- a/tradein-mvp/frontend/src/app/mera-public/public-api.ts +++ b/tradein-mvp/frontend/src/app/mera-public/public-api.ts @@ -148,3 +148,180 @@ export async function fetchCoverage( signal: options.signal, }); } + +/* ------------------------------------------------------------------ * + * СЕРВЕРНАЯ ЧАСТЬ: витринные числа лэндинга. + * + * Всё выше — браузерное (форма оценки). Всё ниже вызывается ТОЛЬКО из + * серверных компонентов, поэтому здесь нет "use client" и нет пользовательских + * AbortSignal'ов. + * + * ПОЧЕМУ АДРЕС ДРУГОЙ, ЧЕМ У ФОРМЫ. `fetch` на сервере не знает origin'а — + * относительный `/trade-in/api/...` там просто не резолвится. В дереве это уже + * решено ровно одним способом: `BACKEND_URL` (next.config.ts rewrites, + * `tradein-mvp/docker-compose.prod.yml:471` — комментарий там дословно + * «internal SSR»). Тот же env, тот же адрес контейнера: Next ходит в backend + * напрямую, минуя Caddy, поэтому префикса `/trade-in` здесь нет — его + * добавляет Caddy для браузера, а не бэкенд (роутер примонтирован на + * `/api/public/mera`, backend/app/main.py:298). + * + * КЭШ — `revalidate: 3600`. Числа пересчитывает ночная задача раз в сутки + * (app/tasks/landing_stats.py), поэтому `no-store` (запрос на каждый заход + * анонима) слал бы бэкенду одинаковые вопросы и упирался бы в его же лимит + * 30/окно — на всех посетителей сразу, включая тех, кому нужна форма. + * Кэш без срока — другая крайность: пересчёт прошёл, а витрина показывает + * вчерашнее до следующего деплоя. Час — это и потолок отставания от ночного + * пересчёта, и, что важнее, потолок времени, на который залипает НЕУДАЧА: + * Data Cache Next'а статусы не разбирает, так что промах тоже живёт час, но + * не дольше. + * ------------------------------------------------------------------ */ + +const SSR_API_BASE = `${process.env.BACKEND_URL ?? "http://localhost:8000"}/api/public/mera`; + +/** Одна витринная величина. Зеркалит `LandingStat` бэкенда. */ +export interface LandingStat { + value: number | string | null; + sample_n: number | null; + note: string | null; + computed_at: string; +} + +/** + * Ответ `/stats`. Отсутствие ключа — это отсутствие ВЕЛИЧИНЫ: бэкенд не пишет + * строку, когда мерить нечего. Ноль на её месте был бы утверждением, которого + * никто не измерял, поэтому тип и допускает `undefined` — читать значения + * напрямую нельзя, только через `formatStat`. + */ +export type LandingStats = Readonly>; + +export interface ShowcaseDeal { + district: string | null; + rooms: number; + area_m2: number; + floor: number | null; + total_floors: number | null; + deal_quarter: string; + predicted_rub: number; + fact_rub: number; + err_pct: number; + n_analogs: number; + note: string; +} + +/** Подпись под витриной: из чего отобраны показанные строки. */ +export interface ShowcaseStats { + considered: number; + priced: number; + no_prediction: number; + incomplete: number; + eligible: number; + written: number; + with_district: number; + rejection_rule: string; +} + +export interface ShowcaseResponse { + computed_at: string | null; + deals: ShowcaseDeal[]; + stats: ShowcaseStats | null; +} + +/** + * GET, который НЕ бросает. Любой сбой — недоступный сервис, 500, не-JSON — + * это `null`. Альтернатива (проброс исключения) уронила бы серверный рендер + * целиком: посетитель получил бы 500 вместо страницы без одного блока. + */ +async function fetchOrNull(path: string): Promise { + try { + const res = await fetch(`${SSR_API_BASE}${path}`, { + // Таймаут, а не бесконечное ожидание: висящий бэкенд иначе держит рендер + // страницы, и «блок не показался» превращается в «сайт не открывается». + signal: AbortSignal.timeout(5_000), + next: { revalidate: 3600 }, + }); + if (!res.ok) return null; + return (await res.json()) as T; + } catch { + return null; + } +} + +/** + * Витринные метрики. Недоступная ручка = пустой набор, а не набор нулей: + * дальше каждый ключ одинаково честно отвечает «величины нет». + */ +export async function fetchLandingStats(): Promise { + const data = await fetchOrNull("/stats"); + return data !== null && typeof data === "object" ? data : {}; +} + +/** + * Витрина сделок. `null` = показывать нечего (ручка молчит, пересчёт ещё не + * отработал, строк нет) — секция не рендерится. Пустой массив отдельным + * состоянием не является: «витрина из нуля строк» на странице не отличима от + * поломки, а `stats` без строк подписывать нечего. + */ +export async function fetchShowcase(): Promise { + const data = await fetchOrNull("/showcase"); + if (data === null || !Array.isArray(data.deals) || data.deals.length === 0) return null; + return data; +} + +/** + * Величина, готовая к выводу. Число и размер выборки едут ОДНИМ объектом + * намеренно: другого способа получить отформатированное число нет, поэтому + * потерять n по дороге в JSX нельзя — можно только осознанно не отрисовать + * поле, и это будет видно в разметке. + */ +export interface FormattedStat { + /** Значение с единицей, если её передали: «1 123», «48,1 %». */ + text: string; + /** Подпись выборки — «по 1 123 оценкам». null, если бэкенд не прислал n. */ + sample: string | null; + /** Что именно измерено — формулировкой бэкенда, не нашей. */ + note: string | null; + computedAt: string; +} + +const ru = (digits: number) => new Intl.NumberFormat("ru-RU", { maximumFractionDigits: digits }); + +/** + * Достаёт метрику по ключу. `null` — во ВСЕХ случаях, когда числа нет: ключа + * не прислали, `value` пустой, ручка не ответила. Ни ноля, ни прочерка, ни + * последнего известного значения — блок вызывающего просто не рендерится. + * + * @param unit единица, приклеиваемая к числу («%», «дн.») — через неразрывный + * пробел, чтобы не отрывалась переносом строки. + * @param sampleWord слово подписи выборки («оценкам», «объявлениям»); без него + * подпись всё равно будет, но безымянной — «n = 1 123». + */ +export function formatStat( + stats: LandingStats, + key: string, + { unit, digits = 2, sampleWord }: { unit?: string; digits?: number; sampleWord?: string } = {}, +): FormattedStat | null { + const stat = stats[key]; + if (!stat || stat.value === null || stat.value === undefined) return null; + + let text: string; + if (typeof stat.value === "number") { + if (!Number.isFinite(stat.value)) return null; + text = ru(digits).format(stat.value); + } else if (typeof stat.value === "string") { + if (stat.value.trim() === "") return null; + text = stat.value; + } else { + return null; + } + if (unit) text = `${text} ${unit}`; + + const n = stat.sample_n; + const sample = + typeof n === "number" && Number.isFinite(n) + ? sampleWord + ? `по ${ru(0).format(n)} ${sampleWord}` + : `n = ${ru(0).format(n)}` + : null; + + return { text, sample, note: stat.note ?? null, computedAt: stat.computed_at }; +} From 2c9b4ab54743d87539a1d9e642c680791331d487 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 19:59:58 +0500 Subject: [PATCH 2/4] =?UTF-8?q?feat(mera-public):=20=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B0=20=D0=BB=D1=8D=D0=BD=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=D0=B0=20v3=20=D0=BD=D0=B0=20=D0=B7=D0=B0=D0=BC=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20=D1=87=D0=B8=D1=81=D0=BB?= =?UTF-8?q?=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Литералы из дизайн-макета заменены величинами из /stats и /showcase, а то, чему нет входа в данных, снято вместе с блоком. Что заменено: - ошибка «1,8%» → 14,5% (n=327) с двумя оговорками рядом: сравнение идёт с объявлениями, активными на момент расчёта, а сделка прошлая; цена ДКП бывает занижена ради налога; - «83% в диапазон» → 88% (n=327) неразрывно с шириной коридора ±37% в подписи и с третьей плиткой: уверенность «низкая» у 325 из 327; - «±6 дн. точность по сроку» СНЯТА — deals.days_on_market пуст 0/108 623; на её месте медианная экспозиция активного объявления, без ± и без «точности»; - «42 700 проверок, из них 3 180 сверены» → estimates_total; «сверено» снято (контура сверки прогноза со сделкой клиента нет); - «−4,3% за месяц» → price_cut_median_pct_per_month (среди снижавших, своя выборка); «×2,4 дольше» СНЯТА (замер даёт ×1,04) → price_cut_share_pct; - «14 объявлений / 47 дн.» → analogs_median и listing_age_median_days, прямо помеченные как пример по рынку, а не расчёт по адресу посетителя; - 5 выдуманных строк сверок и 3 раунда игры → реальные сделки /showcase (район+комнаты+площадь+этаж, квартал вместо дня) с подписью прогона; - цена во всех местах — SERVICE_PRICE_RUB из content.ts. Отказ ручки стоит блока: пустой /stats снимает плитку, пустой /showcase — таблицу, ленту и игру. Ни нуля, ни прочерка, ни прошлого значения. Гейт #2904 переписан: он охранял «плейсхолдеры существуют» и покраснел бы по построению за их снятие. Новый охраняет происхождение чисел — сканирует компоненты на вписанные руками величины и проверяет заполненность записей landing-facts.ts. Фальсифицирован: вписанное в AccuracyV3 «1,8% по 42 700 проверкам» роняет гейт тремя формами сразу. Сканер прикрыт контролем на образцах — он же поймал уже существовавшую «150 ₽» в FreeResultV3. robots/noindex не тронуты. --- .../__tests__/landing-numbers-gate.test.ts | 151 ++++++++++ .../__tests__/landing-v3-render.test.tsx | 94 +++++++ .../marketing-placeholders-gate.test.ts | 125 --------- .../mera-public/_components/v3/AccuracyV3.tsx | 264 +++++++++++------- .../_components/v3/CostOfErrorV3.tsx | 71 ++++- .../_components/v3/DealsTickerV3.tsx | 43 +-- .../_components/v3/FreeCheckCard.tsx | 4 +- .../_components/v3/FreeResultV3.tsx | 17 +- .../_components/v3/GuessGameV3.tsx | 93 +++--- .../app/mera-public/_components/v3/HeroV3.tsx | 62 +++- .../mera-public/_components/v3/StepsV3.tsx | 17 +- .../mera-public/_components/v3/TwoPathsV3.tsx | 11 +- .../mera-public/_components/v3/deal-view.ts | 98 +++++++ .../mera-public/articles/articles-content.ts | 8 +- .../src/app/mera-public/landing-facts.ts | 94 +++++++ .../src/app/mera-public/marketing-v3.ts | 249 ----------------- .../frontend/src/app/mera-public/page.tsx | 61 +++- 17 files changed, 842 insertions(+), 620 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx delete mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/marketing-placeholders-gate.test.ts create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts create mode 100644 tradein-mvp/frontend/src/app/mera-public/landing-facts.ts delete mode 100644 tradein-mvp/frontend/src/app/mera-public/marketing-v3.ts 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..b8e81e6b --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts @@ -0,0 +1,151 @@ +/** + * Гейт #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 { readFileSync } from "node:fs"; +import { join } 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"); +} + +/** Компоненты, которые рисуют витринные величины. */ +const V3_SOURCES = [ + "page.tsx", + "_components/v3/HeroV3.tsx", + "_components/v3/AccuracyV3.tsx", + "_components/v3/CostOfErrorV3.tsx", + "_components/v3/TwoPathsV3.tsx", + "_components/v3/StepsV3.tsx", + "_components/v3/DealsTickerV3.tsx", + "_components/v3/GuessGameV3.tsx", + "_components/v3/FreeCheckCard.tsx", + "_components/v3/FreeResultV3.tsx", + "_components/v3/SourcesV3.tsx", + "_components/v3/WhatYouGetV3.tsx", + "_components/v3/deal-view.ts", +]; + +/** + * Формы, в которых величина попадает на экран. Ищутся ИМЕННО они, а не любые + * цифры: `useState(0)`, `slice(0, 3)` и `toFixed(2)` — это код, а «14,5 %», + * «42 700», «±6 дн.» и «×2,4» — утверждения о рынке. + */ +const MEASUREMENT_SHAPES: readonly { rx: RegExp; what: string }[] = [ + { rx: /\d[\s ]\d{3}/u, what: "число с разделителем тысяч (42 700)" }, + { rx: /\d,\d/u, what: "десятичная дробь через запятую (1,8)" }, + { + rx: /\d\s*(?:%|₽|дн\.|дней|мес\.|млн|тыс\.|м²|сделок|аналог|объявлен|оцен)/iu, + what: "число с единицей измерения (83%, 150 ₽, ±6 дн.)", + }, + { rx: /×\s*\d/u, what: "кратность (×2,4)" }, +]; + +/** + * Комментарии — не витрина: в них величины ЦИТИРУЮТСЯ, чтобы объяснить снятое. + * ` ` заодно превращается в пробел: «150 ₽» на экране — то же самое + * число с единицей, что и «150 ₽», и именно в таком виде цена однажды уже + * проехала мимо сканера. + */ +function stripComments(src: string): string { + return src + .replace(/\/\*[\s\S]*?\*\//g, " ") + .replace(/(^|[^:"'`])\/\/[^\n]*/g, "$1") + .replace(/ |\\u00a0/g, " "); +} + +/** Находит вписанные руками величины. Пусто — значит все числа пришли из данных. */ +function handwrittenValues(src: string): string[] { + const code = stripComments(src); + return MEASUREMENT_SHAPES.filter(({ 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 ₽
", + "", + ]) { + 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', + '
{formatStat(stats, "analogs_median", { digits: 1 })?.text}
', + '/* в макете стояло 1,8% и 42 700 — снято */\nconst x = 1;', + ]) { + expect(handwrittenValues(good), `сканер ругается на честный код: ${good}`).toEqual([]); + } + }); + + it("плейсхолдерные экспорты не вернулись под новым именем", () => { + for (const rel of V3_SOURCES) { + expect(stripComments(read(rel)), `${rel} снова тянет *_PLACEHOLDER`).not.toMatch(/_PLACEHOLDER\b/); + } + }); + + it("блок robots в layout.tsx на месте", () => { + expect(read("layout.tsx"), "layout.tsx не содержит блока robots — зонд сломан").toContain("robots"); + }); +}); 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..51c6559f --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx @@ -0,0 +1,94 @@ +/** + * Проверка «нет данных — нет блока» на самой разметке. + * + * Гейт `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 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 дней среди снижавших"), +}; + +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(); + }); +}); 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/_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" (а не ): * на мобильном брейке (см. landing-v3.module.css) она превращается в карточки @@ -26,31 +32,74 @@ */ import { - ACCURACY_PERIOD_LABEL, - ACCURACY_PLACEHOLDER, - PROOF_ROWS_PLACEHOLDER, -} from "../../marketing-v3"; + BACKTEST, + BACKTEST_CORRIDOR_LABEL, + BACKTEST_PERIOD_LABEL, +} from "../../landing-facts"; +import { formatStat, type LandingStats, type ShowcaseResponse } from "../../public-api"; import styles from "../../landing-v3.module.css"; -const KPI_TILES = [ - { - value: ACCURACY_PLACEHOLDER.priceError, - label: "средняя ошибка по цене", - note: ACCURACY_PLACEHOLDER.priceErrorNote, - }, - { - value: ACCURACY_PLACEHOLDER.daysError, - label: "точность по сроку продажи", - note: ACCURACY_PLACEHOLDER.daysErrorNote, - }, - { - value: ACCURACY_PLACEHOLDER.coverage, - label: "сделок попали в диапазон", - note: ACCURACY_PLACEHOLDER.coverageNote, - }, -] as const; +import { count, dealMeta, dealTitle, errPct, rub } from "./deal-view"; + +interface Tile { + readonly key: string; + readonly value: string; + readonly label: string; + readonly note: string; +} + +export function AccuracyV3({ + stats, + showcase, +}: { + stats: LandingStats; + showcase: ShowcaseResponse | null; +}) { + const estimates = formatStat(stats, "estimates_total", { digits: 0 }); + const period = formatStat(stats, "estimates_period_days", { unit: "дн.", digits: 0 }); + const age = formatStat(stats, "listing_age_median_days", { + unit: "дн.", + digits: 0, + sampleWord: "объявлениям", + }); + + const tiles: Tile[] = [ + { + key: "priceError", + value: BACKTEST.priceError.text, + label: "медианное расхождение прогноза с ценой ДКП", + note: `по ${BACKTEST.priceError.sampleN} сделкам · ${BACKTEST_PERIOD_LABEL}`, + }, + { + key: "coverage", + value: BACKTEST.coverage.text, + label: `сделок попали в коридор шириной ${BACKTEST_CORRIDOR_LABEL}`, + note: "попадание обеспечено шириной коридора, а не точностью точки", + }, + { + key: "confidence", + value: BACKTEST.confidenceLow.text, + label: "расчётов с пометкой «уверенность низкая»", + note: "оценки «уверенность высокая» в выборке не встретилось ни разу", + }, + ]; + if (age) { + tiles.push({ + key: "listingAge", + value: age.text, + label: "медианная экспозиция активного объявления", + note: [age.note, age.sample].filter(Boolean).join(" · "), + }); + } + + const caveats = [ + ...BACKTEST.priceError.caveats, + ...BACKTEST.coverage.caveats, + ]; + + const deals = showcase?.deals ?? []; + const showcaseStats = showcase?.stats ?? null; -export function AccuracyV3() { return (

- Мы сверяем каждый прогноз с реальной сделкой + Мы сверили прогноз с ценой сделки — вот что вышло

- {`Когда квартира продана, мы возвращаемся к своему прогнозу и считаем расхождение. Вот сводка за ${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}
  • ))} -
-

- Таблица иллюстрирует формат отчёта, это не список конкретных сделок. - «Выше средней цены» — разница со средней ценой сделки по похожим - квартирам в районе. -

+ + + {deals.length > 0 && ( + <> +
+
+ Объект + Квартал сделки + Мера сказала + Цена ДКП + + Расхождение + +
+ {deals.map((deal, index) => ( +
+
+ {dealTitle(deal)} + {dealMeta(deal)} +
+
+ {deal.deal_quarter} +
+
+ {rub(deal.predicted_rub)} +
+
+ {rub(deal.fact_rub)} +
+
+ {errPct(deal.err_pct)} +
+
+ ))} +
+

+ {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..1421ad1d 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,67 @@ /** - * 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), тот же источник, что у оферты. + * + * ЧЕГО ЗДЕСЬ БОЛЬШЕ НЕТ. Плитки «×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 +70,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() { ПРОГНОЗ → ФАКТ
    @@ -96,26 +98,14 @@ export function GuessGameV3() { @@ -99,7 +121,7 @@ export function HeroV3() { Бесплатно: сколько похожих квартир продаётся - рядом и как быстро они уходят + рядом и как давно они висят
    @@ -109,12 +131,22 @@ export function HeroV3() { За{" "} - {REPORT_PRICE}: + {`${SERVICE_PRICE_RUB} ₽`}: {" "} - ваша цена и срок продажи — и каждый такой прогноз мы потом - сверяем с фактом сделки + ваша цена, срок продажи и аналоги, на которых построен расчёт
    + {marketExample !== "" && ( +
    + + + Пример по рынку Екатеринбурга, а не расчёт по вашему адресу:{" "} + {marketExample} + +
    + )} {/* Покрытие подано неравномерно намеренно — см. COVERED_CITIES в diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/StepsV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/StepsV3.tsx index 3c4c7538..9bd6afa0 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/StepsV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/StepsV3.tsx @@ -14,7 +14,7 @@ */ import { SERVICE_PRICE_RUB } from "../../content"; -import { STEP1_DURATION_LABEL } from "../../marketing-v3"; +import { STEP1_FIELDS_LABEL } from "../../landing-facts"; import styles from "../../landing-v3.module.css"; interface StepV3 { @@ -40,14 +40,17 @@ const STEPS_V3: readonly StepV3[] = [ ] as const; /** - * Бейджи «ШАГ N · …» по одному на шаг. Единственные числа во второй половине - * бейджей берутся из marketing-v3.ts (`STEP1_DURATION_LABEL`) и content.ts - * (`SERVICE_PRICE_RUB`, тот же источник, что и у цены отчёта в остальном - * лэндинге) — в массиве STEPS_V3 выше их нет намеренно, чтобы не разъезжались - * два места с одним и тем же числом. + * Бейджи «ШАГ N · …» по одному на шаг. Числа во второй половине бейджей — + * `STEP1_FIELDS_LABEL` (landing-facts.ts) и `SERVICE_PRICE_RUB` (content.ts, + * тот же источник, что и у цены отчёта в остальном лэндинге); в массиве + * STEPS_V3 выше их нет намеренно, чтобы не разъезжались два места с одним и + * тем же числом. + * + * В первом бейдже стояло «30 СЕКУНД» — замера времени заполнения формы нет. + * Число полей, наоборот, видно в `FreeCheckCard.tsx` и проверяется глазами. */ const STEP_BADGES: readonly string[] = [ - STEP1_DURATION_LABEL.toUpperCase(), + STEP1_FIELDS_LABEL.toUpperCase(), "БЕСПЛАТНО", `${SERVICE_PRICE_RUB} ₽`, ]; diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/TwoPathsV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/TwoPathsV3.tsx index 8e483dd4..542ed9bc 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/TwoPathsV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/TwoPathsV3.tsx @@ -9,16 +9,17 @@ * партнёрской заявки и договора «продажи под ключ» в продукте НЕТ — до их * появления честный путь заявки один, живой канал. Текст пути 2 из макета * сохранён дословно: он аккуратно не обещает ничего сверх «узнайте условия». + * + * Цена — `SERVICE_PRICE_RUB` (content.ts): один источник с офертой и политикой + * возврата. Раньше она приезжала сюда из плейсхолдерной плитки «цена ошибки», + * и разъехаться с офертой могла молча. */ -import { SUPPORT_TELEGRAM_URL } from "../../content"; -import { COST_OF_ERROR_PLACEHOLDER } from "../../marketing-v3"; +import { SERVICE_PRICE_RUB, SUPPORT_TELEGRAM_URL } from "../../content"; import styles from "../../landing-v3.module.css"; import { PublicLink } from "../PublicLink"; -const REPORT_PRICE = COST_OF_ERROR_PLACEHOLDER[2].value; - export function TwoPathsV3() { return (
    ПУТЬ 1 · САМОСТОЯТЕЛЬНО
    -
    Отчёт за {REPORT_PRICE}
    +
    {`Отчёт за ${SERVICE_PRICE_RUB} ₽`}

    Цена, срок, аналоги, факторы. Дальше вы ведёте продажу сами — на руках все цифры, чтобы уверенно держать свою цену в переговорах. diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts new file mode 100644 index 00000000..33a868ae --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts @@ -0,0 +1,98 @@ +/** + * Форматирование строк витрины `/showcase` — общее для ленты, таблицы сверок и + * игры. Держится в одном месте, потому что «объект» в этих данных описывается + * ТОЛЬКО так: комнаты, площадь, этаж, район (может отсутствовать). Улицы и + * дома в данных нет вовсе — номер дома известен у 2.7% сделок, — и три + * компонента, каждый со своей склейкой, рано или поздно разъехались бы в том, + * что именно они выдают за адрес. + */ + +import type { ShowcaseDeal } from "../../public-api"; + +const RUB = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 }); +const PCT = new Intl.NumberFormat("ru-RU", { + maximumFractionDigits: 1, + signDisplay: "exceptZero", +}); +const AREA = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 1 }); + +export const rub = (value: number): string => `${RUB.format(value)} ₽`; + +/** Счётчик прогона витрины — тем же разделителем тысяч, что и суммы. */ +export const count = (value: number): string => RUB.format(value); + +/** Отклонение прогноза от факта — со знаком: плюс = МЕРА назвала дороже. */ +export const errPct = (value: number): string => `${PCT.format(value)} %`; + +/** «2-к, 52 м²» — всё, что про объект известно наверняка. */ +export const dealTitle = (deal: ShowcaseDeal): string => + `${deal.rooms}-к, ${AREA.format(deal.area_m2)} м²`; + +/** + * Район / этаж / число аналогов через точку. Отсутствующие части ВЫПАДАЮТ, а + * не заменяются прочерком или правдоподобной подстановкой: `district` и + * `floor` в ДКП-данных бывают пустыми. + */ +export function dealMeta(deal: ShowcaseDeal): string { + const parts: string[] = []; + if (deal.district) parts.push(deal.district); + if (deal.floor !== null) { + parts.push(deal.total_floors !== null ? `${deal.floor}/${deal.total_floors} этаж` : `${deal.floor} этаж`); + } + parts.push(`аналогов: ${deal.n_analogs}`); + return parts.join(" · "); +} + +/** Раунд игры «Угадай цену» — та же сделка витрины, подготовленная к слайдеру. */ +export interface GameRound { + readonly key: string; + /** «2-к, 52 м²». */ + readonly title: string; + readonly meta: string; + /** Район или прямое «район не указан» — подстановки нет. */ + readonly districtLabel: string; + readonly quarter: string; + /** Границы слайдера, млн ₽. */ + readonly min: number; + readonly max: number; + readonly step: number; + readonly start: number; + /** Прогноз МЕРЫ и факт ДКП, млн ₽. */ + readonly mera: number; + readonly fact: number; + /** Отклонение прогноза от факта ИМЕННО этой сделки. */ + readonly errPctText: string; +} + +const MLN = 1_000_000; +const STEP_MLN = 0.05; +/** Полуширина коридора слайдера вокруг сделки — чтобы ответ не лежал на краю. */ +const SPAN = 0.35; + +const toHalf = (v: number, up: boolean): number => (up ? Math.ceil(v * 2) / 2 : Math.floor(v * 2) / 2); + +/** + * Границы слайдера считаются от самой сделки — другого масштаба у раунда нет. + * Старт кладётся в СЕРЕДИНУ коридора, а не в прогноз МЕРЫ: иначе первое же + * положение ручки выдавало бы ответ, который игра предлагает угадать. + */ +export function toGameRound(deal: ShowcaseDeal, index: number): GameRound { + const mera = deal.predicted_rub / MLN; + const fact = deal.fact_rub / MLN; + const min = Math.max(toHalf(Math.min(mera, fact) * (1 - SPAN), false), STEP_MLN); + const max = toHalf(Math.max(mera, fact) * (1 + SPAN), true); + return { + key: `${deal.deal_quarter}-${index}`, + title: dealTitle(deal), + meta: dealMeta(deal), + districtLabel: deal.district ?? "район не указан", + quarter: deal.deal_quarter, + min, + max, + step: STEP_MLN, + start: Math.round((min + max) / 2 / STEP_MLN) * STEP_MLN, + mera, + fact, + errPctText: errPct(deal.err_pct), + }; +} diff --git a/tradein-mvp/frontend/src/app/mera-public/articles/articles-content.ts b/tradein-mvp/frontend/src/app/mera-public/articles/articles-content.ts index da7996db..e4941909 100644 --- a/tradein-mvp/frontend/src/app/mera-public/articles/articles-content.ts +++ b/tradein-mvp/frontend/src/app/mera-public/articles/articles-content.ts @@ -20,9 +20,11 @@ * * Поэтому §1 и §4 стоят на СВОЁМ замере (см. поля `source` — там основание и * его границы), кадастровое число снято, а поправки §3 честно помечены как - * рыночная практика, а не наш замер. Плейсхолдеров marketing-v3 в статье - * больше нет — но строка `articles/articles-content.ts` в `V3_SOURCES` гейта - * #2904 оставлена намеренно: вернётся плейсхолдер — гейт его увидит. + * рыночная практика, а не наш замер. Модуля `marketing-v3.ts` с числами из + * макета больше не существует вовсе (29.08.2026): величины лэндинга уехали в + * `/stats`, `/showcase` и `landing-facts.ts`. Гейт `landing-numbers-gate` этот + * файл намеренно НЕ сканирует — здесь числа живут в связном тексте, и у + * каждого блока стоит своё поле `source`/`footnote` с датой и выборкой. * * ЗАМЕР, НА КОТОРОМ СТОИТ ТЕКСТ. 6202 объявления квартир Домклик в * Екатеринбурге, срез 18.07.2026, парно «цена публикации → цена на дату diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts new file mode 100644 index 00000000..b718b2c1 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts @@ -0,0 +1,94 @@ +/** + * landing-facts — величины лэндинга, которые НЕ приходят из `/stats`, но + * измерены. Пришло на смену `marketing-v3.ts` (числа из дизайн-макета). + * + * ЧЕМ ЭТОТ ФАЙЛ ОТЛИЧАЕТСЯ ОТ ПРЕДЫДУЩЕГО. В `marketing-v3.ts` лежали + * продающие формулировки, и шапка честно признавала, что ни одна из них не + * подтверждена замером. Здесь наоборот: каждая запись — результат конкретного + * запроса к боевой базе, у неё есть размер выборки и оговорки, без которых + * число врёт. Записи без `source`/`sampleN` завести нельзя — тип не даст, а + * гейт `__tests__/landing-numbers-gate.test.ts` проверяет, что поля не пустые. + * + * ПОЧЕМУ ЭТО НЕ В `/stats`. Всё, что считается ночной задачей + * (`app/tasks/landing_stats.py`), фронт берёт через `fetchLandingStats()` — + * так число не может отстать от базы. Сверка прогноза с ценой ДКП такой + * задачи пока не имеет: это разовый замер владельца на проде, и до появления + * регулярного пересчёта честнее держать его здесь, вместе с датой замера, + * чем показывать «свежим» на витрине. + * + * ЧЕГО ЗДЕСЬ НЕТ. Точности прогноза по СРОКУ продажи: `deals.days_on_market` + * заполнен 0 раз из 108 623, сверять прогноз срока не с чем. На витрине её + * место занимает `listing_age_median_days` из `/stats` — экспозиция активного + * объявления, и она названа своим именем, без слова «точность». + * Счётчика «сверено с фактом сделки клиента» тоже нет: контура такой сверки в + * продукте не существует (`trade_in_leads` — 4 строки без полей исхода). + */ + +/** + * Замеренная величина витрины. + * + * `caveats` — не украшение: у обоих чисел бэктеста есть границы, без которых + * они читаются лучше, чем есть. Рендерятся рядом со значением (AccuracyV3), + * а не в комментарии. + */ +export interface MeasuredValue { + /** Значение с единицей, готовое к выводу. */ + readonly text: string; + /** Размер выборки, по которой значение посчитано. */ + readonly sampleN: number; + /** Откуда взято: запрос, база, дата замера. */ + readonly source: string; + /** Оговорки, без которых величина вводит в заблуждение. */ + readonly caveats: readonly string[]; +} + +/** Общий источник трёх величин ниже — один и тот же прогон сверки. */ +const BACKTEST_SOURCE = + "Ручная сверка на проде (poincare, 29.08.2026): прогноз МЕРЫ против цены ДКП " + + "Росреестра по Екатеринбургу, сделки с 06.2025"; + +/** + * Сверка «прогноз → цена ДКП». Три величины идут КОМПЛЕКТОМ и показываются + * вместе: попадание в коридор без ширины коридора и без уровня уверенности + * читается как точность, которой нет. + */ +export const BACKTEST: Readonly> = { + priceError: { + text: "14,5 %", + sampleN: 327, + source: BACKTEST_SOURCE, + caveats: [ + "Прогноз считается по объявлениям, активным НА МОМЕНТ РАСЧЁТА, а сделка — прошлая: " + + "это не point-in-time замер. Честный будет не раньше конца 2026 года по календарю данных.", + "Цена ДКП бывает занижена ради налога — она нижняя граница правды, а не сама правда.", + ], + }, + coverage: { + text: "88 %", + sampleN: 327, + source: BACKTEST_SOURCE, + caveats: [ + "Попадание достигнуто шириной коридора ±37 %, а не точностью точки: " + + "коридор такой ширины накрывает почти любую сделку.", + ], + }, + confidenceLow: { + text: "325 из 327", + sampleN: 327, + source: BACKTEST_SOURCE, + caveats: ["Оценки «уверенность высокая» в выборке не встретилось ни разу."], + }, +}; + +/** Ширина коридора — та же сверка; стоит в подписи к `coverage`, не отдельной плиткой. */ +export const BACKTEST_CORRIDOR_LABEL = "±37 %"; + +/** Окно сделок бэктеста. Окно РАСЧЁТОВ — другое, оно приходит из `/stats`. */ +export const BACKTEST_PERIOD_LABEL = "сделки с июня 2025 года"; + +/** + * Бейдж первого шага. Раньше здесь стояло «30 секунд» — замера времени + * заполнения формы нет и не было. Число полей формы, наоборот, проверяется + * глазами в `FreeCheckCard.tsx`: адрес, площадь, комнаты, этаж, состояние. + */ +export const STEP1_FIELDS_LABEL = "5 полей"; diff --git a/tradein-mvp/frontend/src/app/mera-public/marketing-v3.ts b/tradein-mvp/frontend/src/app/mera-public/marketing-v3.ts deleted file mode 100644 index 866c05ab..00000000 --- a/tradein-mvp/frontend/src/app/mera-public/marketing-v3.ts +++ /dev/null @@ -1,249 +0,0 @@ -/** - * marketing-v3 — ПЛЕЙСХОЛДЕРНЫЕ маркетинговые величины лэндинга (макет v3). - * - * ⚠️ ЧИТАТЬ ПЕРЕД ИСПОЛЬЗОВАНИЕМ. Ни одно число в этом файле не подтверждено - * замером продукта. Они взяты из дизайн-макета владельца (15.08.2026) как - * продающие формулировки и держатся ЗДЕСЬ, в одном месте, ровно для того, - * чтобы их можно было заменить одной правкой, когда появятся вычисляемые - * метрики. - * - * Что говорят наши собственные замеры на ту же тему (зафиксировано, чтобы - * расхождение не потерялось — issue «compliance: заменить маркетинговые - * метрики лэндинга на вычисляемые до снятия noindex»): - * - * | здесь | факт по замеру | - * |------------------------|-----------------------------------------------| - * | ошибка 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 }` в `layout.tsx` стоит и домен не открыт - * наружу, эти строки никому не показываются. ПЕРЕД снятием noindex каждая - * величина обязана быть либо заменена вычисляемой, либо снята со страницы — - * публикация недостоверных характеристик платной услуги подпадает под ст. 5 - * ФЗ «О рекламе» и ЗоЗПП. - * - * Правило для новых секций: не вписывай число в JSX руками — заведи константу - * здесь. Иначе замена «одной правкой» перестанет работать ровно тогда, когда - * понадобится. - */ - -/** Точность прогноза — блок «Точность» и бейдж героя. */ -export const ACCURACY_PLACEHOLDER = { - /** Средняя ошибка по цене. */ - priceError: "1,8%", - priceErrorNote: "медиана расхождения прогноза и цены сделки", - /** Точность по сроку продажи. */ - daysError: "±6 дн.", - daysErrorNote: "отклонение факта от прогнозного окна", - /** Доля сделок внутри рекомендованного коридора. */ - coverage: "83%", - coverageNote: "цена сделки внутри рекомендованного коридора", - /** Объём проверок за год. */ - checks: "42 700", - checksNote: "из них 3 180 доведены до сделки и сверены", -} as const; - -/** Блок «Почему это важно» — цена ошибки в цене. */ -export const COST_OF_ERROR_PLACEHOLDER = [ - { - value: "−4,3%", - title: "теряет цена за каждый месяц простоя", - text: "Покупатели видят «зависшее» объявление и начинают торговаться сильнее.", - }, - { - value: "×2,4", - title: "дольше продаётся квартира с завышенной ценой", - text: "Даже +7% к рыночной цене выбивают вас из выдачи по фильтрам покупателей.", - }, - { - value: "150 ₽", - title: "стоит узнать правильную цену заранее", - text: "Один платёж. Без подписки. Отчёт остаётся у вас по ссылке.", - }, -] as const; - -/** - * Пример бесплатной части в макете. Это ИЛЛЮСТРАЦИЯ на статичной странице, а - * не расчёт по введённому адресу: анонимного расчёта сегодня нет вовсе - * (`PUBLIC_ESTIMATE_ENABLED = false`, ручки закрыты RBAC). - * - * Как только появится `POST /trade-in/coverage`, эти значения обязаны уехать — - * показывать одинаковые «14 объявлений» любому, кто ввёл свой адрес, это уже - * не продающая формулировка, а обман конкретного человека. - */ -export const FREE_SAMPLE_PLACEHOLDER = { - analogs: "14", - analogsLabel: "похожих объявлений рядом", - days: "47 дн.", - daysLabel: "средний срок продажи", -} as const; - -/** Отчётный период сводки в блоке «Точность» (лид над KPI-плитками, id="accuracy"). */ -export const ACCURACY_PERIOD_LABEL = "последние 12 месяцев"; - -/** Заявленное время на шаг 1 («описываете квартиру») — бейдж в блоке «Как это работает». */ -export const STEP1_DURATION_LABEL = "30 секунд"; - -/** - * Строки таблицы сверок в блоке «Точность» (id="accuracy"). Это ИЛЛЮСТРАЦИЯ - * формата отчёта из дизайн-макета (адреса, суммы и расхождения придуманы для - * примера), а не реальные сделки — макет не содержал заполненных данных для - * этой таблицы (только шаблон `{{ r.addr }}` и т.д.), реальных проверок под - * ней нет. `checks`/`err`/`gain` здесь НЕ обязаны биться с - * `ACCURACY_PLACEHOLDER` арифметически — это витринные примеры, а не выборка, - * из которой посчитана средняя ошибка. - * - * Как и весь файл: до снятия noindex каждая строка обязана быть либо заменена - * реальной сверенной сделкой, либо явно помечена как иллюстрация в самой - * вёрстке (см. подпись под таблицей в AccuracyV3.tsx). - */ -export interface ProofRow { - /** Адрес/район объекта. */ - readonly addr: string; - /** Тип, площадь, этаж. */ - readonly meta: string; - /** Прогноз «Меры». */ - readonly said: string; - /** Фактическая цена сделки. */ - readonly fact: string; - /** Расхождение прогноза и факта, %. */ - readonly err: string; - /** Превышение над средней ценой по району, ₽. */ - readonly gain: string; - /** То же превышение, %. */ - readonly gainPct: string; -} - -export const PROOF_ROWS_PLACEHOLDER: readonly ProofRow[] = [ - { - addr: "Втузгородок, ул. Ясная, 8", - meta: "1-к, 34 м², 6/16 этаж", - said: "3 640 000 ₽", - fact: "3 705 000 ₽", - err: "1,8%", - gain: "+165 000 ₽", - gainPct: "+4,7%", - }, - { - addr: "Юго-Западный, ул. Юмашева, 15", - meta: "2-к, 52 м², 3/9 этаж", - said: "5 210 000 ₽", - fact: "5 180 000 ₽", - err: "0,6%", - gain: "+96 000 ₽", - gainPct: "+1,9%", - }, - { - addr: "Ботанический, ул. Академика Бардина, 23", - meta: "3-к, 71 м², 8/16 этаж", - said: "7 480 000 ₽", - fact: "7 640 000 ₽", - err: "2,1%", - gain: "+310 000 ₽", - gainPct: "+4,2%", - }, - { - addr: "Уралмаш, ул. Кировградская, 42", - meta: "2-к, 46 м², 5/5 этаж", - said: "4 120 000 ₽", - fact: "4 065 000 ₽", - err: "1,3%", - gain: "+58 000 ₽", - gainPct: "+1,4%", - }, - { - addr: "Центр, ул. Малышева, 51", - meta: "1-к, 38 м², 11/25 этаж", - said: "6 350 000 ₽", - fact: "6 490 000 ₽", - err: "2,2%", - gain: "+215 000 ₽", - gainPct: "+3,4%", - }, -] as const; - -// --------------------------------------------------------------------------- -// Дополнение по макету 29.08 («B2C модуль для МЕРА», Макс) — секции «лента -// сделок», «игра "Угадай цену"», «возражения», «статьи». Тот же контракт, что -// у величин выше: ничего из этого НЕ подтверждено замером, контура сверки -// «прогноз → факт» в продукте не существует (см. шапку модуля и гейт-тест) — -// поэтому данные живут здесь, под noindex, до появления вычисляемых. -// --------------------------------------------------------------------------- - -/** Раунд мини-игры «Угадай цену»: реквизит из макета, НЕ реальная сделка. */ -export interface GameRound { - readonly district: string; - readonly addr: string; - readonly meta: string; - readonly tags: readonly string[]; - readonly question: string; - readonly hint: string; - /** Слайдер: млн ₽. */ - readonly min: number; - readonly max: number; - readonly step: number; - readonly start: number; - /** «МЕРА сказала» и «факт сделки», млн ₽ — числа из макета. */ - readonly mera: number; - readonly fact: number; - /** Позиция точки на декоративной карте, в процентах. */ - readonly dotX: number; - readonly dotY: number; -} - -export const GAME_ROUNDS_PLACEHOLDER: readonly GameRound[] = [ - { - district: "ЦЕНТРАЛЬНЫЙ Р-Н", - addr: "1-комнатная, 34 м²", - meta: "Центральный р-н · 4/9 этаж · панель", - tags: ["ХОРОШЕЕ СОСТОЯНИЕ", "9 АНАЛОГОВ", "БЛИЗКО К МЕТРО"], - question: "Какую цену поставить, чтобы продать за 30 дней?", - hint: "Продано в марте 2026. Ваш ответ — в миллионах рублей.", - min: 4.5, - max: 7.5, - step: 0.05, - start: 5.4, - mera: 5.55, - fact: 5.5, - dotX: 32, - dotY: 42, - }, - { - district: "ЮГО-ЗАПАД", - addr: "2-комнатная, 52 м²", - meta: "Юго-Западный р-н · 7/16 этаж · монолит", - tags: ["КОСМЕТИКА", "14 АНАЛОГОВ", "ДВОР БЕЗ МАШИН"], - question: "За сколько её реально купили?", - hint: "Продано в мае 2026. Ваш ответ — в миллионах рублей.", - min: 5.5, - max: 9.5, - step: 0.05, - start: 7.2, - mera: 7.4, - fact: 7.55, - dotX: 58, - dotY: 61, - }, - { - district: "УРАЛМАШ", - addr: "3-комнатная, 74 м²", - meta: "Орджоникидзевский р-н · 2/5 этаж · кирпич", - tags: ["ТРЕБУЕТ РЕМОНТА", "6 АНАЛОГОВ", "ТИХИЙ ДВОР"], - question: "Какая цена ушла бы за 45 дней?", - hint: "Продано в июне 2026. Ваш ответ — в миллионах рублей.", - min: 4.0, - max: 8.0, - step: 0.05, - start: 6.0, - mera: 5.85, - fact: 5.95, - dotX: 44, - dotY: 28, - }, -]; - -/** Ошибка МЕРЫ в финале игры — та же величина, что в бейдже героя. */ -export const GAME_MERA_ERROR_LABEL = "1,8%"; diff --git a/tradein-mvp/frontend/src/app/mera-public/page.tsx b/tradein-mvp/frontend/src/app/mera-public/page.tsx index 12d0f44b..ace48006 100644 --- a/tradein-mvp/frontend/src/app/mera-public/page.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/page.tsx @@ -12,6 +12,8 @@ import { StepsV3 } from "./_components/v3/StepsV3"; import { StickyCtaV3 } from "./_components/v3/StickyCtaV3"; import { TwoPathsV3 } from "./_components/v3/TwoPathsV3"; import { WhatYouGetV3 } from "./_components/v3/WhatYouGetV3"; +import { toGameRound } from "./_components/v3/deal-view"; +import { fetchLandingStats, fetchShowcase } from "./public-api"; import styles from "./landing.module.css"; /** @@ -30,36 +32,69 @@ import styles from "./landing.module.css"; * статьи. Доказательная часть (точность, источники, состав результата) идёт * подряд и целиком ДО первого разговора про деньги. * + * ОТКУДА ЧИСЛА (29.08.2026). Страница серверно-асинхронная: витринные величины + * приходят из `/api/public/mera/stats` и `/showcase`, а не из литералов — + * плейсхолдерного `marketing-v3.ts` больше нет. Обе ручки НЕ бросают: + * недоступный бэкенд даёт пустой набор и `null`, и тогда блок, которому нечего + * показать, просто не рендерится — ни нуля, ни прочерка, ни прошлого значения. + * Единственные числа, живущие в коде, — разовая сверка прогноза с ценой ДКП + * (`landing-facts.ts`), у неё там записаны выборка, источник и оговорки. + * + * ЛЕНТА И ИГРА СТОЯТ НА ТЕХ ЖЕ СДЕЛКАХ, что и таблица сверок: это один набор + * `/showcase`, а не три. Игра не рендерится, пока сделок меньше `MIN_ROUNDS` — + * «сыграйте три раунда» на одной строке было бы обещанием, которого данные не + * покрывают. + * + * СЕКЦИИ БЕЗ ДАННЫХ (`SourcesV3`, `WhatYouGetV3`) РЕНДЕРЯТСЯ ВСЕГДА: их + * содержимое — реестр источников и состав результата из `content.ts`, ручек + * они не спрашивают. Гасить их вместе с витринными числами значило бы прятать + * дисклеймер 135-ФЗ ровно тогда, когда бэкенд лежит. + * * МЕТАДАННЫЕ ЖИВУТ В `layout.tsx`, а не здесь. Заголовок и `robots: { index: * false }` там уже ровно те же, что стояли в `v3/page.tsx`; второй экземпляр * на уровне страницы ничего бы не менял, зато мог бы разъехаться с первым. - * NOINDEX НЕ СНИМАТЬ вместе с этим переключением: он держится за - * плейсхолдерные числа в `marketing-v3.ts` (гейт #2904), а не за то, какой - * вариант дизайна стоит в корне. + * NOINDEX НЕ СНИМАТЬ вместе с переездом витрины на замеренные числа: маршрут + * наружу открывается отдельным решением владельца (гейт + * `landing-numbers-gate.test.ts` держит флаг в layout), а не побочным + * эффектом того, что плейсхолдеры кончились. * * ЧЕГО ЗДЕСЬ ПО-ПРЕЖНЕМУ НЕТ: * - модалка проверки — сознательно заменена экраном /estimate (см. * StickyCtaV3), все CTA макетного `openModal` ведут туда; * - партнёрская заявка «продажи под ключ» — кнопка ведёт в Telegram * поддержки до появления формы и договора. - * - * ЧИСЛА в секциях «Точность» и «Цена ошибки» — плейсхолдеры из макета, - * см. `marketing-v3.ts`: они обязаны быть заменены вычисляемыми до снятия - * `robots: noindex`. */ -export default function MeraPublicPage() { + +/** + * Окно перегенерации страницы. Нужно отдельно от кэша самих запросов + * (`revalidate: 3600` в public-api.ts): сборка идёт в докере, где бэкенда нет, + * поэтому в билд страница уезжает БЕЗ витринных блоков. С часовым окном она + * такой и стояла бы час после каждого деплоя. Минута — это задержка появления + * чисел после выката; нагрузки на бэкенд она не добавляет, перегенерация + * переиспользует тот же часовой кэш ответов. + */ +export const revalidate = 60; + +/** Раундов в игре. Меньше — секция не показывается вовсе. */ +const MIN_ROUNDS = 3; + +export default async function MeraPublicPage() { + const [stats, showcase] = await Promise.all([fetchLandingStats(), fetchShowcase()]); + const deals = showcase?.deals ?? []; + const rounds = deals.slice(0, MIN_ROUNDS).map(toGameRound); + return ( <>

    - - + {deals.length > 0 && } + - + - - + {rounds.length >= MIN_ROUNDS && } + From b17a2a310c0bbc55363a2639e51b71e7381cd49e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 20:17:11 +0500 Subject: [PATCH 3/4] =?UTF-8?q?fix(mera/b2c):=20=D0=B3=D0=B5=D0=B9=D1=82?= =?UTF-8?q?=20=D0=B2=D0=B8=D1=82=D1=80=D0=B8=D0=BD=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D1=87=D0=B8=D1=81=D0=B5=D0=BB=20=D0=BB=D0=BE=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B8=D1=81=D1=85=D0=BE=D0=B6=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5,=20=D0=B0=20=D0=BD=D0=B5=20=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D1=83=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревьюер нашёл три дыры и воспроизвёл каждую на живом коде. 1. Сканер проверял ФОРМУ. Четыре маркетинговых утверждения, вписанные в DealsTickerV3, оставляли гейт зелёным 17/17: «9 из 10» (доля словами), «42700 квартир» (разделитель тысяч никакой + единица вне списка), «в 2 раза точнее» (кратность словами), «42 городах». Последнее — строка из таблицы расхождений СТАРОГО гейта, то есть новый пропускал ровно тот класс вранья, ради которого заводился. Добавлены четыре формы; эти же четыре строки внесены в контроль на инструмент — прежние шесть образцов повторяли то, что сканер и так знал, и покрытия не расширяли. 2. noindex перестал охраняться. `toContain("robots")` остаётся зелёным при `index: true` — проверено переворотом флага. Вернулась проверка значения (не слабее прежней `noindexIsOn()`), и не только в layout, но и в метаданных самой страницы, которые layout перекрывают. 3. Список файлов отставал от страницы: корень рендерил 15 компонентов, сканировались 11 — «возражения», шапка, подвал и sticky-CTA были вне охраны. Список больше не пишется руками: он выводится из каталога компонентов, а отдельная проверка требует, чтобы всё, что импортирует корень, попало в этот список (ловит компонент, положенный мимо каталога). Гейт на этой ветке КРАСНЫЙ и красный по делу: расширенный список нашёл «Проверить за 30 секунд» в HeaderV3, StickyCtaV3, ObjectionsV3 и FreeCheckCard. Замера времени заполнения формы нет и не было — это записано в landing-facts.ts и в StepsV3, откуда ту же фразу уже сняли. Правка текста — вне участка этой задачи (файлы вёрстки правит соседняя ветка). --- .../__tests__/landing-numbers-gate.test.ts | 131 +++++++++++++++--- 1 file changed, 111 insertions(+), 20 deletions(-) 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 index b8e81e6b..6e34f1ab 100644 --- 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 @@ -29,8 +29,8 @@ * их регуляркой значило бы требовать переписать статью в константы; правило * (2) там не поможет, а правило (1) дало бы шум вместо охраны. */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; import { describe, expect, it } from "vitest"; @@ -42,36 +42,62 @@ 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 = [ - "page.tsx", - "_components/v3/HeroV3.tsx", - "_components/v3/AccuracyV3.tsx", - "_components/v3/CostOfErrorV3.tsx", - "_components/v3/TwoPathsV3.tsx", - "_components/v3/StepsV3.tsx", - "_components/v3/DealsTickerV3.tsx", - "_components/v3/GuessGameV3.tsx", - "_components/v3/FreeCheckCard.tsx", - "_components/v3/FreeResultV3.tsx", - "_components/v3/SourcesV3.tsx", - "_components/v3/WhatYouGetV3.tsx", - "_components/v3/deal-view.ts", + 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: /(? { "
    ×2,4 дольше продаётся
    ", "
    150 ₽
    ", "", + // Четыре строки ревьюера. Он вписал их в DealsTickerV3, и гейт остался + // зелёным 17/17: каждая проходила МИМО прежних шести форм. Они здесь не + // ради полноты — без них правило «сканер проверен на образцах» повторяло + // то, что сканер и так знает, то есть покрытия не расширяло. + "
    Точность 9 из 10 прогнозов
    ", + "
    Проверено более 42700 квартир по всей России
    ", + "
    МЕРА в 2 раза точнее риелтора
    ", + "
    Работаем в 42 городах
    ", ]) { expect(handwrittenValues(bad), `сканер не увидел вписанное число: ${bad}`).not.toEqual([]); } @@ -134,18 +168,75 @@ describe("гейт витринных чисел (#2904)", () => { 'const STEP_MLN = 0.05;\nMath.ceil(v * 2) / 2', '
    {formatStat(stats, "analogs_median", { digits: 1 })?.text}
    ', '/* в макете стояло 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/); } }); - it("блок robots в layout.tsx на месте", () => { - expect(read("layout.tsx"), "layout.tsx не содержит блока robots — зонд сломан").toContain("robots"); + /** + * НАЛИЧИЯ СЛОВА `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); }); }); From 2e82b0810f6b1de3749dca4f492dc89ad7574f04 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 20:28:10 +0500 Subject: [PATCH 4/4] =?UTF-8?q?fix(mera/b2c):=20=D1=81=D0=BD=D1=8F=D1=82?= =?UTF-8?q?=D1=8C=20=D1=81=20=D0=B2=D0=B8=D1=82=D1=80=D0=B8=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=BE=D0=B1=D0=B5=D1=89=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B2=D1=80?= =?UTF-8?q?=D0=B5=D0=BC=D0=B5=D0=BD=D0=B8=20=D0=B8=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B0=D1=82=D1=8C=20=D0=B2=D0=B5=D0=BB=D0=B8?= =?UTF-8?q?=D1=87=D0=B8=D0=BD=D1=8B=20=D0=B8=D0=B7=D0=BC=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit «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 дн»). --- .../__tests__/landing-v3-render.test.tsx | 41 ++++++++++++++- .../mera-public/_components/InnerHeader.tsx | 2 +- .../_components/estimate/ReportResult.tsx | 37 +++++++++---- .../_components/v3/CostOfErrorV3.tsx | 9 +++- .../_components/v3/FreeCheckCard.tsx | 10 +++- .../_components/v3/FreeResultV3.tsx | 11 ++-- .../mera-public/_components/v3/HeaderV3.tsx | 2 +- .../app/mera-public/_components/v3/HeroV3.tsx | 52 ++++++++++++++----- .../_components/v3/ObjectionsV3.tsx | 2 +- .../mera-public/_components/v3/StepsV3.tsx | 6 +-- .../_components/v3/StickyCtaV3.tsx | 12 ++++- .../mera-public/_components/v3/TwoPathsV3.tsx | 9 +++- .../mera-public/articles/articles-content.ts | 4 +- .../articles/kak-ocenit-kvartiru/page.tsx | 2 +- .../src/app/mera-public/articles/page.tsx | 2 +- .../src/app/mera-public/landing-facts.ts | 21 ++++++-- 16 files changed, 173 insertions(+), 49 deletions(-) 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 index 51c6559f..ed59741d 100644 --- 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 @@ -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(); - expect(screen.queryByText(/столько продавцов снижают цену/)).toBeNull(); + 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/_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/CostOfErrorV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx index 1421ad1d..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 @@ -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(" · "), }); } 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 c97ad1ac..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 мс → готовый * результат прямо в карточке» не воспроизводится и не будет: результату нужно * место рядом с полями, а автокомплиту — место под список; в карточке первого @@ -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/HeroV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/HeroV3.tsx index 3e5ad3a7..42435255 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/HeroV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/HeroV3.tsx @@ -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 (
    - Не зависнуть: узнаете, за сколько дней уйдёт - квартира по вашей цене + Не зависнуть: увидите, сколько времени уже + висят похожие квартиры рядом
    @@ -133,20 +157,20 @@ export function HeroV3({ stats }: { stats: LandingStats }) { За{" "} {`${SERVICE_PRICE_RUB} ₽`}: {" "} - ваша цена, срок продажи и аналоги, на которых построен расчёт + ваша цена, экспозиция похожих объявлений и аналоги, на которых + построен расчёт
    - {marketExample !== "" && ( -
    + {marketLines.map((line) => ( +
    - Пример по рынку Екатеринбурга, а не расчёт по вашему адресу:{" "} - {marketExample} + {line.label} {line.text}
    - )} + ))}
    {/* Покрытие подано неравномерно намеренно — см. COVERED_CITIES в diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/ObjectionsV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/ObjectionsV3.tsx index 9b1d736b..e5cc5b1e 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/ObjectionsV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/ObjectionsV3.tsx @@ -135,7 +135,7 @@ export function ObjectionsV3() { Что обычно спрашивают перед оплатой - Проверить за 30 секунд + Проверить квартиру
    diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/StepsV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/StepsV3.tsx index 9bd6afa0..a5cd6e92 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/StepsV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/StepsV3.tsx @@ -30,11 +30,11 @@ const STEPS_V3: readonly StepV3[] = [ }, { title: "Мы собираем рынок вокруг", - text: "Похожие квартиры рядом: сколько их продаётся сейчас и за сколько дней они уходят.", + text: "Похожие квартиры рядом: сколько их продаётся сейчас и как давно они висят.", }, { - title: "Вы получаете цену и срок", - text: "Конкретная цифра для объявления, прогноз срока продажи и все аналоги, на которых построен расчёт.", + title: "Вы получаете цену и картину рынка", + text: "Конкретная цифра для объявления, сколько уже висят похожие объявления и все аналоги, на которых построен расчёт.", accent: true, }, ] as const; diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/StickyCtaV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/StickyCtaV3.tsx index eb957cf4..0b95e7c4 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/StickyCtaV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/StickyCtaV3.tsx @@ -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() { diff --git a/tradein-mvp/frontend/src/app/mera-public/articles/page.tsx b/tradein-mvp/frontend/src/app/mera-public/articles/page.tsx index 646bdcc1..cee546f8 100644 --- a/tradein-mvp/frontend/src/app/mera-public/articles/page.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/articles/page.tsx @@ -88,7 +88,7 @@ export default function ArticlesPage() {

    - Проверить за 30 секунд + Проверить квартиру diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts index b718b2c1..a73b1bb2 100644 --- a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts +++ b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts @@ -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 полей";