feat(mera/b2c): витрина лэндинга переезжает с выдуманных чисел на замеренные #3243

Merged
bot-backend merged 4 commits from feat/b2c-landing-honest-copy into main 2026-08-29 18:25:20 +00:00
26 changed files with 1470 additions and 651 deletions

View file

@ -0,0 +1,242 @@
/**
* Гейт #2904 (переписан 29.08.2026): ни одна величина не выходит на витрину
* без источника.
*
* ЧТО ОХРАНЯЛ ПРЕДЫДУЩИЙ ГЕЙТ. Он следил за парой «плейсхолдеры на странице
* `noindex` в layout.tsx»: пока числа из дизайн-макета живы, страницу нельзя
* открыть наружу. Плейсхолдеров больше нет числа заменены замеренными
* (`/api/public/mera/stats`, `/showcase`) либо сняты вместе с блоком, и
* второй тест того гейта («ни один плейсхолдер не найден зонд сломан»)
* покраснел бы ПО ПОСТРОЕНИЮ ровно за честную работу. Правило пришлось
* сформулировать заново.
*
* ЧТО ОХРАНЯЕТ ЭТОТ. Не наличие плейсхолдеров, а происхождение чисел:
*
* 1. в компонентах лэндинга v3 нет вписанных руками величин число
* приходит либо из ручки (`formatStat`/`/showcase`), либо из
* `landing-facts.ts`, где рядом с ним лежат выборка и оговорки;
* 2. каждая запись `landing-facts.ts` заполнена: значение, размер выборки,
* источник, оговорки пустых полей быть не может;
* 3. сам сканер проверен на образцах: он обязан найти вписанное число и не
* обязан ругаться на честный код. Без этого пункта правило (1) стало бы
* зелёным по построению при первой же опечатке в регулярке;
* 4. блок `robots` в layout.tsx на месте. Снятие `noindex` решение
* владельца, но оно должно быть осознанным, а не побочным.
*
* ЧЕГО ГЕЙТ НЕ ДЕЛАЕТ. Он не сканирует `articles/articles-content.ts`: числа
* статьи это связный текст, у каждого блока которого стоит собственное поле
* `source`/`footnote` с датой и выборкой замера (см. шапку того файла). Ловить
* их регуляркой значило бы требовать переписать статью в константы; правило
* (2) там не поможет, а правило (1) дало бы шум вместо охраны.
*/
import { readdirSync, readFileSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { BACKTEST, type MeasuredValue } from "../landing-facts";
const ROOT = join(__dirname, "..");
function read(rel: string): string {
return readFileSync(join(ROOT, rel), "utf8");
}
/**
* Корень витрины и каталог её компонентов.
*
* КОРЕНЬ `page.tsx`, а не `v3/page.tsx`: 29.08.2026 превью стало корнем,
* каталог `v3/` удалён. Путь тут один и используется и для скана, и для
* разбора импортов ниже; разъехаться половинки не могут, а `read()` на
* несуществующем файле падает громко, а не молча пропускает проверку.
*/
const LANDING_ROOT = "page.tsx";
const V3_DIR = "_components/v3";
/**
* Что сканируем. Список НЕ пишется руками: он выводится из каталога
* компонентов витрины плюс сам корень страницы. Захардкоженный перечень уже
* отстал однажды страница рендерила пятнадцать компонентов, в списке стояло
* одиннадцать, и «возражения» (естественное место для «в 9 из 10 случаев»)
* оказались вне охраны. Каталог отстать не может: файл, положенный рядом с
* остальными, попадает под гейт тем же коммитом, которым появился.
*
* Компонент, положенный ВНЕ каталога, каталогом не ловится за это отвечает
* отдельная проверка «всё, что импортирует корень, попало в список».
*/
const V3_SOURCES = [
LANDING_ROOT,
...readdirSync(join(ROOT, V3_DIR))
.filter((name) => /\.tsx?$/u.test(name))
.sort()
.map((name) => `${V3_DIR}/${name}`),
];
/**
* Формы, в которых величина попадает на экран. Ищутся ИМЕННО они, а не любые
* цифры: `useState(0)`, `slice(0, 3)` и `toFixed(2)` это код, а «14,5 %»,
* «42 700», «±6 дн.» и «×2,4» утверждения о рынке.
*
* ЧЕГО СКАНЕР НЕ ЛОВИЛ ДО 29.08.2026. Ревьюер вписал в DealsTickerV3 четыре
* маркетинговых утверждения, и гейт остался зелёным: он проверял ФОРМУ записи,
* а формы были соседние. «42700» разделитель тысяч не пробелом, а никак;
* «квартир»/«городах» единица вне короткого списка; «в 2 раза» кратность
* словами вместо «×2»; «9 из 10» доля словами. Последнее особенно дорого:
* «42 города РФ» стояло отдельной строкой в таблице расхождений СТАРОГО гейта,
* то есть новый пропускал ровно тот класс вранья, ради которого заводился.
*/
const MEASUREMENT_SHAPES: readonly { rx: RegExp; what: string }[] = [
{ rx: /\d[\s ]\d{3}/u, what: "число с разделителем тысяч (42 700)" },
{ rx: /(?<!\d)\d{4,}(?!\d)/u, what: "число от четырёх знаков без разделителя (42700)" },
{ rx: /\d,\d/u, what: "десятичная дробь через запятую (1,8)" },
{
rx: /\d\s*(?:%||дн\.|дней|мес\.|млн|тыс\.|м²|сек|минут|сделок|аналог|объявлен|оцен|квартир|город|объект|дом[ао]|проверок|прогноз|район|клиент|человек|регион)/iu,
what: "число с единицей измерения (83%, 150 ₽, ±6 дн., 42 700 квартир, 42 города)",
},
{ rx: /×\s*\d/u, what: "кратность (×2,4)" },
// `\b` здесь не годится: границей слова JS считает край [A-Za-z0-9_], и
// перед кириллическим «в» она не срабатывает никогда. Якорим по цифре.
{ rx: /\d\s*раз/iu, what: "кратность словами (в 2 раза точнее)" },
{ rx: /\d\s+из\s+\d/iu, what: "доля словами (9 из 10)" },
];
/**
* Комментарии не витрина: в них величины ЦИТИРУЮТСЯ, чтобы объяснить снятое.
* `&nbsp;` заодно превращается в пробел: «150&nbsp;» на экране то же самое
* число с единицей, что и «150 », и именно в таком виде цена однажды уже
* проехала мимо сканера.
*/
function stripComments(src: string): string {
return src
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/(^|[^:"'`])\/\/[^\n]*/g, "$1")
.replace(/&nbsp;|\\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 [
"<div>СРЕДНЯЯ ОШИБКА — 1,8%</div>",
"<div>{`42 700 проверок`}</div>",
"const days = '±6 дн.';",
"<div>×2,4 дольше продаётся</div>",
"<div>150 ₽</div>",
"<button>Открыть за 150&nbsp;₽</button>",
// Четыре строки ревьюера. Он вписал их в DealsTickerV3, и гейт остался
// зелёным 17/17: каждая проходила МИМО прежних шести форм. Они здесь не
// ради полноты — без них правило «сканер проверен на образцах» повторяло
// то, что сканер и так знает, то есть покрытия не расширяло.
"<div>Точность 9 из 10 прогнозов</div>",
"<div>Проверено более 42700 квартир по всей России</div>",
"<div>МЕРА в 2 раза точнее риелтора</div>",
"<div>Работаем в 42 городах</div>",
]) {
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',
'<div id="hero-v3-title">{formatStat(stats, "analogs_median", { digits: 1 })?.text}</div>',
'/* в макете стояло 1,8% и 42 700 — снято */\nconst x = 1;',
// Живые строки из компонентов: счётчик берётся из длины массива, а не
// вписан. Расширяя список единиц, легко начать ругаться и на них.
'ДАННЫЕ ПО {OBLAST_CITIES.length} ГОРОДАМ: {names}',
'РАУНД {Math.min(idx + 1, rounds.length)} ИЗ {rounds.length}',
'`Показано ${count(s.written)} строк из ${count(s.eligible)} годных`',
]) {
expect(handwrittenValues(good), `сканер ругается на честный код: ${good}`).toEqual([]);
}
});
/**
* Список сканируемых файлов обязан покрывать то, что реально рендерит
* корень. Каталог `_components/v3` ловит соседей автоматически; эта проверка
* ловит компонент, положенный ВНЕ каталога и подключённый к странице,
* случай, при котором каталожный вывод отстал бы так же, как отставал
* захардкоженный список.
*/
it("каждый компонент, который импортирует корень страницы, попал в список сканируемых", () => {
// Корень лежит в самом `mera-public/`, поэтому его относительные импорты
// разрешаются от ROOT. Пока страница жила в `v3/`, здесь стоял `v3` —
// после переезда тот же путь дал бы `_components/` мимо каталога, и
// проверка молча перестала бы что-либо находить.
const pageDir = resolve(ROOT);
const rendered = [...read(LANDING_ROOT).matchAll(/from\s+"(\.[^"]+)"/gu)]
.map(([, spec]) => relative(ROOT, resolve(pageDir, spec)).split("\\").join("/"))
.filter((rel) => rel.startsWith("_components/"));
expect(rendered.length, "в корне не нашлось ни одного импорта — зонд сломан").toBeGreaterThan(0);
const missed = rendered.filter((rel) => !V3_SOURCES.some((src) => src === rel || src === `${rel}.tsx` || src === `${rel}.ts`));
expect(missed, [
`корень рендерит ${missed.join(", ")}, а гейт эти файлы не читает.`,
"Компоненты витрины живут в _components/v3 — оттуда список набирается сам.",
].join(" ")).toEqual([]);
});
it("плейсхолдерные экспорты не вернулись под новым именем", () => {
for (const rel of V3_SOURCES) {
expect(stripComments(read(rel)), `${rel} снова тянет *_PLACEHOLDER`).not.toMatch(/_PLACEHOLDER\b/);
}
});
/**
* НАЛИЧИЯ СЛОВА `robots` НЕДОСТАТОЧНО. Проверка на подстроку остаётся
* зелёной при `index: true` то есть ровно тогда, когда страница уходит в
* индекс. Поэтому сверяется значение.
*
* ПРОВЕРЯЕТСЯ ПАРА, А НЕ ОДИН ФАЙЛ. Пока витрина жила на `v3/page.tsx`, у
* неё был собственный `export const metadata` и флаг стоял в двух местах
* обоих и требовали. С переездом в корень второй экземпляр снят намеренно
* (см. шапку `page.tsx`): метаданные держит только layout. Требовать
* `noindex` в `page.tsx` теперь значило бы требовать вернуть дубль, а просто
* убрать проверку оставить дыру: страничные `metadata` в Next
* ПЕРЕКРЫВАЮТ layout, и `index: true` на уровне корня погасил бы флаг, не
* тронув файла, за которым тут следят. Отсюда две половины: в layout стоит
* noindex, а корень своих метаданных не заводит вовсе.
*/
const NOINDEX = /robots:\s*\{\s*index:\s*false\s*,\s*follow:\s*false\s*\}/u;
it("в layout.tsx стоит noindex, а не просто слово robots", () => {
expect(
read("layout.tsx"),
"layout.tsx: витрина открыта поисковикам — маршрут наружу ещё не открыт",
).toMatch(NOINDEX);
});
it("корень не заводит своих metadata — иначе noindex из layout перекрыт незаметно", () => {
expect(
stripComments(read(LANDING_ROOT)),
`${LANDING_ROOT}: появился export const metadata — он перекрывает layout, и noindex надо проверять здесь же`,
).not.toMatch(/export\s+const\s+metadata\b/u);
});
});

