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

View file

@ -1,21 +1,27 @@
/** /**
* AccuracyV3 «Точность»: KPI-плитки + таблица сверок прогноза с фактом * AccuracyV3 «Точность»: KPI-плитки + таблица «прогноз против цены сделки»
* сделки (макет v3, ~строки 231-283, id="accuracy"). Серверный компонент. * (макет v3, ~строки 231-283, id="accuracy"). Серверный компонент.
* *
* KPI-значения из `ACCURACY_PLACEHOLDER` (marketing-v3.ts), не хардкод: это * ОТКУДА ЧИСЛА. Три первых плитки разовая сверка прогноза с ценой ДКП
* плейсхолдерные маркетинговые величины, гейтящиеся `noindex` до подтверждения * (`landing-facts.ts`, там же источник и оговорки). Экспозиция и число
* замером (см. шапку файла). * расчётов `/stats`, каждая плитка рендерится только если величина пришла:
* недоступная ручка снимает плитку, а не показывает ноль. Таблица `/showcase`
* (реальные сделки Росреестра против прогноза), и вместе со строками едет
* подпись: сколько сделок рассмотрено, сколько годных не поместилось и по
* какому правилу отсеяно остальное.
* *
* Таблица сверок `PROOF_ROWS_PLACEHOLDER`. В самом макете под неё не было * ТРИ ПЛИТКИ БЭКТЕСТА ИДУТ КОМПЛЕКТОМ И ПОРОЗНЬ НЕ ПОКАЗЫВАЮТСЯ. «88 % в
* заполнено ни одной реальной строки (только `{{ r.addr }}`-шаблон), поэтому * коридоре» без ширины коридора (±37 %) и без того, что уверенность расчёта
* строки витринный пример формата отчёта, а не факт. Ровно поэтому подпись * низкая у 325 из 327, это три разных способа выглядеть точнее, чем есть.
* под таблицей ЗАМЕНЕНА: в макете там было «ДАННЫЕ ОБЕЗЛИЧЕНЫ · » фраза, * Поэтому ширина коридора стоит в ПОДПИСИ к самой плитке, а не отдельной
* прямо утверждающая, что за строками стоят настоящие обезличенные сделки. * строкой мелким шрифтом, и оговорки рендерятся тут же, под сеткой.
* Мы этого утверждать не можем (строки придуманы для примера), поэтому подпись *
* честно называет таблицу иллюстрацией формата, а вторую половину исходной * ЧЕГО ЗДЕСЬ НЕТ. Плитки «точность по сроку продажи»: `deals.days_on_market`
* фразы («"выше средней цены" разница со средней ценой сделки по похожим * заполнен 0 раз из 108 623 сверять прогноз срока не с чем. На её месте
* квартирам в районе») сохраняет она объясняет, ЧТО значит колонка, а не * стоит медианная ЭКСПОЗИЦИЯ активного объявления, названная своим именем:
* заявляет что-то о происхождении данных. * это «сколько висит», а не «за сколько продаётся», и знака ± у неё нет.
* Счётчик «из них сверено» тоже снят контура сверки прогноза с фактом
* сделки клиента не существует.
* *
* Таблица размечена ARIA role="table/row/columnheader/cell" (а не <table>): * Таблица размечена ARIA role="table/row/columnheader/cell" (а не <table>):
* на мобильном брейке (см. landing-v3.module.css) она превращается в карточки * на мобильном брейке (см. landing-v3.module.css) она превращается в карточки
@ -26,31 +32,74 @@
*/ */
import { import {
ACCURACY_PERIOD_LABEL, BACKTEST,
ACCURACY_PLACEHOLDER, BACKTEST_CORRIDOR_LABEL,
PROOF_ROWS_PLACEHOLDER, BACKTEST_PERIOD_LABEL,
} from "../../marketing-v3"; } from "../../landing-facts";
import { formatStat, type LandingStats, type ShowcaseResponse } from "../../public-api";
import styles from "../../landing-v3.module.css"; import styles from "../../landing-v3.module.css";
const KPI_TILES = [ import { count, dealMeta, dealTitle, errPct, rub } from "./deal-view";
{
value: ACCURACY_PLACEHOLDER.priceError, interface Tile {
label: "средняя ошибка по цене", readonly key: string;
note: ACCURACY_PLACEHOLDER.priceErrorNote, readonly value: string;
}, readonly label: string;
{ readonly note: string;
value: ACCURACY_PLACEHOLDER.daysError, }
label: "точность по сроку продажи",
note: ACCURACY_PLACEHOLDER.daysErrorNote, export function AccuracyV3({
}, stats,
{ showcase,
value: ACCURACY_PLACEHOLDER.coverage, }: {
label: "сделок попали в диапазон", stats: LandingStats;
note: ACCURACY_PLACEHOLDER.coverageNote, showcase: ShowcaseResponse | null;
}, }) {
] as const; 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 ( return (
<section <section
id="accuracy" id="accuracy"
@ -63,91 +112,90 @@ export function AccuracyV3() {
ПРОГНОЗ ПРОТИВ ФАКТА ПРОГНОЗ ПРОТИВ ФАКТА
</div> </div>
<h2 id="accuracy-v3-title" className={styles.accTitle}> <h2 id="accuracy-v3-title" className={styles.accTitle}>
Мы сверяем каждый прогноз с реальной сделкой Мы сверили прогноз с ценой сделки вот что вышло
</h2> </h2>
<p className={styles.accLead}> <p className={styles.accLead}>
{`Когда квартира продана, мы возвращаемся к своему прогнозу и считаем расхождение. Вот сводка за ${ACCURACY_PERIOD_LABEL}.`} {`Сверка прогноза с ценой ДКП Росреестра: ${BACKTEST_PERIOD_LABEL}, ${BACKTEST.priceError.sampleN} сделок.`}
{estimates
? ` Расчёты в системе живут в другом окне: ${estimates.text}${
period ? ` за ${period.text}` : ""
}.`
: ""}
</p> </p>
</div> </div>
<ul className={styles.accKpiGrid} role="list"> <ul className={styles.accKpiGrid} role="list">
{KPI_TILES.map((tile) => ( {tiles.map((tile) => (
<li key={tile.label} className={styles.accKpiTile}> <li key={tile.key} className={styles.accKpiTile}>
<div className={styles.accKpiValue}>{tile.value}</div> <div className={styles.accKpiValue}>{tile.value}</div>
<div className={styles.accKpiLabel}>{tile.label}</div> <div className={styles.accKpiLabel}>{tile.label}</div>
<div className={styles.accKpiNote}>{tile.note}</div> <div className={styles.accKpiNote}>{tile.note}</div>
</li> </li>
))} ))}
<li className={`${styles.accKpiTile} ${styles.accKpiTileDark}`}> {estimates && (
<div className={styles.accKpiValue}> <li className={`${styles.accKpiTile} ${styles.accKpiTileDark}`}>
{ACCURACY_PLACEHOLDER.checks} <div className={styles.accKpiValue}>{estimates.text}</div>
</div> <div className={styles.accKpiLabel}>расчётов сделано</div>
<div className={styles.accKpiLabel}>проверок за год</div> <div className={styles.accKpiNoteDark}>
<div className={styles.accKpiNoteDark}> {[estimates.note, period ? `окно наблюдения — ${period.text}` : null]
{ACCURACY_PLACEHOLDER.checksNote} .filter(Boolean)
</div> .join(" · ")}
</li> </div>
</li>
)}
</ul> </ul>
<div <ul className={styles.accFootnote} role="list">
className={styles.accTable} {caveats.map((line) => (
role="table" <li key={line}>{line}</li>
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>
))} ))}
</div> </ul>
<p className={styles.accFootnote}>
Таблица иллюстрирует формат отчёта, это не список конкретных сделок. {deals.length > 0 && (
«Выше средней цены» разница со средней ценой сделки по похожим <>
квартирам в районе. <div
</p> 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> </div>
</section> </section>
); );

View file

@ -1,18 +1,74 @@
/** /**
* CostOfErrorV3 «Цена ошибки»: три плитки про стоимость неверной цены * CostOfErrorV3 «Цена ошибки»: плитки про стоимость неверной цены
* (макет v3, ~строки 376-400, без id). Серверный компонент. * (макет v3, ~строки 376-400, без id). Серверный компонент.
* *
* Значения и подписи из `COST_OF_ERROR_PLACEHOLDER` (marketing-v3.ts), * ОТКУДА ЧИСЛА. Обе рыночные величины `/stats`, и у них РАЗНЫЕ знаменатели:
* заголовок цитирует `SERVICE_PRICE_RUB` (content.ts) тот же источник * доля снижавших считается по всем наблюдаемым объявлениям, а медиана
* правды, что и у цены отчёта во всём остальном лэндинге, а не отдельный * просадки только по тем, кто снижал. Поэтому размер выборки печатается у
* литерал «150». * каждой плитки отдельно, а в заголовке плитки прямо сказано «среди тех, кто
* снижал»: одна подпись на две величины была бы подменой знаменателя.
* Цена `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 { 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"; 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 ( return (
<section aria-labelledby="cost-v3-title" className={styles.costSection}> <section aria-labelledby="cost-v3-title" className={styles.costSection}>
<div className={styles.costContainer}> <div className={styles.costContainer}>
@ -21,16 +77,16 @@ export function CostOfErrorV3() {
ПОЧЕМУ ЭТО ВАЖНО ПОЧЕМУ ЭТО ВАЖНО
</div> </div>
<h2 id="cost-v3-title" className={styles.costTitle}> <h2 id="cost-v3-title" className={styles.costTitle}>
{`${SERVICE_PRICE_RUB} ₽ против двух месяцев вашей жизни`} {`${SERVICE_PRICE_RUB} ₽ против двух месяцев вашей жизни`}
</h2> </h2>
</div> </div>
<ul className={styles.costGrid} role="list"> <ul className={styles.costGrid} role="list">
{COST_OF_ERROR_PLACEHOLDER.map((tile, index) => { {tiles.map((tile, index) => {
const dark = index === COST_OF_ERROR_PLACEHOLDER.length - 1; const dark = index === tiles.length - 1;
return ( return (
<li <li
key={tile.title} key={tile.key}
className={ className={
dark dark
? `${styles.costTile} ${styles.costTileDark}` ? `${styles.costTile} ${styles.costTileDark}`

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,7 +14,7 @@
*/ */
import { SERVICE_PRICE_RUB } from "../../content"; 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"; import styles from "../../landing-v3.module.css";
interface StepV3 { interface StepV3 {
@ -30,24 +30,27 @@ const STEPS_V3: readonly StepV3[] = [
}, },
{ {
title: "Мы собираем рынок вокруг", title: "Мы собираем рынок вокруг",
text: "Похожие квартиры рядом: сколько их продаётся сейчас и за сколько дней они уходят.", text: "Похожие квартиры рядом: сколько их продаётся сейчас и как давно они висят.",
}, },
{ {
title: "Вы получаете цену и срок", title: "Вы получаете цену и картину рынка",
text: "Конкретная цифра для объявления, прогноз срока продажи и все аналоги, на которых построен расчёт.", text: "Конкретная цифра для объявления, сколько уже висят похожие объявления и все аналоги, на которых построен расчёт.",
accent: true, accent: true,
}, },
] as const; ] as const;
/** /**
* Бейджи «ШАГ N · » по одному на шаг. Единственные числа во второй половине * Бейджи «ШАГ N · » по одному на шаг. Числа во второй половине бейджей
* бейджей берутся из marketing-v3.ts (`STEP1_DURATION_LABEL`) и content.ts * `STEP1_FIELDS_LABEL` (landing-facts.ts) и `SERVICE_PRICE_RUB` (content.ts,
* (`SERVICE_PRICE_RUB`, тот же источник, что и у цены отчёта в остальном * тот же источник, что и у цены отчёта в остальном лэндинге); в массиве
* лэндинге) в массиве STEPS_V3 выше их нет намеренно, чтобы не разъезжались * STEPS_V3 выше их нет намеренно, чтобы не разъезжались два места с одним и
* два места с одним и тем же числом. * тем же числом.
*
* В первом бейдже стояло «30 СЕКУНД» замера времени заполнения формы нет.
* Число полей, наоборот, видно в `FreeCheckCard.tsx` и проверяется глазами.
*/ */
const STEP_BADGES: readonly string[] = [ const STEP_BADGES: readonly string[] = [
STEP1_DURATION_LABEL.toUpperCase(), STEP1_FIELDS_LABEL.toUpperCase(),
"БЕСПЛАТНО", "БЕСПЛАТНО",
`${SERVICE_PRICE_RUB}`, `${SERVICE_PRICE_RUB}`,
]; ];

