From 49d75131ff181e07292ce3cd3eb5ba62d2cd9914 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 19:59:58 +0500 Subject: [PATCH] =?UTF-8?q?feat(mera-public):=20=D0=B2=D0=B8=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=20=D0=BB=D1=8D=D0=BD=D0=B4=D0=B8=D0=BD=D0=B3?= =?UTF-8?q?=D0=B0=20v3=20=D0=BD=D0=B0=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20=D1=87=D0=B8=D1=81=D0=BB=D0=B0?= =?UTF-8?q?=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 | 149 ++++++++++ .../__tests__/landing-v3-render.test.tsx | 94 +++++++ .../marketing-placeholders-gate.test.ts | 113 -------- .../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/v3/page.tsx | 59 ++-- 17 files changed, 832 insertions(+), 614 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..d5cde1e2 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts @@ -0,0 +1,149 @@ +/** + * Гейт #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 = [ + "v3/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/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 272b4390..00000000 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/marketing-placeholders-gate.test.ts +++ /dev/null @@ -1,113 +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, которые могут ссылаться на плейсхолдеры. */ -const V3_SOURCES = [ - "v3/page.tsx", - "_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() { @@ -81,7 +103,7 @@ export function HeroV3() { Бесплатно: сколько похожих квартир продаётся - рядом и как быстро они уходят + рядом и как давно они висят
    @@ -91,12 +113,22 @@ export function HeroV3() { За{" "} - {REPORT_PRICE}: + {`${SERVICE_PRICE_RUB} ₽`}: {" "} - ваша цена и срок продажи — и каждый такой прогноз мы потом - сверяем с фактом сделки + ваша цена, срок продажи и аналоги, на которых построен расчёт
    + {marketExample !== "" && ( +
    + + + Пример по рынку Екатеринбурга, а не расчёт по вашему адресу:{" "} + {marketExample} + +
    + )} 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/v3/page.tsx b/tradein-mvp/frontend/src/app/mera-public/v3/page.tsx index 5a50b038..655a33a9 100644 --- a/tradein-mvp/frontend/src/app/mera-public/v3/page.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/v3/page.tsx @@ -12,6 +12,8 @@ import { ObjectionsV3 } from "../_components/v3/ObjectionsV3"; import { StepsV3 } from "../_components/v3/StepsV3"; import { StickyCtaV3 } from "../_components/v3/StickyCtaV3"; import { TwoPathsV3 } from "../_components/v3/TwoPathsV3"; +import { toGameRound } from "../_components/v3/deal-view"; +import { fetchLandingStats, fetchShowcase } from "../public-api"; import styles from "../landing.module.css"; /** @@ -24,22 +26,24 @@ import styles from "../landing.module.css"; * на одном и том же контенте. Переключение — отдельным решением, когда * версия принята. * - * ОБНОВЛЕНИЕ 29.08 (макет «B2C модуль», Макс): добавлены лента - * «прогноз → факт», игра «Угадай цену», «Два пути», «Возражения» и тизер - * статей. Данные ленты/игры — реквизит из marketing-v3.ts под тем же гейтом - * #2904 (контура сверки прогноза с фактом в продукте по-прежнему нет). + * ОТКУДА ЧИСЛА (29.08.2026). Страница стала серверно-асинхронной: витринные + * величины приходят из `/api/public/mera/stats` и `/showcase`, а не из + * литералов. Обе ручки НЕ бросают: недоступный бэкенд даёт пустой набор и + * `null`, и тогда блок, которому нечего показать, просто не рендерится — + * ни нуля, ни прочерка, ни прошлого значения. Единственные числа, живущие в + * коде, — разовая сверка прогноза с ценой ДКП (`landing-facts.ts`), у неё там + * записаны выборка, источник и оговорки. + * + * ЛЕНТА И ИГРА СТОЯТ НА ТЕХ ЖЕ СДЕЛКАХ, что и таблица сверок: это один набор + * `/showcase`, а не три. Игра не рендерится, пока сделок меньше `MIN_ROUNDS` — + * «сыграйте три раунда» на одной строке было бы обещанием, которого данные не + * покрывают. * * ЧЕГО ЗДЕСЬ ПО-ПРЕЖНЕМУ НЕТ: - * - страницы статей («МЕРА статьи»/«МЕРА статья») — карточки тизера - * рендерятся без ссылок до появления раздела; * - модалка проверки — сознательно заменена экраном /estimate (см. * StickyCtaV3), все CTA макетного `openModal` ведут туда; * - партнёрская заявка «продажи под ключ» — кнопка ведёт в Telegram * поддержки до появления формы и договора. - * - * ЧИСЛА в секции «Точность» и «Цена ошибки» — плейсхолдеры из макета, - * см. `marketing-v3.ts`: они обязаны быть заменены вычисляемыми до снятия - * `robots: noindex`. */ export const metadata: Metadata = { @@ -47,20 +51,37 @@ export const metadata: Metadata = { robots: { index: false, follow: false }, }; -export default function MeraPublicV3Page() { +/** + * Окно перегенерации страницы. Нужно отдельно от кэша самих запросов + * (`revalidate: 3600` в public-api.ts): сборка идёт в докере, где бэкенда нет, + * поэтому в билд страница уезжает БЕЗ витринных блоков. С часовым окном она + * такой и стояла бы час после каждого деплоя. Минута — это задержка появления + * чисел после выката; нагрузки на бэкенд она не добавляет, перегенерация + * переиспользует тот же часовой кэш ответов. + */ +export const revalidate = 60; + +/** Раундов в игре. Меньше — секция не показывается вовсе. */ +const MIN_ROUNDS = 3; + +export default async function MeraPublicV3Page() { + 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 && } + + + +