View file

@ -0,0 +1,133 @@
/**
* Проверка «нет данных нет блока» на самой разметке.
*
* Гейт `landing-numbers-gate` следит, что числа не вписаны руками; здесь
* проверяется вторая половина правила что при отсутствии величины блок
* ИСЧЕЗАЕТ, а не показывает ноль, прочерк или подпись без числа. Отказ ручки
* `/stats` в проде выглядит именно так: пустой объект.
*/
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { AccuracyV3 } from "../_components/v3/AccuracyV3";
import { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3";
import { HeroV3 } from "../_components/v3/HeroV3";
import type { LandingStat, ShowcaseResponse } from "../public-api";
const stat = (value: number, sample_n: number | null, note: string): LandingStat => ({
value,
sample_n,
note,
computed_at: "2026-08-29T03:00:00+00:00",
});
const STATS = {
estimates_total: stat(1123, 1123, "Расчётов сделано в системе"),
estimates_period_days: stat(62, 1123, "Дней между первым и последним расчётом"),
listing_age_median_days: stat(26, 25943, "Медианная ЭКСПОЗИЦИЯ активного объявления"),
price_cut_share_pct: stat(48.1, 6276, "Доля объявлений, снижавших цену"),
price_cut_median_pct_per_month: stat(-2.17, 3018, "Медианное изменение цены за 30 дней среди снижавших"),
};
/** Hero печатает ещё и медиану аналогов — величину БЕЗ фильтра по городу. */
const HERO_STATS = {
...STATS,
analogs_median: stat(
14,
1123,
"Медиана числа аналогов на расчёт (только расчёты, где аналоги нашлись)",
),
};
const SHOWCASE: ShowcaseResponse = {
computed_at: "2026-08-29T03:00:00+00:00",
deals: [
{
district: "Юго-Западный",
rooms: 2,
area_m2: 52,
floor: 3,
total_floors: 9,
deal_quarter: "II кв. 2026",
predicted_rub: 5210000,
fact_rub: 5180000,
err_pct: 0.58,
n_analogs: 12,
note: "Прогноз посчитан по активным объявлениям на дату пересчёта",
},
],
stats: {
considered: 4000,
priced: 900,
no_prediction: 3100,
incomplete: 12,
eligible: 888,
written: 20,
with_district: 17,
rejection_rule: "Строка не попадает на витрину, только если данных нет",
},
};
describe("витрина лэндинга v3 без данных", () => {
it("«Точность»: с данными показывает величины вместе с выборкой и коридором", () => {
render(<AccuracyV3 stats={STATS} showcase={SHOWCASE} />);
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(<AccuracyV3 stats={{}} showcase={null} />);
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(<CostOfErrorV3 stats={STATS} />);
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(<CostOfErrorV3 stats={{}} />);
expect(screen.queryByText(/столько объявлений на Домклике снижали цену/)).toBeNull();
expect(screen.getByText("150 ₽")).toBeTruthy();
});
/**
* Величина не должна стоять под подписью, к которой не привязана. Экспозиция
* считается с фильтром по городу (`_LISTING_AGE_SQL`), медиана аналогов по
* всей таблице `trade_in_estimates`; под общей шапкой «пример по рынку
* Екатеринбурга» вторая была бы подписана чужой границей. Проверяем не
* наличие двух строк, а СОДЕРЖИМОЕ каждой: слить их обратно в одну и
* оставить тест зелёным невозможно.
*/
it("HeroV3: аналоги подписаны своей границей и своей оговоркой, а не Екатеринбургом", () => {
render(<HeroV3 stats={HERO_STATS} />);
const ekbLine = screen.getByText(/Пример по рынку Екатеринбурга/).parentElement;
expect(ekbLine?.textContent).toMatch(/активное объявление висит\s+26\s+дн\./u);
expect(ekbLine?.textContent, "экспозиция и аналоги снова в одной строке").not.toMatch(/аналог/);
const allLine = screen.getByText(/По всем расчётам МЕРЫ/).parentElement;
expect(allLine?.textContent).toMatch(/медиана аналогов на расчёт/);
// Отбор выживших: расчёты без аналогов в знаменатель не попали.
expect(allLine?.textContent, "note бэкенда выброшен").toMatch(
/только расчёты, где аналоги нашлись/,
);
});
it("HeroV3: без /stats обе строки примера исчезают, а не показывают подпись без числа", () => {
render(<HeroV3 stats={{}} />);
expect(screen.queryByText(/Пример по рынку Екатеринбурга/)).toBeNull();
expect(screen.queryByText(/По всем расчётам МЕРЫ/)).toBeNull();
});
});

View file

@ -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");
});
});

