feat(mera-public): витрина лэндинга v3 на замеренных числах

Литералы из дизайн-макета заменены величинами из /stats и /showcase, а то,
чему нет входа в данных, снято вместе с блоком.

Что заменено:
- ошибка «1,8%» → 14,5% (n=327) с двумя оговорками рядом: сравнение идёт с
  объявлениями, активными на момент расчёта, а сделка прошлая; цена ДКП
  бывает занижена ради налога;
- «83% в диапазон» → 88% (n=327) неразрывно с шириной коридора ±37% в
  подписи и с третьей плиткой: уверенность «низкая» у 325 из 327;
- «±6 дн. точность по сроку» СНЯТА — deals.days_on_market пуст 0/108 623; на
  её месте медианная экспозиция активного объявления, без ± и без «точности»;
- «42 700 проверок, из них 3 180 сверены» → estimates_total; «сверено» снято
  (контура сверки прогноза со сделкой клиента нет);
- «−4,3% за месяц» → price_cut_median_pct_per_month (среди снижавших, своя
  выборка); «×2,4 дольше» СНЯТА (замер даёт ×1,04) → price_cut_share_pct;
- «14 объявлений / 47 дн.» → analogs_median и listing_age_median_days, прямо
  помеченные как пример по рынку, а не расчёт по адресу посетителя;
- 5 выдуманных строк сверок и 3 раунда игры → реальные сделки /showcase
  (район+комнаты+площадь+этаж, квартал вместо дня) с подписью прогона;
- цена во всех местах — SERVICE_PRICE_RUB из content.ts.

Отказ ручки стоит блока: пустой /stats снимает плитку, пустой /showcase —
таблицу, ленту и игру. Ни нуля, ни прочерка, ни прошлого значения.

Гейт #2904 переписан: он охранял «плейсхолдеры существуют» и покраснел бы по
построению за их снятие. Новый охраняет происхождение чисел — сканирует
компоненты на вписанные руками величины и проверяет заполненность записей
landing-facts.ts. Фальсифицирован: вписанное в AccuracyV3 «1,8% по 42 700
проверкам» роняет гейт тремя формами сразу. Сканер прикрыт контролем на
образцах — он же поймал уже существовавшую «150 ₽» в FreeResultV3.

robots/noindex не тронуты.
This commit is contained in:
bot-backend 2026-08-29 19:59:58 +05:00
parent 53328a217c
commit 49d75131ff
17 changed files with 832 additions and 614 deletions

View file