View file

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

View file

@ -9,16 +9,23 @@
* партнёрской заявки и договора «продажи под ключ» в продукте НЕТ до их * партнёрской заявки и договора «продажи под ключ» в продукте НЕТ до их
* появления честный путь заявки один, живой канал. Текст пути 2 из макета * появления честный путь заявки один, живой канал. Текст пути 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 { SERVICE_PRICE_RUB, SUPPORT_TELEGRAM_URL } from "../../content";
import { COST_OF_ERROR_PLACEHOLDER } from "../../marketing-v3";
import styles from "../../landing-v3.module.css"; import styles from "../../landing-v3.module.css";
import { PublicLink } from "../PublicLink"; import { PublicLink } from "../PublicLink";
const REPORT_PRICE = COST_OF_ERROR_PLACEHOLDER[2].value;
export function TwoPathsV3() { export function TwoPathsV3() {
return ( return (
<section <section
@ -35,9 +42,10 @@ export function TwoPathsV3() {
<div className={styles.pathsGrid}> <div className={styles.pathsGrid}>
<div className={styles.pathCard}> <div className={styles.pathCard}>
<div className={styles.pathEyebrow}>ПУТЬ 1 · САМОСТОЯТЕЛЬНО</div> <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 className={styles.pathBody}>
Цена, срок, аналоги, факторы. Дальше вы ведёте продажу сами на Цена, экспозиция похожих объявлений, аналоги, факторы. Дальше вы
ведёте продажу сами на
руках все цифры, чтобы уверенно держать свою цену в переговорах. руках все цифры, чтобы уверенно держать свою цену в переговорах.
</p> </p>
<PublicLink to="estimate" className={styles.pathBtnLight}> <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` там основание и * Поэтому §1 и §4 стоят на СВОЁМ замере (см. поля `source` там основание и
* его границы), кадастровое число снято, а поправки §3 честно помечены как * его границы), кадастровое число снято, а поправки §3 честно помечены как
* рыночная практика, а не наш замер. Плейсхолдеров marketing-v3 в статье * рыночная практика, а не наш замер. Модуля `marketing-v3.ts` с числами из
* больше нет но строка `articles/articles-content.ts` в `V3_SOURCES` гейта * макета больше не существует вовсе (29.08.2026): величины лэндинга уехали в
* #2904 оставлена намеренно: вернётся плейсхолдер гейт его увидит. * `/stats`, `/showcase` и `landing-facts.ts`. Гейт `landing-numbers-gate` этот
* файл намеренно НЕ сканирует здесь числа живут в связном тексте, и у
* каждого блока стоит своё поле `source`/`footnote` с датой и выборкой.
* *
* ЗАМЕР, НА КОТОРОМ СТОИТ ТЕКСТ. 6202 объявления квартир Домклик в * ЗАМЕР, НА КОТОРОМ СТОИТ ТЕКСТ. 6202 объявления квартир Домклик в
* Екатеринбурге, срез 18.07.2026, парно «цена публикации цена на дату * Екатеринбурге, срез 18.07.2026, парно «цена публикации цена на дату
@ -147,8 +149,8 @@ export const HOWTO = {
}, },
cta: { cta: {
eyebrow: "ЕСЛИ СЧИТАТЬ ВРУЧНУЮ НЕКОГДА", eyebrow: "ЕСЛИ СЧИТАТЬ ВРУЧНУЮ НЕКОГДА",
title: "МЕРА делает всё это за 30 секунд", title: "МЕРА делает всё это за вас",
body: `Мы находим аналоги, считаем медиану, применяем поправки и проверяем цену сроком продажи. Бесплатно покажем рынок вокруг, за ${SERVICE_PRICE_RUB} ₽ — вашу цену и прогноз.`, body: `Мы находим аналоги, считаем медиану, применяем поправки и смотрим, сколько похожие объявления уже висят. Бесплатно покажем рынок вокруг, за ${SERVICE_PRICE_RUB} ₽ — вашу цену и аналоги, на которых она построена.`,
}, },
s5: { s5: {
id: "s5", id: "s5",

View file

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

View file

@ -88,7 +88,7 @@ export default function ArticlesPage() {
</p> </p>
</div> </div>
<PublicLink to="estimate" className={styles.gameBtnAccent}> <PublicLink to="estimate" className={styles.gameBtnAccent}>
Проверить за 30 секунд Проверить квартиру
</PublicLink> </PublicLink>
</div> </div>
</main> </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 { StickyCtaV3 } from "./_components/v3/StickyCtaV3";
import { TwoPathsV3 } from "./_components/v3/TwoPathsV3"; import { TwoPathsV3 } from "./_components/v3/TwoPathsV3";
import { WhatYouGetV3 } from "./_components/v3/WhatYouGetV3"; 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"; 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: * МЕТАДАННЫЕ ЖИВУТ В `layout.tsx`, а не здесь. Заголовок и `robots: { index:
* false }` там уже ровно те же, что стояли в `v3/page.tsx`; второй экземпляр * false }` там уже ровно те же, что стояли в `v3/page.tsx`; второй экземпляр
* на уровне страницы ничего бы не менял, зато мог бы разъехаться с первым. * на уровне страницы ничего бы не менял, зато мог бы разъехаться с первым.
* NOINDEX НЕ СНИМАТЬ вместе с этим переключением: он держится за * NOINDEX НЕ СНИМАТЬ вместе с переездом витрины на замеренные числа: маршрут
* плейсхолдерные числа в `marketing-v3.ts` (гейт #2904), а не за то, какой * наружу открывается отдельным решением владельца (гейт
* вариант дизайна стоит в корне. * `landing-numbers-gate.test.ts` держит флаг в layout), а не побочным
* эффектом того, что плейсхолдеры кончились.
* *
* ЧЕГО ЗДЕСЬ ПО-ПРЕЖНЕМУ НЕТ: * ЧЕГО ЗДЕСЬ ПО-ПРЕЖНЕМУ НЕТ:
* - модалка проверки сознательно заменена экраном /estimate (см. * - модалка проверки сознательно заменена экраном /estimate (см.
* StickyCtaV3), все CTA макетного `openModal` ведут туда; * StickyCtaV3), все CTA макетного `openModal` ведут туда;
* - партнёрская заявка «продажи под ключ» кнопка ведёт в Telegram * - партнёрская заявка «продажи под ключ» кнопка ведёт в 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 ( return (
<> <>
<HeaderV3 /> <HeaderV3 />
<main id="content" className={styles.main} tabIndex={-1}> <main id="content" className={styles.main} tabIndex={-1}>
<DealsTickerV3 /> {deals.length > 0 && <DealsTickerV3 deals={deals} />}
<HeroV3 /> <HeroV3 stats={stats} />
<StepsV3 /> <StepsV3 />
<AccuracyV3 /> <AccuracyV3 stats={stats} showcase={showcase} />
<SourcesV3 /> <SourcesV3 />
<WhatYouGetV3 /> <WhatYouGetV3 />
<GuessGameV3 /> {rounds.length >= MIN_ROUNDS && <GuessGameV3 rounds={rounds} />}
<CostOfErrorV3 /> <CostOfErrorV3 stats={stats} />
<TwoPathsV3 /> <TwoPathsV3 />
<ObjectionsV3 /> <ObjectionsV3 />
<ArticlesV3 /> <ArticlesV3 />

View file

@ -148,3 +148,180 @@ export async function fetchCoverage(
signal: options.signal, 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 };
}