View file

@ -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<unknown>) {
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<LandingStat>): 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();
});
});

View file

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

View file

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

View file

@ -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" (а не <table>):
* на мобильном брейке (см. 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 (
<section
id="accuracy"
@ -63,91 +112,90 @@ export function AccuracyV3() {
ПРОГНОЗ ПРОТИВ ФАКТА
</div>
<h2 id="accuracy-v3-title" className={styles.accTitle}>
Мы сверяем каждый прогноз с реальной сделкой
Мы сверили прогноз с ценой сделки вот что вышло
</h2>
<p className={styles.accLead}>
{`Когда квартира продана, мы возвращаемся к своему прогнозу и считаем расхождение. Вот сводка за ${ACCURACY_PERIOD_LABEL}.`}
{`Сверка прогноза с ценой ДКП Росреестра: ${BACKTEST_PERIOD_LABEL}, ${BACKTEST.priceError.sampleN} сделок.`}
{estimates
? ` Расчёты в системе живут в другом окне: ${estimates.text}${
period ? ` за ${period.text}` : ""
}.`
: ""}
</p>
</div>
<ul className={styles.accKpiGrid} role="list">
{KPI_TILES.map((tile) => (
<li key={tile.label} className={styles.accKpiTile}>
{tiles.map((tile) => (
<li key={tile.key} className={styles.accKpiTile}>
<div className={styles.accKpiValue}>{tile.value}</div>
<div className={styles.accKpiLabel}>{tile.label}</div>
<div className={styles.accKpiNote}>{tile.note}</div>
</li>
))}
<li className={`${styles.accKpiTile} ${styles.accKpiTileDark}`}>
<div className={styles.accKpiValue}>
{ACCURACY_PLACEHOLDER.checks}
</div>
<div className={styles.accKpiLabel}>проверок за год</div>
<div className={styles.accKpiNoteDark}>
{ACCURACY_PLACEHOLDER.checksNote}
</div>
</li>
{estimates && (
<li className={`${styles.accKpiTile} ${styles.accKpiTileDark}`}>
<div className={styles.accKpiValue}>{estimates.text}</div>
<div className={styles.accKpiLabel}>расчётов сделано</div>
<div className={styles.accKpiNoteDark}>
{[estimates.note, period ? `окно наблюдения — ${period.text}` : null]
.filter(Boolean)
.join(" · ")}
</div>
</li>
)}
</ul>
<div
className={styles.accTable}
role="table"
aria-label="Сверка прогноза «Меры» с фактом сделки"
>
<div className={styles.accTableHead} role="row">
<span role="columnheader">Объект</span>
<span role="columnheader">Мера сказала</span>
<span role="columnheader">Факт сделки</span>
<span role="columnheader">Расхождение</span>
<span role="columnheader" className={styles.accTableHeadCellRight}>
Выше средней цены по району
</span>
</div>
{PROOF_ROWS_PLACEHOLDER.map((row) => (
<div key={row.addr} className={styles.accRow} role="row">
<div role="cell">
<span className={styles.accObjectAddr}>{row.addr}</span>
<span className={styles.accObjectMeta}>{row.meta}</span>
</div>
<div
role="cell"
className={styles.accCellSaid}
data-label="Мера сказала"
>
{row.said}
</div>
<div
role="cell"
className={styles.accCellFact}
data-label="Факт сделки"
>
{row.fact}
</div>
<div
role="cell"
className={styles.accCellErr}
data-label="Расхождение"
>
{row.err}
</div>
<div
role="cell"
className={styles.accCellGain}
data-label="Выше средней цены по району"
>
<div className={styles.accGainStack}>
<div className={styles.accGainValue}>{row.gain}</div>
<div className={styles.accGainPct}>{row.gainPct}</div>
</div>
</div>
</div>
<ul className={styles.accFootnote} role="list">
{caveats.map((line) => (
<li key={line}>{line}</li>
))}
</div>
<p className={styles.accFootnote}>
Таблица иллюстрирует формат отчёта, это не список конкретных сделок.
«Выше средней цены» разница со средней ценой сделки по похожим
квартирам в районе.
</p>
</ul>
{deals.length > 0 && (
<>
<div
className={styles.accTable}
role="table"
aria-label="Сверка прогноза «Меры» с ценой сделки"
>
<div className={styles.accTableHead} role="row">
<span role="columnheader">Объект</span>
<span role="columnheader">Квартал сделки</span>
<span role="columnheader">Мера сказала</span>
<span role="columnheader">Цена ДКП</span>
<span role="columnheader" className={styles.accTableHeadCellRight}>
Расхождение
</span>
</div>
{deals.map((deal, index) => (
<div key={`${deal.deal_quarter}-${index}`} className={styles.accRow} role="row">
<div role="cell">
<span className={styles.accObjectAddr}>{dealTitle(deal)}</span>
<span className={styles.accObjectMeta}>{dealMeta(deal)}</span>
</div>
<div role="cell" data-label="Квартал сделки">
{deal.deal_quarter}
</div>
<div role="cell" className={styles.accCellSaid} data-label="Мера сказала">
{rub(deal.predicted_rub)}
</div>
<div role="cell" className={styles.accCellFact} data-label="Цена ДКП">
{rub(deal.fact_rub)}
</div>
<div role="cell" className={styles.accCellErr} data-label="Расхождение">
{errPct(deal.err_pct)}
</div>
</div>
))}
</div>
<p className={styles.accFootnote}>
{showcaseStats
? `Показано ${count(showcaseStats.written)} строк из ${count(showcaseStats.eligible)} годных, рассмотрено сделок: ${count(showcaseStats.considered)}. Район известен у ${count(showcaseStats.with_district)} из показанных. ${showcaseStats.rejection_rule}`
: "Подпись прогона не пришла — из чего отобраны строки, сказать нечем."}
</p>
<p className={styles.accFootnote}>{deals[0].note}</p>
</>
)}
</div>
</section>
);

View file

@ -1,18 +1,74 @@
/**
* CostOfErrorV3 «Цена ошибки»: три плитки про стоимость неверной цены
* CostOfErrorV3 «Цена ошибки»: плитки про стоимость неверной цены
* (макет v3, ~строки 376-400, без id). Серверный компонент.
*
* Значения и подписи из `COST_OF_ERROR_PLACEHOLDER` (marketing-v3.ts),
* заголовок цитирует `SERVICE_PRICE_RUB` (content.ts) тот же источник
* правды, что и у цены отчёта во всём остальном лэндинге, а не отдельный
* литерал «150».
* ОТКУДА ЧИСЛА. Обе рыночные величины `/stats`, и у них РАЗНЫЕ знаменатели:
* доля снижавших считается по всем наблюдаемым объявлениям, а медиана
* просадки только по тем, кто снижал. Поэтому размер выборки печатается у
* каждой плитки отдельно, а в заголовке плитки прямо сказано «среди тех, кто
* снижал»: одна подпись на две величины была бы подменой знаменателя.
* Цена `SERVICE_PRICE_RUB` (content.ts), тот же источник, что у оферты.
*
* ОБЪЯВЛЕНИЯ, А НЕ ПРОДАВЦЫ. `price_cut_share_pct` доля ОБЪЯВЛЕНИЙ, и
* только по Домклику (единственный источник, где триггер пишет стартовую
* цену, см. шапку `landing_stats.py`). Один продавец ведёт несколько
* объявлений, поэтому «столько продавцов снижают цену» приписывало величине
* единицу, которой её не мерили: подпись приведена к измеренному, а источник
* назван прямо в ней, а не только в `note` под плиткой.
*
* ЧЕГО ЗДЕСЬ БОЛЬШЕ НЕТ. Плитки «×2,4 дольше продаётся квартира с завышенной
* ценой»: замер даёт ×1,04 (79 дней против 76), и это уже с цензурой в пользу
* заявления. Её место заняла доля снижающих цену измеренная величина про то
* же самое: рынок не соглашается с завышенной ценой.
*/
import { SERVICE_PRICE_RUB } from "../../content";
import { COST_OF_ERROR_PLACEHOLDER } from "../../marketing-v3";
import { formatStat, type LandingStats } from "../../public-api";
import styles from "../../landing-v3.module.css";
export function CostOfErrorV3() {
interface CostTile {
readonly key: string;
readonly value: string;
readonly title: string;
readonly text: string;
}
export function CostOfErrorV3({ stats }: { stats: LandingStats }) {
const drop = formatStat(stats, "price_cut_median_pct_per_month", {
unit: "%",
digits: 2,
sampleWord: "объявлениям",
});
const share = formatStat(stats, "price_cut_share_pct", {
unit: "%",
digits: 1,
sampleWord: "объявлениям",
});
const tiles: CostTile[] = [];
if (drop) {
tiles.push({
key: "drop",
value: drop.text,
title: "медианное изменение цены за месяц — среди тех, кто снижал",
text: [drop.note, drop.sample].filter(Boolean).join(" · "),
});
}
if (share) {
tiles.push({
key: "share",
value: share.text,
title: "столько объявлений на Домклике снижали цену",
text: [share.note, share.sample].filter(Boolean).join(" · "),
});
}
tiles.push({
key: "price",
value: `${SERVICE_PRICE_RUB}`,
title: "стоит узнать правильную цену заранее",
text: "Один платёж. Без подписки. Отчёт остаётся у вас по ссылке.",
});
return (
<section aria-labelledby="cost-v3-title" className={styles.costSection}>
<div className={styles.costContainer}>
@ -21,16 +77,16 @@ export function CostOfErrorV3() {
ПОЧЕМУ ЭТО ВАЖНО
</div>
<h2 id="cost-v3-title" className={styles.costTitle}>
{`${SERVICE_PRICE_RUB} ₽ против двух месяцев вашей жизни`}
{`${SERVICE_PRICE_RUB} ₽ против двух месяцев вашей жизни`}
</h2>
</div>
<ul className={styles.costGrid} role="list">
{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 (
<li
key={tile.title}
key={tile.key}
className={
dark
? `${styles.costTile} ${styles.costTileDark}`

View file

@ -2,11 +2,17 @@
* DealsTickerV3 бегущая строка «ПРОГНОЗ ФАКТ» (макет 29.08, секция
* «V3 лента сделок»): тёмная полоса сразу под шапкой.
*
* Данные ТЕ ЖЕ строки, что в таблице сверок (`PROOF_ROWS_PLACEHOLDER`):
* лента и таблица иллюстрируют один и тот же будущий контур «прогноз факт»,
* которого в продукте пока нет заводить второй набор выдуманных сделок
* значило бы удвоить сущности под одним и тем же гейтом (#2904). Список
* дублируется дважды подряд стандартный приём бесшовного CSS-marquee
* Данные ТЕ ЖЕ строки `/showcase`, что в таблице сверок ниже: лента и
* таблица показывают один набор сделок, а не два. Пустая витрина = полосы нет
* (решает родитель, `v3/page.tsx`) лента из выдуманных строк была бы ровно
* тем, против чего продукт и продаётся.
*
* ЧЕГО В СТРОКЕ БОЛЬШЕ НЕТ: колонки « +165 000 выше средней цены по
* району». Средней цены сделки по району в данных нет, считать эту величину
* не из чего. Адреса тоже нет: улица и дом известны у 2.7% сделок, поэтому
* объект описан тем, что есть комнаты, площадь, район, этаж.
*
* Список дублируется дважды подряд стандартный приём бесшовного CSS-marquee
* (анимация уводит ровно на 50%).
*
* `aria-hidden` на второй копии и `role="marquee"`-семантика намеренно НЕ
@ -15,11 +21,13 @@
* `prefers-reduced-motion` глушится глобальным правилом из css-модуля.
*/
import { PROOF_ROWS_PLACEHOLDER } from "../../marketing-v3";
import type { ShowcaseDeal } from "../../public-api";
import styles from "../../landing-v3.module.css";
export function DealsTickerV3() {
const items = [...PROOF_ROWS_PLACEHOLDER, ...PROOF_ROWS_PLACEHOLDER];
import { dealTitle, errPct, rub } from "./deal-view";
export function DealsTickerV3({ deals }: { deals: readonly ShowcaseDeal[] }) {
const items = [...deals, ...deals];
return (
<div className={styles.tickerRoot}>
<div className={styles.tickerBadge}>
@ -27,23 +35,20 @@ export function DealsTickerV3() {
<span className={styles.tickerBadgeText}>ПРОГНОЗ ФАКТ</span>
</div>
<div className={styles.tickerTrack} aria-hidden="true">
{items.map((r, i) => (
<div key={`${r.addr}-${i}`} className={styles.tickerItem}>
<span className={styles.tickerObj}>{r.addr}</span>
{items.map((deal, i) => (
<div key={`${deal.deal_quarter}-${i}`} className={styles.tickerItem}>
<span className={styles.tickerObj}>{dealTitle(deal)}</span>
<span className={styles.tickerPair}>
<span className={styles.tickerLabelMuted}>ПРОГНОЗ</span>
<span className={styles.tickerSaid}>{r.said}</span>
<span className={styles.tickerSaid}>{rub(deal.predicted_rub)}</span>
</span>
<span className={styles.tickerArrow}></span>
<span className={styles.tickerPair}>
<span className={styles.tickerLabelAccent}>ФАКТ</span>
<span className={styles.tickerFact}>{r.fact}</span>
</span>
<span className={styles.tickerErr}>{r.err}</span>
<span className={styles.tickerGain}>
<span className={styles.tickerGainCaret}></span>
<span>{r.gain}</span>
<span className={styles.tickerLabelAccent}>ДКП</span>
<span className={styles.tickerFact}>{rub(deal.fact_rub)}</span>
</span>
<span className={styles.tickerErr}>{errPct(deal.err_pct)}</span>
<span className={styles.tickerGain}>{deal.deal_quarter}</span>
</div>
))}
</div>

View file

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

View file

@ -12,20 +12,19 @@
* `"result"` и рендерить этот компонент с РЕАЛЬНЫМИ `analogs`/`days` из ответа
* ручки не константами.
*
* Данные приходят пропсами, а не читаются из `marketing-v3.ts` внутри
* компонента, вызывающий код сегодня мог бы по ошибке подставить туда
* `FREE_SAMPLE_PLACEHOLDER` для любого адреса, и тогда «14 похожих объявлений»
* увидел бы каждый посетитель независимо от того, что он ввёл. Это уже не
* продающая формулировка, а обман конкретного человека см. шапку
* `marketing-v3.ts`. Пропсовый контракт держит эту ответственность на
* вызывающей стороне и делает её явной в диффе, когда придёт время подключать
* реальные данные.
* Данные приходят ПРОПСАМИ и только пропсами. Соблазн подставить сюда общую
* рыночную медиану (её отдаёт `/stats`) выглядит безобидно, но «12 похожих
* объявлений» увидел бы каждый посетитель независимо от того, какой адрес он
* ввёл, а это уже не продающая формулировка, а обман конкретного человека.
* Пропсовый контракт держит ответственность на вызывающей стороне и делает её
* явной в диффе, когда придёт время подключать реальные данные.
*
* Замыленная рублёвая цифра из макета («9,75 млн », `filter: blur`) сюда
* НЕ перенесена вообще вместо неё абстрактная плашка-скелет без единой
* цифры (см. `.heroResultSkeleton*` в `landing-v3.module.css`).
*/
import { SERVICE_PRICE_RUB } from "../../content";
import styles from "../../landing-v3.module.css";
export interface FreeResultV3Props {
@ -33,9 +32,14 @@ export interface FreeResultV3Props {
analogs: string;
/** Подпись под числом объявлений. */
analogsLabel: string;
/** Средний срок продажи — крупная цифра второй плитки, напр. "47 дн.". */
/**
* Экспозиция похожих объявлений крупная цифра второй плитки, напр.
* "47 дн.". НЕ срок продажи: считается по тем, кто ещё висит, и снятие
* объявления не означает сделку (см. `median_listing_age_days` в
* public-api.ts).
*/
days: string;
/** Подпись под сроком продажи. */
/** Подпись под экспозицией. */
daysLabel: string;
/** Клик по «Проверить другую квартиру» — вернуть карточку к форме. */
onBack: () => void;
@ -73,14 +77,14 @@ export function FreeResultV3({
</div>
<div className={styles.heroResultOverlay}>
<p className={styles.heroResultOverlayTitle}>
Ваша цена и срок продажи готовы
Ваша цена готова
</p>
<button
type="button"
className={styles.heroResultOverlayBtn}
disabled
>
Открыть за 150&nbsp;
{`Открыть за ${SERVICE_PRICE_RUB}\u00a0₽`}
</button>
<p className={styles.heroResultOverlayCaption}>
Оплата подключается вместе с открытием сервиса пока недоступна.

View file

@ -4,32 +4,35 @@
* GuessGameV3 мини-игра «Сыграйте против МЕРЫ на реальных сделках»
* (макет 29.08, секция `#game` «V3 игра "Угадай цену"»).
*
* Клиентский компонент: три раунда «слайдер ответ сверка», итоговый экран
* со средней ошибкой игрока против ошибки МЕРЫ. Раунды реквизит из
* `GAME_ROUNDS_PLACEHOLDER` (см. шапку marketing-v3.ts: контура сверки
* «прогноз факт» в продукте нет, «реальные сделки» здесь макетные, и
* страница закрыта noindex до появления вычисляемых).
* Раунды РЕАЛЬНЫЕ сделки из `/showcase` (родитель готовит их через
* `toGameRound`): характеристики объекта, прогноз МЕРЫ и цена ДКП. Раньше
* здесь стояли три придуманных квартиры с придуманными адресами, а фраза «на
* реальных сделках» была неправдой. Сделок мало игру не рендерит родитель:
* играть на одной строке под заголовком «три квартиры» нельзя.
*
* Ошибка игрока считается медианой |ответ факт| / факт по трём раундам
* та же метрика, которой описана ошибка МЕРЫ (GAME_MERA_ERROR_LABEL).
* Финальная CTA ведёт на `/estimate` (решение StickyCtaV3 вместо макетной
* модалки).
* ЧЕГО В КАРТОЧКЕ БОЛЬШЕ НЕТ: точки «ОБЪЕКТ НА КАРТЕ». Координат в данных
* витрины нет вовсе (улица известна у 2.7% сделок), а точка на карте это
* утверждение о конкретном месте. Фон-сетка осталась декорацией, объект
* подписан тем, что известно: район (или прямое «район не указан»), комнаты,
* площадь, этаж, квартал сделки.
*
* Ошибка игрока считается медианой |ответ факт| / факт по сыгранным раундам
* той же формулой, что и ошибка МЕРЫ на этих же сделках (`err_pct` витрины).
* Обе величины меряют одно и то же на одной выборке, поэтому их и можно
* ставить рядом.
*/
import { useState } from "react";
import {
GAME_MERA_ERROR_LABEL,
GAME_ROUNDS_PLACEHOLDER,
} from "../../marketing-v3";
import styles from "../../landing-v3.module.css";
import type { GameRound } from "./deal-view";
import { PublicLink } from "../PublicLink";
type Phase = "playing" | "revealed" | "finished";
const fmtMln = (v: number) =>
`${v.toFixed(2).replace(".", ",")} млн ₽`;
const fmtMln = (v: number) => `${v.toFixed(2).replace(".", ",")} млн ₽`;
const fmtPct = (share: number) => `${(share * 100).toFixed(1).replace(".", ",")}%`;
function median(xs: number[]): number {
const s = [...xs].sort((a, b) => a - b);
@ -37,8 +40,7 @@ function median(xs: number[]): number {
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
}
export function GuessGameV3() {
const rounds = GAME_ROUNDS_PLACEHOLDER;
export function GuessGameV3({ rounds }: { rounds: readonly GameRound[] }) {
const [idx, setIdx] = useState(0);
const [phase, setPhase] = useState<Phase>("playing");
const [guess, setGuess] = useState(rounds[0].start);
@ -68,12 +70,12 @@ export function GuessGameV3() {
setPhase("playing");
};
const userErrPct = errors.length
? `${(median(errors) * 100).toFixed(1).replace(".", ",")}%`
: "—";
const roundErrPct = errors.length
? `${(errors[errors.length - 1] * 100).toFixed(1).replace(".", ",")}%`
: "—";
const userErrPct = errors.length ? fmtPct(median(errors)) : "—";
const roundErrPct = errors.length ? fmtPct(errors[errors.length - 1]) : "—";
// Ошибка МЕРЫ — по ТЕМ ЖЕ раундам, что сыграл человек, а не общая цифра
// бэктеста: сравнивать медиану по трём сделкам с медианой по тремстам
// значило бы сопоставлять две разные величины.
const meraErrPct = fmtPct(median(rounds.map((round) => Math.abs(round.mera - round.fact) / round.fact)));
return (
<section id="game" className={styles.gameSection} aria-labelledby="game-v3-title">
@ -85,8 +87,8 @@ export function GuessGameV3() {
Сыграйте против МЕРЫ на реальных сделках
</h2>
<p className={styles.gameLead}>
Три квартиры, которые уже проданы. Назовите свою цифру потом
увидите, что сказала МЕРА и чем всё закончилось.
Квартиры, которые уже проданы: данные Росреестра. Назовите свою
цифру потом увидите, что сказала МЕРА и чем всё закончилось.
</p>
</div>
@ -96,26 +98,14 @@ export function GuessGameV3() {
<div className={styles.gameMapGrid} aria-hidden="true" />
<div className={styles.gameMapRoadH} aria-hidden="true" />
<div className={styles.gameMapRoadV} aria-hidden="true" />
<div
className={styles.gameMapDot}
style={{ left: `${r.dotX}%`, top: `${r.dotY}%` }}
aria-hidden="true"
>
<span className={styles.gameMapPing} />
<span className={styles.gameMapCore} />
</div>
<div className={styles.gameMapLabel}>
ОБЪЕКТ НА КАРТЕ · {r.district}
{`ОБЪЕКТ · ${r.districtLabel.toUpperCase()}`}
</div>
<div className={styles.gameMapBottom}>
<div className={styles.gameAddr}>{r.addr}</div>
<div className={styles.gameAddr}>{r.title}</div>
<div className={styles.gameMeta}>{r.meta}</div>
<div className={styles.gameTags}>
{r.tags.map((t) => (
<span key={t} className={styles.gameTag}>
{t}
</span>
))}
<span className={styles.gameTag}>{`СДЕЛКА · ${r.quarter}`}</span>
</div>
</div>
</div>
@ -123,8 +113,10 @@ export function GuessGameV3() {
<div className={styles.gamePanel}>
{phase === "playing" && (
<div className={styles.gameFade}>
<div className={styles.gameQuestion}>{r.question}</div>
<div className={styles.gameHint}>{r.hint}</div>
<div className={styles.gameQuestion}>За сколько её реально купили?</div>
<div className={styles.gameHint}>
{`Сделка зарегистрирована в ${r.quarter}. Ваш ответ — в миллионах рублей.`}
</div>
<div className={styles.gameGuess}>{fmtMln(guess)}</div>
<input
type="range"
@ -159,17 +151,12 @@ export function GuessGameV3() {
<span className={styles.gameRowValueAccent}>{fmtMln(r.mera)}</span>
</div>
<div className={styles.gameRowDark}>
<span className={styles.gameRowLabelMuted}>Факт сделки</span>
<span className={styles.gameRowLabelMuted}>Цена ДКП</span>
<span className={styles.gameRowValueLight}>{fmtMln(r.fact)}</span>
</div>
</div>
<div className={styles.gameVerdict}>
Вы разошлись с фактом на {roundErrPct}
{" · "}МЕРА на{" "}
{`${(Math.abs(r.mera - r.fact) / r.fact * 100)
.toFixed(1)
.replace(".", ",")}%`}
.
{`Вы разошлись с ценой ДКП на ${roundErrPct} · МЕРА — на ${r.errPctText}.`}
</div>
<button type="button" onClick={next} className={styles.gameBtnDark}>
{isLast ? "Показать итог" : "Следующая квартира →"}
@ -179,18 +166,16 @@ export function GuessGameV3() {
{phase === "finished" && (
<div className={`${styles.gameFade} ${styles.gameFinal}`}>
<div className={styles.gameResultEyebrow}>ИТОГ ТРЁХ РАУНДОВ</div>
<div className={styles.gameResultEyebrow}>ИТОГ</div>
<div className={styles.gameFinalGrid}>
<div className={styles.gameFinalTileLight}>
<div className={styles.gameFinalValue}>{userErrPct}</div>
<div className={styles.gameFinalLabel}>ваша ошибка по цене</div>
</div>
<div className={styles.gameFinalTileSoft}>
<div className={styles.gameFinalValueAccent}>
{GAME_MERA_ERROR_LABEL}
</div>
<div className={styles.gameFinalValueAccent}>{meraErrPct}</div>
<div className={styles.gameFinalLabelAccent}>
ошибка МЕРЫ по цене
ошибка МЕРЫ на этих же квартирах
</div>
</div>
</div>

View file

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

View file

@ -5,10 +5,34 @@
* Серверный компонент: весь текст статичен, единственное состояние живёт в
* `FreeCheckCard` (клиентский дочерний компонент).
*
* Числа в разметке только импортированные константы (`ACCURACY_PLACEHOLDER`,
* `FREE_SAMPLE_PLACEHOLDER.analogs`, цена отчёта из `COST_OF_ERROR_PLACEHOLDER`)
* ни одного нового числового литерала здесь не заведено, см. шапку
* `../../marketing-v3.ts`.
* ЧИСЛА. Бейдж сверка прогноза с ценой ДКП из `landing-facts.ts` (значение
* едет вместе с размером выборки: «14,5 %» без «по 327 сделкам» это уже
* рекламная строка, а не замер; полные оговорки стоят в блоке «Точность»,
* куда бейдж и отсылает). Пример по рынку под перками `/stats`, и он ПРЯМО
* назван примером: анонимного расчёта по адресу посетителя сегодня нет, а
* одинаковые «14 аналогов» каждому, кто ввёл свой адрес, были бы обманом
* конкретного человека. Цена `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 строки без полей исхода), и обещать его
* нельзя. Сверка, которая есть, бэктест по чужим сделкам Росреестра, она и
* показана в бейдже.
*
* ПОРТ ИЗ v1 (`_components/Hero.tsx`), два блока внизу левой колонки:
* - плашка покрытия: полное `PRIMARY_CITY`, частично `SECONDARY_CITIES`,
@ -28,16 +52,38 @@ import {
PRIMARY_CITY,
REGION_NAME,
SECONDARY_CITIES,
SERVICE_PRICE_RUB,
} from "../../content";
import { ACCURACY_PLACEHOLDER, COST_OF_ERROR_PLACEHOLDER, FREE_SAMPLE_PLACEHOLDER } from "../../marketing-v3";
import { BACKTEST } from "../../landing-facts";
import { formatStat, type LandingStats } from "../../public-api";
import styles from "../../landing-v3.module.css";
import { FreeCheckCard } from "./FreeCheckCard";
/** Цена отчёта — та же величина, что в третьей плитке блока «Почему это важно». */
const REPORT_PRICE = COST_OF_ERROR_PLACEHOLDER[2].value;
export function HeroV3({ stats }: { stats: LandingStats }) {
const analogs = formatStat(stats, "analogs_median", { digits: 1, sampleWord: "расчётам" });
const age = formatStat(stats, "listing_age_median_days", {
unit: "дн.",
digits: 0,
sampleWord: "объявлениям",
});
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));
export function HeroV3() {
return (
<section
id="check"
@ -49,7 +95,7 @@ export function HeroV3() {
<div className={styles.heroBadge}>
<span className={styles.heroBadgeDot} aria-hidden="true" />
<span className={styles.heroBadgeText}>
СРЕДНЯЯ ОШИБКА ПРОГНОЗА {ACCURACY_PLACEHOLDER.priceError}
{`МЕДИАННОЕ РАСХОЖДЕНИЕ С ЦЕНОЙ ДКП — ${BACKTEST.priceError.text} ПО ${BACKTEST.priceError.sampleN} СДЕЛКАМ`}
</span>
</div>
@ -77,8 +123,8 @@ export function HeroV3() {
</span>
<span className={styles.heroListText}>
<strong>Не зависнуть:</strong> узнаете, за сколько дней уйдёт
квартира по вашей цене
<strong>Не зависнуть:</strong> увидите, сколько времени уже
висят похожие квартиры рядом
</span>
</div>
<div className={styles.heroListItem}>
@ -86,8 +132,8 @@ export function HeroV3() {
</span>
<span className={styles.heroListText}>
<strong>Не уступать зря:</strong> будет чем ответить на торг {" "}
{FREE_SAMPLE_PLACEHOLDER.analogs} аналогов с ценами
<strong>Не уступать зря:</strong> будет чем ответить на торг
список аналогов с ценами, на которых построен расчёт
</span>
</div>
</div>
@ -99,7 +145,7 @@ export function HeroV3() {
</span>
<span className={styles.heroListText}>
<strong>Бесплатно:</strong> сколько похожих квартир продаётся
рядом и как быстро они уходят
рядом и как давно они висят
</span>
</div>
<div className={styles.heroListItem}>
@ -109,12 +155,22 @@ export function HeroV3() {
<span className={styles.heroListText}>
<strong>
За{" "}
<span className={styles.heroNoBreak}>{REPORT_PRICE}</span>:
<span className={styles.heroNoBreak}>{`${SERVICE_PRICE_RUB}`}</span>:
</strong>{" "}
ваша цена и срок продажи и каждый такой прогноз мы потом
сверяем с фактом сделки
ваша цена, экспозиция похожих объявлений и аналоги, на которых
построен расчёт
</span>
</div>
{marketLines.map((line) => (
<div key={line.key} className={styles.heroListItem}>
<span className={styles.heroListIcon} aria-hidden="true">
</span>
<span className={styles.heroListText}>
<strong>{line.label}</strong> {line.text}
</span>
</div>
))}
</div>
{/* Покрытие подано неравномерно намеренно см. COVERED_CITIES в

View file

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

View file

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

View file

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

View file

@ -9,16 +9,23 @@
* партнёрской заявки и договора «продажи под ключ» в продукте НЕТ до их
* появления честный путь заявки один, живой канал. Текст пути 2 из макета
* сохранён дословно: он аккуратно не обещает ничего сверх «узнайте условия».
*
* Цена `SERVICE_PRICE_RUB` (content.ts): один источник с офертой и политикой
* возврата. Раньше она приезжала сюда из плейсхолдерной плитки «цена ошибки»,
* и разъехаться с офертой могла молча.
*
* СОСТАВ ОТЧЁТА НАЗВАН ПО ФАКТУ. В перечне пути 1 стоял «срок»: платный отчёт
* действительно отдаёт `est_days_on_market`, но это медиана `days_on_market`
* аналогов сколько похожие объявления УЖЕ висят, а не срок до сделки
* (`deals.days_on_market` заполнен 0 раз из 108 623, сверять прогноз срока не
* с чем). Слово заменено на измеренную величину.
*/
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 (
<section
@ -35,9 +42,10 @@ export function TwoPathsV3() {
<div className={styles.pathsGrid}>
<div className={styles.pathCard}>
<div className={styles.pathEyebrow}>ПУТЬ 1 · САМОСТОЯТЕЛЬНО</div>
<div className={styles.pathTitle}>Отчёт за {REPORT_PRICE}</div>
<div className={styles.pathTitle}>{`Отчёт за ${SERVICE_PRICE_RUB}`}</div>
<p className={styles.pathBody}>
Цена, срок, аналоги, факторы. Дальше вы ведёте продажу сами на
Цена, экспозиция похожих объявлений, аналоги, факторы. Дальше вы
ведёте продажу сами на
руках все цифры, чтобы уверенно держать свою цену в переговорах.
</p>
<PublicLink to="estimate" className={styles.pathBtnLight}>

View file

@ -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),
};
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,107 @@
/**
* 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<Record<"priceError" | "coverage" | "confidenceLow", MeasuredValue>> = {
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 года";
/**
* Бейдж первого шага И подпись под кнопкой формы (`FreeCheckCard.tsx`).
* Раньше в обоих местах стояло «30 секунд» замера времени заполнения формы
* нет и не было. Число полей формы, наоборот, проверяется глазами там же:
* город, адрес, площадь, комнаты, этаж, состояние.
*
* ШЕСТЬ, А НЕ ПЯТЬ. Пока эта подпись делалась, соседняя ветка вернула в форму
* ВЫБОР ГОРОДА (#2576: без него «Ленина, 1» давало жителю Нижнего Тагила цену
* по одноимённой улице в ЕКБ). Число полей не константа продукта, а счёт
* того, что стоит в разметке рядом; после слияния двух веток «5 полей» было бы
* ровно тем враньём на витрине, ради снятия которого подпись и заводилась.
* Меняется форма пересчитать здесь; проверяется по `heroFieldLabel` в
* `FreeCheckCard.tsx`.
*
* Оба места берут строку ОТСЮДА, а не пишут её у себя: «30 секунд» пережило
* снятие из бейджа ровно потому, что было вписано в четыре компонента
* независимо, и правка одного не касалась остальных.
*/
export const STEP1_FIELDS_LABEL = "6 полей";

View file

@ -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%";

View file

@ -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 (
<>
<HeaderV3 />
<main id="content" className={styles.main} tabIndex={-1}>
<DealsTickerV3 />
<HeroV3 />
{deals.length > 0 && <DealsTickerV3 deals={deals} />}
<HeroV3 stats={stats} />
<StepsV3 />
<AccuracyV3 />
<AccuracyV3 stats={stats} showcase={showcase} />
<SourcesV3 />
<WhatYouGetV3 />
<GuessGameV3 />
<CostOfErrorV3 />
{rounds.length >= MIN_ROUNDS && <GuessGameV3 rounds={rounds} />}
<CostOfErrorV3 stats={stats} />
<TwoPathsV3 />
<ObjectionsV3 />
<ArticlesV3 />

View file

@ -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<Record<string, LandingStat | undefined>>;
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<T>(path: string): Promise<T | null> {
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<LandingStats> {
const data = await fetchOrNull<LandingStats>("/stats");
return data !== null && typeof data === "object" ? data : {};
}
/**
* Витрина сделок. `null` = показывать нечего (ручка молчит, пересчёт ещё не
* отработал, строк нет) секция не рендерится. Пустой массив отдельным
* состоянием не является: «витрина из нуля строк» на странице не отличима от
* поломки, а `stats` без строк подписывать нечего.
*/
export async function fetchShowcase(): Promise<ShowcaseResponse | null> {
const data = await fetchOrNull<ShowcaseResponse>("/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 };
}