@ -0,0 +1,149 @@
/**
* Гейт #2904 (переписан 29.08.2026): ни одна величина не выходит на витрину
* без источника.
*
* ЧТО ОХРАНЯЛ ПРЕДЫДУЩИЙ ГЕЙТ. Он следил за парой «плейсхолдеры на странице
* `noindex` в layout.tsx»: пока числа из дизайн-макета живы, страницу нельзя
* открыть наружу. Плейсхолдеров больше нет числа заменены замеренными
* (`/api/public/mera/stats`, `/showcase`) либо сняты вместе с блоком, и
* второй тест того гейта («ни один плейсхолдер не найден зонд сломан»)
* покраснел бы ПО ПОСТРОЕНИЮ ровно за честную работу. Правило пришлось
* сформулировать заново.
*
* ЧТО ОХРАНЯЕТ ЭТОТ. Не наличие плейсхолдеров, а происхождение чисел:
*
* 1. в компонентах лэндинга v3 нет вписанных руками величин число
* приходит либо из ручки (`formatStat`/`/showcase`), либо из
* `landing-facts.ts`, где рядом с ним лежат выборка и оговорки;
* 2. каждая запись `landing-facts.ts` заполнена: значение, размер выборки,
* источник, оговорки пустых полей быть не может;
* 3. сам сканер проверен на образцах: он обязан найти вписанное число и не
* обязан ругаться на честный код. Без этого пункта правило (1) стало бы
* зелёным по построению при первой же опечатке в регулярке;
* 4. блок `robots` в layout.tsx на месте. Снятие `noindex` решение
* владельца, но оно должно быть осознанным, а не побочным.
*
* ЧЕГО ГЕЙТ НЕ ДЕЛАЕТ. Он не сканирует `articles/articles-content.ts`: числа
* статьи это связный текст, у каждого блока которого стоит собственное поле
* `source`/`footnote` с датой и выборкой замера (см. шапку того файла). Ловить
* их регуляркой значило бы требовать переписать статью в константы; правило
* (2) там не поможет, а правило (1) дало бы шум вместо охраны.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { BACKTEST, type MeasuredValue } from "../landing-facts";
const ROOT = join(__dirname, "..");
function read(rel: string): string {
return readFileSync(join(ROOT, rel), "utf8");
}
/** Компоненты, которые рисуют витринные величины. */
const V3_SOURCES = [
"v3/page.tsx",
"_components/v3/HeroV3.tsx",
"_components/v3/AccuracyV3.tsx",
"_components/v3/CostOfErrorV3.tsx",
"_components/v3/TwoPathsV3.tsx",
"_components/v3/StepsV3.tsx",
"_components/v3/DealsTickerV3.tsx",
"_components/v3/GuessGameV3.tsx",
"_components/v3/FreeCheckCard.tsx",
"_components/v3/FreeResultV3.tsx",
"_components/v3/deal-view.ts",
];
/**
* Формы, в которых величина попадает на экран. Ищутся ИМЕННО они, а не любые
* цифры: `useState(0)`, `slice(0, 3)` и `toFixed(2)` это код, а «14,5 %»,
* «42 700», «±6 дн.» и «×2,4» утверждения о рынке.
*/
const MEASUREMENT_SHAPES: readonly { rx: RegExp; what: string }[] = [
{ rx: /\d[\s ]\d{3}/u, what: "число с разделителем тысяч (42 700)" },
{ rx: /\d,\d/u, what: "десятичная дробь через запятую (1,8)" },
{
rx: /\d\s*(?:%||дн\.|дней|мес\.|млн|тыс\.|м²|сделок|аналог|объявлен|оцен)/iu,
what: "число с единицей измерения (83%, 150 ₽, ±6 дн.)",
},
{ rx: /×\s*\d/u, what: "кратность (×2,4)" },
];
/**
* Комментарии не витрина: в них величины ЦИТИРУЮТСЯ, чтобы объяснить снятое.
* ` ` заодно превращается в пробел: «150 » на экране то же самое
* число с единицей, что и «150 », и именно в таком виде цена однажды уже
* проехала мимо сканера.
*/
function stripComments(src: string): string {
return src
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/(^|[^:"'`])\/\/[^\n]*/g, "$1")
.replace(/ |\\u00a0/g, " ");
}
/** Находит вписанные руками величины. Пусто — значит все числа пришли из данных. */
function handwrittenValues(src: string): string[] {
const code = stripComments(src);
return MEASUREMENT_SHAPES.filter(({ rx }) => rx.test(code)).map(({ what }) => what);
}
describe("гейт витринных чисел (#2904)", () => {
it.each(V3_SOURCES)("в %s нет чисел, вписанных руками", (rel) => {
const found = handwrittenValues(read(rel));
expect(found, [
`в ${rel} найдена величина, вписанная в код: ${found.join(", ")}.`,
"Витринное число обязано приходить из данных — formatStat() по /stats,",
"строки /showcase — либо из landing-facts.ts, где у него есть выборка,",
"источник и оговорки. Нет источника — блок не рендерится.",
].join(" ")).toEqual([]);
});
it.each(Object.entries(BACKTEST))("величина %s в landing-facts заполнена целиком", (key, value: MeasuredValue) => {
expect(value.text.trim(), `${key}: пустое значение`).not.toBe("");
expect(Number.isInteger(value.sampleN) && value.sampleN > 0, `${key}: выборка не указана`).toBe(true);
expect(value.source.trim().length, `${key}: не сказано, откуда число`).toBeGreaterThan(20);
expect(value.caveats.length, `${key}: нет ни одной оговорки`).toBeGreaterThan(0);
for (const caveat of value.caveats) {
expect(caveat.trim(), `${key}: пустая оговорка`).not.toBe("");
}
});
it("сканер видит вписанное число и молчит на честном коде — иначе он проходил бы вхолостую", () => {
// Контроль на инструмент. Слева — ровно то, что гейт обязан ловить
// (величины из прежнего marketing-v3.ts), справа — код, который в
// компонентах есть и ругаться на который нельзя.
for (const bad of [
"<div>СРЕДНЯЯ ОШИБКА — 1,8%</div>",
"<div>{`42 700 проверок`}</div>",
"const days = '±6 дн.';",
"<div>×2,4 дольше продаётся</div>",
"<div>150 ₽</div>",
"<button>Открыть за 150&nbsp;₽</button>",
]) {
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;',
]) {
expect(handwrittenValues(good), `сканер ругается на честный код: ${good}`).toEqual([]);
}
});
it("плейсхолдерные экспорты не вернулись под новым именем", () => {
for (const rel of V3_SOURCES) {
expect(stripComments(read(rel)), `${rel} снова тянет *_PLACEHOLDER`).not.toMatch(/_PLACEHOLDER\b/);
}
});
it("блок robots в layout.tsx на месте", () => {
expect(read("layout.tsx"), "layout.tsx не содержит блока robots — зонд сломан").toContain("robots");
});
});

View file

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

View file

@ -1,113 +0,0 @@
/**
* Гейт #2904: снять `noindex` можно только вместе с плейсхолдерными числами.
*
* В `marketing-v3.ts` лежат величины из дизайн-макета, НЕ подтверждённые замером:
*
* | здесь | факт по замеру |
* | ошибка 1,8% | 13,2% медиана |%| (бэктест n=277), held-out 16,1% |
* | 83% в диапазон | 81,95%, но коридор шириной 74% от точки |
* | ±6 дн по сроку | сверки срока с фактом НЕ СУЩЕСТВУЕТ |
* | 42 700 проверок за год | 1 084 оценки всего (база живёт 2,5 месяца) |
* | 42 города РФ | 6 городов области, полное покрытие 1 (ЕКБ) |
*
* Пока страница закрыта `robots: { index: false }`, эти строки никому не видны, и
* держать их допустимо. Публикация же недостоверных характеристик ПЛАТНОЙ услуги
* подпадает под ст. 5 ФЗ «О рекламе» и ЗоЗПП.
*
* До сих пор это правило жило ТОЛЬКО в комментарии модуля. Правило, записанное
* словами, не мешает снять одну строку в layout.tsx и выкатить страницу поэтому
* гейт здесь механический: он краснеет ровно в тот момент, когда noindex исчезает,
* а плейсхолдеры остаются.
*
* Тест НЕ запрещает открывать страницу. Он требует, чтобы открытие шло вместе с
* заменой чисел на вычисляемые (или их снятием) то есть чтобы решение было
* осознанным, а не побочным следствием правки метаданных.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = join(__dirname, "..");
function read(rel: string): string {
return readFileSync(join(ROOT, rel), "utf8");
}
/** Компоненты лэндинга v3, которые могут ссылаться на плейсхолдеры. */
const V3_SOURCES = [
"v3/page.tsx",
"_components/v3/HeroV3.tsx",
"_components/v3/AccuracyV3.tsx",
"_components/v3/FreeCheckCard.tsx",
"_components/v3/FreeResultV3.tsx",
// Макет 29.08: лента и игра тоже стоят на реквизитных величинах.
"_components/v3/DealsTickerV3.tsx",
"_components/v3/GuessGameV3.tsx",
// Раздел статей (29.08) — плитки §4 статьи берут те же плейсхолдеры. Без
// этой строки гейт остался бы зелёным при снятии noindex: он сканировал бы
// только лэндинг, а величины уже живут и на статье.
"articles/articles-content.ts",
];
const PLACEHOLDER_EXPORTS = [
"ACCURACY_PLACEHOLDER",
"COST_OF_ERROR_PLACEHOLDER",
"FREE_SAMPLE_PLACEHOLDER",
"PROOF_ROWS_PLACEHOLDER",
"GAME_ROUNDS_PLACEHOLDER",
];
function placeholdersInUse(): string[] {
const used: string[] = [];
for (const rel of V3_SOURCES) {
let src: string;
try {
src = read(rel);
} catch {
continue; // файл переименован/удалён — не повод падать
}
for (const name of PLACEHOLDER_EXPORTS) {
if (src.includes(name) && !used.includes(name)) used.push(name);
}
}
return used;
}
function noindexIsOn(): boolean {
const layout = read("layout.tsx").replace(/\s+/g, " ");
return layout.includes("robots: { index: false");
}
describe("гейт noindex ↔ плейсхолдерные числа (#2904)", () => {
it("пока плейсхолдеры на странице — noindex обязан стоять", () => {
const used = placeholdersInUse();
if (used.length === 0) {
// Числа заменены вычисляемыми — гейт больше не нужен, открывать можно.
return;
}
expect(noindexIsOn(), [
`лэндинг v3 всё ещё использует непроверенные величины: ${used.join(", ")},`,
"но `robots: { index: false }` в layout.tsx снят.",
"Перед открытием страницы наружу каждая цифра обязана быть заменена",
"вычисляемой либо снята — см. шапку marketing-v3.ts и issue #2904.",
].join(" ")).toBe(true);
});
it("сам гейт видит и плейсхолдеры, и noindex — иначе он проходил бы вхолостую", () => {
// Контроль на инструмент: если оба зонда перестанут находить своё
// (переименование файлов, смена формы записи), проверка выше станет
// зелёной по построению и перестанет что-либо охранять.
expect(placeholdersInUse().length, "ни один плейсхолдер не найден в компонентах v3 — зонд сломан").toBeGreaterThan(0);
expect(read("layout.tsx"), "layout.tsx не содержит блока robots — зонд сломан").toContain("robots");
});
it("расхождение «заявлено против замера» остаётся в шапке модуля", () => {
// Таблица — единственное место, где зафиксировано, НАСКОЛЬКО числа расходятся
// с фактом. Потеряется она — и следующий читатель решит, что цифры проверены.
const mod = read("marketing-v3.ts");
expect(mod).toContain("13,2%");
expect(mod).toContain("НЕ СУЩЕСТВУЕТ");
expect(mod).toContain("1 084");
});
});

View file

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

View file

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

View file

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

View file

@ -15,8 +15,8 @@
* Раздел «шаг 1 из 2» / полоска прогресса / ярлык рынка (`{{ cityMarket }}`,
* `{{ stepLabel }}`) из макета сюда сознательно НЕ перенесены: они требуют
* состояния города, которым владеет шапка сайта вне участка этой задачи
* («первый экран: герой и карточка»), и вносили бы числовые литералы (33%,
* 66%, 100%), которых нет ни в макете-константах, ни в `marketing-v3.ts`.
* («первый экран: герой и карточка»), и вносили бы числовые литералы (доли
* прогресса), которых нет ни в макете-константах, ни в замерах.
*
* Ветка результата ПОСТРОЕНА (`FreeResultV3.tsx`), но не подключена см.
* TODO у `PUBLIC_ESTIMATE_ENABLED` ниже и JSDoc в `FreeResultV3.tsx`.

View file

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

View file

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

View file

@ -5,21 +5,43 @@
* Серверный компонент: весь текст статичен, единственное состояние живёт в
* `FreeCheckCard` (клиентский дочерний компонент).
*
* Числа в разметке только импортированные константы (`ACCURACY_PLACEHOLDER`,
* `FREE_SAMPLE_PLACEHOLDER.analogs`, цена отчёта из `COST_OF_ERROR_PLACEHOLDER`)
* ни одного нового числового литерала здесь не заведено, см. шапку
* `../../marketing-v3.ts`.
* ЧИСЛА. Бейдж сверка прогноза с ценой ДКП из `landing-facts.ts` (значение
* едет вместе с размером выборки: «14,5 %» без «по 327 сделкам» это уже
* рекламная строка, а не замер; полные оговорки стоят в блоке «Точность»,
* куда бейдж и отсылает). Пример по рынку под перками `/stats`, и он ПРЯМО
* назван примером: анонимного расчёта по адресу посетителя сегодня нет, а
* одинаковые «14 аналогов» каждому, кто ввёл свой адрес, были бы обманом
* конкретного человека. Цена `SERVICE_PRICE_RUB` (content.ts), один
* источник с офертой и политикой возврата.
*
* ЧЕГО В ТЕКСТЕ БОЛЬШЕ НЕТ: обещания «каждый такой прогноз мы потом сверяем с
* фактом сделки». Контура сверки прогноза клиента с его сделкой в продукте не
* существует (`trade_in_leads` 4 строки без полей исхода), и обещать его
* нельзя. Сверка, которая есть, бэктест по чужим сделкам Росреестра, она и
* показана в бейдже.
*/
import { ACCURACY_PLACEHOLDER, COST_OF_ERROR_PLACEHOLDER, FREE_SAMPLE_PLACEHOLDER } from "../../marketing-v3";
import { SERVICE_PRICE_RUB } from "../../content";
import { BACKTEST } from "../../landing-facts";
import { formatStat, type LandingStats } from "../../public-api";
import styles from "../../landing-v3.module.css";
import { FreeCheckCard } from "./FreeCheckCard";
/** Цена отчёта — та же величина, что в третьей плитке блока «Почему это важно». */
const REPORT_PRICE = COST_OF_ERROR_PLACEHOLDER[2].value;
export function HeroV3({ stats }: { stats: LandingStats }) {
const analogs = formatStat(stats, "analogs_median", { digits: 1, sampleWord: "расчётам" });
const age = formatStat(stats, "listing_age_median_days", {
unit: "дн.",
digits: 0,
sampleWord: "объявлениям",
});
const marketExample = [
analogs && `медиана аналогов на расчёт — ${analogs.text}${analogs.sample ? ` (${analogs.sample})` : ""}`,
age && `активное объявление висит ${age.text}${age.sample ? ` (медиана ${age.sample})` : ""}`,
]
.filter((line): line is string => Boolean(line))
.join("; ");
export function HeroV3() {
return (
<section
id="check"
@ -31,7 +53,7 @@ export function HeroV3() {
<div className={styles.heroBadge}>
<span className={styles.heroBadgeDot} aria-hidden="true" />
<span className={styles.heroBadgeText}>
СРЕДНЯЯ ОШИБКА ПРОГНОЗА {ACCURACY_PLACEHOLDER.priceError}
{`МЕДИАННОЕ РАСХОЖДЕНИЕ С ЦЕНОЙ ДКП — ${BACKTEST.priceError.text} ПО ${BACKTEST.priceError.sampleN} СДЕЛКАМ`}
</span>
</div>
@ -68,8 +90,8 @@ export function HeroV3() {
</span>
<span className={styles.heroListText}>
<strong>Не уступать зря:</strong> будет чем ответить на торг {" "}
{FREE_SAMPLE_PLACEHOLDER.analogs} аналогов с ценами
<strong>Не уступать зря:</strong> будет чем ответить на торг
список аналогов с ценами, на которых построен расчёт
</span>
</div>
</div>
@ -81,7 +103,7 @@ export function HeroV3() {
</span>
<span className={styles.heroListText}>
<strong>Бесплатно:</strong> сколько похожих квартир продаётся
рядом и как быстро они уходят
рядом и как давно они висят
</span>
</div>
<div className={styles.heroListItem}>
@ -91,12 +113,22 @@ export function HeroV3() {
<span className={styles.heroListText}>
<strong>
За{" "}
<span className={styles.heroNoBreak}>{REPORT_PRICE}</span>:
<span className={styles.heroNoBreak}>{`${SERVICE_PRICE_RUB}`}</span>:
</strong>{" "}
ваша цена и срок продажи и каждый такой прогноз мы потом
сверяем с фактом сделки
ваша цена, срок продажи и аналоги, на которых построен расчёт
</span>
</div>
{marketExample !== "" && (
<div className={styles.heroListItem}>
<span className={styles.heroListIcon} aria-hidden="true">
</span>
<span className={styles.heroListText}>
<strong>Пример по рынку Екатеринбурга, а не расчёт по вашему адресу:</strong>{" "}
{marketExample}
</span>
</div>
)}
</div>
</div>

View file

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

View file

@ -9,16 +9,17 @@
* партнёрской заявки и договора «продажи под ключ» в продукте НЕТ до их
* появления честный путь заявки один, живой канал. Текст пути 2 из макета
* сохранён дословно: он аккуратно не обещает ничего сверх «узнайте условия».
*
* Цена `SERVICE_PRICE_RUB` (content.ts): один источник с офертой и политикой
* возврата. Раньше она приезжала сюда из плейсхолдерной плитки «цена ошибки»,
* и разъехаться с офертой могла молча.
*/
import { SUPPORT_TELEGRAM_URL } from "../../content";
import { COST_OF_ERROR_PLACEHOLDER } from "../../marketing-v3";
import { SERVICE_PRICE_RUB, SUPPORT_TELEGRAM_URL } from "../../content";
import styles from "../../landing-v3.module.css";
import { PublicLink } from "../PublicLink";
const REPORT_PRICE = COST_OF_ERROR_PLACEHOLDER[2].value;
export function TwoPathsV3() {
return (
<section
@ -35,7 +36,7 @@ export function TwoPathsV3() {
<div className={styles.pathsGrid}>
<div className={styles.pathCard}>
<div className={styles.pathEyebrow}>ПУТЬ 1 · САМОСТОЯТЕЛЬНО</div>
<div className={styles.pathTitle}>Отчёт за {REPORT_PRICE}</div>
<div className={styles.pathTitle}>{`Отчёт за ${SERVICE_PRICE_RUB}`}</div>
<p className={styles.pathBody}>
Цена, срок, аналоги, факторы. Дальше вы ведёте продажу сами на
руках все цифры, чтобы уверенно держать свою цену в переговорах.

View file

@ -0,0 +1,98 @@
/**
* Форматирование строк витрины `/showcase` общее для ленты, таблицы сверок и
* игры. Держится в одном месте, потому что «объект» в этих данных описывается
* ТОЛЬКО так: комнаты, площадь, этаж, район (может отсутствовать). Улицы и
* дома в данных нет вовсе номер дома известен у 2.7% сделок, и три
* компонента, каждый со своей склейкой, рано или поздно разъехались бы в том,
* что именно они выдают за адрес.
*/
import type { ShowcaseDeal } from "../../public-api";
const RUB = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 });
const PCT = new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 1,
signDisplay: "exceptZero",
});
const AREA = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 1 });
export const rub = (value: number): string => `${RUB.format(value)}`;
/** Счётчик прогона витрины — тем же разделителем тысяч, что и суммы. */
export const count = (value: number): string => RUB.format(value);
/** Отклонение прогноза от факта — со знаком: плюс = МЕРА назвала дороже. */
export const errPct = (value: number): string => `${PCT.format(value)} %`;
/** «2-к, 52 м²» — всё, что про объект известно наверняка. */
export const dealTitle = (deal: ShowcaseDeal): string =>
`${deal.rooms}-к, ${AREA.format(deal.area_m2)} м²`;
/**
* Район / этаж / число аналогов через точку. Отсутствующие части ВЫПАДАЮТ, а
* не заменяются прочерком или правдоподобной подстановкой: `district` и
* `floor` в ДКП-данных бывают пустыми.
*/
export function dealMeta(deal: ShowcaseDeal): string {
const parts: string[] = [];
if (deal.district) parts.push(deal.district);
if (deal.floor !== null) {
parts.push(deal.total_floors !== null ? `${deal.floor}/${deal.total_floors} этаж` : `${deal.floor} этаж`);
}
parts.push(`аналогов: ${deal.n_analogs}`);
return parts.join(" · ");
}
/** Раунд игры «Угадай цену» — та же сделка витрины, подготовленная к слайдеру. */
export interface GameRound {
readonly key: string;
/** «2-к, 52 м²». */
readonly title: string;
readonly meta: string;
/** Район или прямое «район не указан» — подстановки нет. */
readonly districtLabel: string;
readonly quarter: string;
/** Границы слайдера, млн ₽. */
readonly min: number;
readonly max: number;
readonly step: number;
readonly start: number;
/** Прогноз МЕРЫ и факт ДКП, млн ₽. */
readonly mera: number;
readonly fact: number;
/** Отклонение прогноза от факта ИМЕННО этой сделки. */
readonly errPctText: string;
}
const MLN = 1_000_000;
const STEP_MLN = 0.05;
/** Полуширина коридора слайдера вокруг сделки — чтобы ответ не лежал на краю. */
const SPAN = 0.35;
const toHalf = (v: number, up: boolean): number => (up ? Math.ceil(v * 2) / 2 : Math.floor(v * 2) / 2);
/**
* Границы слайдера считаются от самой сделки другого масштаба у раунда нет.
* Старт кладётся в СЕРЕДИНУ коридора, а не в прогноз МЕРЫ: иначе первое же
* положение ручки выдавало бы ответ, который игра предлагает угадать.
*/
export function toGameRound(deal: ShowcaseDeal, index: number): GameRound {
const mera = deal.predicted_rub / MLN;
const fact = deal.fact_rub / MLN;
const min = Math.max(toHalf(Math.min(mera, fact) * (1 - SPAN), false), STEP_MLN);
const max = toHalf(Math.max(mera, fact) * (1 + SPAN), true);
return {
key: `${deal.deal_quarter}-${index}`,
title: dealTitle(deal),
meta: dealMeta(deal),
districtLabel: deal.district ?? "район не указан",
quarter: deal.deal_quarter,
min,
max,
step: STEP_MLN,
start: Math.round((min + max) / 2 / STEP_MLN) * STEP_MLN,
mera,
fact,
errPctText: errPct(deal.err_pct),
};
}

View file

@ -20,9 +20,11 @@
*
* Поэтому §1 и §4 стоят на СВОЁМ замере (см. поля `source` там основание и
* его границы), кадастровое число снято, а поправки §3 честно помечены как
* рыночная практика, а не наш замер. Плейсхолдеров marketing-v3 в статье
* больше нет но строка `articles/articles-content.ts` в `V3_SOURCES` гейта
* #2904 оставлена намеренно: вернётся плейсхолдер гейт его увидит.
* рыночная практика, а не наш замер. Модуля `marketing-v3.ts` с числами из
* макета больше не существует вовсе (29.08.2026): величины лэндинга уехали в
* `/stats`, `/showcase` и `landing-facts.ts`. Гейт `landing-numbers-gate` этот
* файл намеренно НЕ сканирует здесь числа живут в связном тексте, и у
* каждого блока стоит своё поле `source`/`footnote` с датой и выборкой.
*
* ЗАМЕР, НА КОТОРОМ СТОИТ ТЕКСТ. 6202 объявления квартир Домклик в
* Екатеринбурге, срез 18.07.2026, парно «цена публикации цена на дату

View file

@ -0,0 +1,94 @@
/**
* landing-facts величины лэндинга, которые НЕ приходят из `/stats`, но
* измерены. Пришло на смену `marketing-v3.ts` (числа из дизайн-макета).
*
* ЧЕМ ЭТОТ ФАЙЛ ОТЛИЧАЕТСЯ ОТ ПРЕДЫДУЩЕГО. В `marketing-v3.ts` лежали
* продающие формулировки, и шапка честно признавала, что ни одна из них не
* подтверждена замером. Здесь наоборот: каждая запись результат конкретного
* запроса к боевой базе, у неё есть размер выборки и оговорки, без которых
* число врёт. Записи без `source`/`sampleN` завести нельзя тип не даст, а
* гейт `__tests__/landing-numbers-gate.test.ts` проверяет, что поля не пустые.
*
* ПОЧЕМУ ЭТО НЕ В `/stats`. Всё, что считается ночной задачей
* (`app/tasks/landing_stats.py`), фронт берёт через `fetchLandingStats()`
* так число не может отстать от базы. Сверка прогноза с ценой ДКП такой
* задачи пока не имеет: это разовый замер владельца на проде, и до появления
* регулярного пересчёта честнее держать его здесь, вместе с датой замера,
* чем показывать «свежим» на витрине.
*
* ЧЕГО ЗДЕСЬ НЕТ. Точности прогноза по СРОКУ продажи: `deals.days_on_market`
* заполнен 0 раз из 108 623, сверять прогноз срока не с чем. На витрине её
* место занимает `listing_age_median_days` из `/stats` экспозиция активного
* объявления, и она названа своим именем, без слова «точность».
* Счётчика «сверено с фактом сделки клиента» тоже нет: контура такой сверки в
* продукте не существует (`trade_in_leads` 4 строки без полей исхода).
*/
/**
* Замеренная величина витрины.
*
* `caveats` не украшение: у обоих чисел бэктеста есть границы, без которых
* они читаются лучше, чем есть. Рендерятся рядом со значением (AccuracyV3),
* а не в комментарии.
*/
export interface MeasuredValue {
/** Значение с единицей, готовое к выводу. */
readonly text: string;
/** Размер выборки, по которой значение посчитано. */
readonly sampleN: number;
/** Откуда взято: запрос, база, дата замера. */
readonly source: string;
/** Оговорки, без которых величина вводит в заблуждение. */
readonly caveats: readonly string[];
}
/** Общий источник трёх величин ниже — один и тот же прогон сверки. */
const BACKTEST_SOURCE =
"Ручная сверка на проде (poincare, 29.08.2026): прогноз МЕРЫ против цены ДКП " +
"Росреестра по Екатеринбургу, сделки с 06.2025";
/**
* Сверка «прогноз цена ДКП». Три величины идут КОМПЛЕКТОМ и показываются
* вместе: попадание в коридор без ширины коридора и без уровня уверенности
* читается как точность, которой нет.
*/
export const BACKTEST: Readonly<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 года";
/**
* Бейдж первого шага. Раньше здесь стояло «30 секунд» замера времени
* заполнения формы нет и не было. Число полей формы, наоборот, проверяется
* глазами в `FreeCheckCard.tsx`: адрес, площадь, комнаты, этаж, состояние.
*/
export const STEP1_FIELDS_LABEL = "5 полей";

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 { ObjectionsV3 } from "../_components/v3/ObjectionsV3";
import { StepsV3 } from "../_components/v3/StepsV3";
import { StickyCtaV3 } from "../_components/v3/StickyCtaV3";
import { TwoPathsV3 } from "../_components/v3/TwoPathsV3";
import { toGameRound } from "../_components/v3/deal-view";
import { fetchLandingStats, fetchShowcase } from "../public-api";
import styles from "../landing.module.css";
/**
@ -24,22 +26,24 @@ import styles from "../landing.module.css";
* на одном и том же контенте. Переключение отдельным решением, когда
* версия принята.
*
* ОБНОВЛЕНИЕ 29.08 (макет «B2C модуль», Макс): добавлены лента
* «прогноз факт», игра «Угадай цену», «Два пути», «Возражения» и тизер
* статей. Данные ленты/игры реквизит из marketing-v3.ts под тем же гейтом
* #2904 (контура сверки прогноза с фактом в продукте по-прежнему нет).
* ОТКУДА ЧИСЛА (29.08.2026). Страница стала серверно-асинхронной: витринные
* величины приходят из `/api/public/mera/stats` и `/showcase`, а не из
* литералов. Обе ручки НЕ бросают: недоступный бэкенд даёт пустой набор и
* `null`, и тогда блок, которому нечего показать, просто не рендерится
* ни нуля, ни прочерка, ни прошлого значения. Единственные числа, живущие в
* коде, разовая сверка прогноза с ценой ДКП (`landing-facts.ts`), у неё там
* записаны выборка, источник и оговорки.
*
* ЛЕНТА И ИГРА СТОЯТ НА ТЕХ ЖЕ СДЕЛКАХ, что и таблица сверок: это один набор
* `/showcase`, а не три. Игра не рендерится, пока сделок меньше `MIN_ROUNDS`
* «сыграйте три раунда» на одной строке было бы обещанием, которого данные не
* покрывают.
*
* ЧЕГО ЗДЕСЬ ПО-ПРЕЖНЕМУ НЕТ:
* - страницы статей («МЕРА статьи»/«МЕРА статья») карточки тизера
* рендерятся без ссылок до появления раздела;
* - модалка проверки сознательно заменена экраном /estimate (см.
* StickyCtaV3), все CTA макетного `openModal` ведут туда;
* - партнёрская заявка «продажи под ключ» кнопка ведёт в Telegram
* поддержки до появления формы и договора.
*
* ЧИСЛА в секции «Точность» и «Цена ошибки» плейсхолдеры из макета,
* см. `marketing-v3.ts`: они обязаны быть заменены вычисляемыми до снятия
* `robots: noindex`.
*/
export const metadata: Metadata = {
@ -47,20 +51,37 @@ export const metadata: Metadata = {
robots: { index: false, follow: false },
};
export default function MeraPublicV3Page() {
/**
* Окно перегенерации страницы. Нужно отдельно от кэша самих запросов
* (`revalidate: 3600` в public-api.ts): сборка идёт в докере, где бэкенда нет,
* поэтому в билд страница уезжает БЕЗ витринных блоков. С часовым окном она
* такой и стояла бы час после каждого деплоя. Минута это задержка появления
* чисел после выката; нагрузки на бэкенд она не добавляет, перегенерация
* переиспользует тот же часовой кэш ответов.
*/
export const revalidate = 60;
/** Раундов в игре. Меньше — секция не показывается вовсе. */
const MIN_ROUNDS = 3;
export default async function MeraPublicV3Page() {
const [stats, showcase] = await Promise.all([fetchLandingStats(), fetchShowcase()]);
const deals = showcase?.deals ?? [];
const rounds = deals.slice(0, MIN_ROUNDS).map(toGameRound);
return (
<>
<HeaderV3 />
<main id="content" className={styles.main} tabIndex={-1}>
<DealsTickerV3 />
<HeroV3 />
{deals.length > 0 && <DealsTickerV3 deals={deals} />}
<HeroV3 stats={stats} />
<StepsV3 />
<AccuracyV3 />
<GuessGameV3 />
<CostOfErrorV3 />
<TwoPathsV3 />
<ObjectionsV3 />
<ArticlesV3 />
<AccuracyV3 stats={stats} showcase={showcase} />
{rounds.length >= MIN_ROUNDS && <GuessGameV3 rounds={rounds} />}
<CostOfErrorV3 stats={stats} />
<TwoPathsV3 />
<ObjectionsV3 />
<ArticlesV3 />
</main>
<FooterV3 />
<StickyCtaV3 />