feat(mera/b2c): витрина лэндинга переезжает с выдуманных чисел на замеренные #3243
26 changed files with 1470 additions and 651 deletions
|
|
@ -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)" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Комментарии — не витрина: в них величины ЦИТИРУЮТСЯ, чтобы объяснить снятое.
|
||||
* ` ` заодно превращается в пробел: «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 ₽</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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -8,7 +8,7 @@ import { PublicLink } from "./PublicLink";
|
|||
* (`v3/HeaderV3.tsx`).
|
||||
*
|
||||
* Из неё убраны навигация по секциям лэндинга, селектор города и CTA
|
||||
* «Проверить за 30 секунд». Причина у всех трёх одна: человек уже ушёл с
|
||||
* «Проверить квартиру». Причина у всех трёх одна: человек уже ушёл с
|
||||
* лэндинга. Якоря вроде `#accuracy` тут просто не на что навести (секций нет
|
||||
* на этих страницах), город выбирается прямо в форме проверки — а второй
|
||||
* селектор города в шапке означал бы два места, задающих одно значение.
|
||||
|
|
|
|||
|
|
@ -23,11 +23,24 @@
|
|||
* «возраст активного объявления», а НЕ макетное «срок продажи по дому» —
|
||||
* выборка цензурирована, честное имя величины важнее красоты.
|
||||
*
|
||||
* Возврат 150 ₽ при ненадёжном расчёте — политика с /refund; в тизере она
|
||||
* Возврат при ненадёжном расчёте — политика с /refund; в тизере она
|
||||
* упоминается той же строкой, что в макете, со ссылкой на полный текст.
|
||||
*
|
||||
* ЦЕНА — ТОЛЬКО `SERVICE_PRICE_RUB`. Здесь оставались три литерала «150 ₽»
|
||||
* (плашка тизера, текст и строка возврата). Цена стоит в оферте и в политике
|
||||
* возврата; вписанная в экран копия разъехалась бы с ними молча, и человек
|
||||
* увидел бы одну цифру на витрине, а другую в документе, который его
|
||||
* обязывает.
|
||||
*
|
||||
* СРОК. Строка тизера обещала «прогноз срока продажи»: платный расчёт отдаёт
|
||||
* `est_days_on_market` — медиану `days_on_market` аналогов, то есть сколько
|
||||
* похожие объявления УЖЕ висят. `deals.days_on_market` заполнен 0 раз из
|
||||
* 108 623, поэтому сверить прогноз срока не с чем и прогнозом эта величина
|
||||
* не является. Названа тем, что считается, — ровно как в пункте 5 выше про
|
||||
* бесплатные плитки.
|
||||
*/
|
||||
|
||||
import { SUPPORT_TELEGRAM_LABEL, SUPPORT_TELEGRAM_URL } from "../../content";
|
||||
import { SERVICE_PRICE_RUB, SUPPORT_TELEGRAM_LABEL, SUPPORT_TELEGRAM_URL } from "../../content";
|
||||
import type { CoverageVerdict } from "../../coverage-copy";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
|
|
@ -129,7 +142,7 @@ function FreeCards({ verdict }: { verdict: CoverageVerdict }) {
|
|||
|
||||
const PAID_ROWS = [
|
||||
{ label: "РЕКОМЕНДОВАННАЯ ЦЕНА", w: "60%" },
|
||||
{ label: "ПРОГНОЗ СРОКА ПРОДАЖИ ПРИ ЭТОЙ ЦЕНЕ", w: "45%" },
|
||||
{ label: "СКОЛЬКО УЖЕ ВИСЯТ ПОХОЖИЕ ОБЪЯВЛЕНИЯ", w: "45%" },
|
||||
{ label: "ДИАПАЗОН И ПОЛОЖЕНИЕ НА РЫНКЕ", w: "70%" },
|
||||
{ label: "АНАЛОГИ С АДРЕСАМИ И ЦЕНАМИ", w: "88%" },
|
||||
] as const;
|
||||
|
|
@ -139,7 +152,9 @@ function PaidTeaser() {
|
|||
return (
|
||||
<div className={styles.repPaid}>
|
||||
<div className={styles.repPaidBar}>
|
||||
<span className={styles.repPaidBarText}>ПОЛНЫЙ ОТЧЁТ · 150 ₽</span>
|
||||
<span className={styles.repPaidBarText}>
|
||||
ПОЛНЫЙ ОТЧЁТ · {SERVICE_PRICE_RUB} ₽
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.repPaidGrid}>
|
||||
<div className={styles.repPaidLeft}>
|
||||
|
|
@ -159,17 +174,17 @@ function PaidTeaser() {
|
|||
</div>
|
||||
<div className={styles.repPaidRight}>
|
||||
<div className={styles.repPaidTitle}>
|
||||
Цена и срок продажи — следующий шаг
|
||||
Ваша цена — следующий шаг
|
||||
</div>
|
||||
<p className={styles.repPaidText}>
|
||||
Полный расчёт стоит 150 ₽: диапазон цены, цена за квадратный
|
||||
метр и список квартир, на которых он построен. Один платёж, без
|
||||
подписки. Приём оплаты сейчас подключается — как только заработает,
|
||||
кнопка появится здесь.
|
||||
Полный расчёт стоит {SERVICE_PRICE_RUB} ₽: диапазон цены, цена
|
||||
за квадратный метр и список квартир, на которых он построен. Один
|
||||
платёж, без подписки. Приём оплаты сейчас подключается — как только
|
||||
заработает, кнопка появится здесь.
|
||||
</p>
|
||||
<p className={styles.repPaidFine}>
|
||||
Если надёжный расчёт не получится — вернём 150 ₽:{" "}
|
||||
<PublicLink to="refund">условия возврата</PublicLink>.
|
||||
Если надёжный расчёт не получится — вернём {SERVICE_PRICE_RUB}
|
||||
₽: <PublicLink to="refund">условия возврата</PublicLink>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,18 +1,74 @@
|
|||
/**
|
||||
* CostOfErrorV3 — «Цена ошибки»: три плитки про стоимость неверной цены
|
||||
* CostOfErrorV3 — «Цена ошибки»: плитки про стоимость неверной цены
|
||||
* (макет v3, ~строки 376-400, без id). Серверный компонент.
|
||||
*
|
||||
* Значения и подписи — из `COST_OF_ERROR_PLACEHOLDER` (marketing-v3.ts),
|
||||
* заголовок цитирует `SERVICE_PRICE_RUB` (content.ts) — тот же источник
|
||||
* правды, что и у цены отчёта во всём остальном лэндинге, а не отдельный
|
||||
* литерал «150».
|
||||
* ОТКУДА ЧИСЛА. Обе рыночные величины — `/stats`, и у них РАЗНЫЕ знаменатели:
|
||||
* доля снижавших считается по всем наблюдаемым объявлениям, а медиана
|
||||
* просадки — только по тем, кто снижал. Поэтому размер выборки печатается у
|
||||
* каждой плитки отдельно, а в заголовке плитки прямо сказано «среди тех, кто
|
||||
* снижал»: одна подпись на две величины была бы подменой знаменателя.
|
||||
* Цена — `SERVICE_PRICE_RUB` (content.ts), тот же источник, что у оферты.
|
||||
*
|
||||
* ОБЪЯВЛЕНИЯ, А НЕ ПРОДАВЦЫ. `price_cut_share_pct` — доля ОБЪЯВЛЕНИЙ, и
|
||||
* только по Домклику (единственный источник, где триггер пишет стартовую
|
||||
* цену, см. шапку `landing_stats.py`). Один продавец ведёт несколько
|
||||
* объявлений, поэтому «столько продавцов снижают цену» приписывало величине
|
||||
* единицу, которой её не мерили: подпись приведена к измеренному, а источник
|
||||
* назван прямо в ней, а не только в `note` под плиткой.
|
||||
*
|
||||
* ЧЕГО ЗДЕСЬ БОЛЬШЕ НЕТ. Плитки «×2,4 дольше продаётся квартира с завышенной
|
||||
* ценой»: замер даёт ×1,04 (79 дней против 76), и это уже с цензурой в пользу
|
||||
* заявления. Её место заняла доля снижающих цену — измеренная величина про то
|
||||
* же самое: рынок не соглашается с завышенной ценой.
|
||||
*/
|
||||
|
||||
import { SERVICE_PRICE_RUB } from "../../content";
|
||||
import { COST_OF_ERROR_PLACEHOLDER } from "../../marketing-v3";
|
||||
import { formatStat, type LandingStats } from "../../public-api";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
export function CostOfErrorV3() {
|
||||
interface CostTile {
|
||||
readonly key: string;
|
||||
readonly value: string;
|
||||
readonly title: string;
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export function CostOfErrorV3({ stats }: { stats: LandingStats }) {
|
||||
const drop = formatStat(stats, "price_cut_median_pct_per_month", {
|
||||
unit: "%",
|
||||
digits: 2,
|
||||
sampleWord: "объявлениям",
|
||||
});
|
||||
const share = formatStat(stats, "price_cut_share_pct", {
|
||||
unit: "%",
|
||||
digits: 1,
|
||||
sampleWord: "объявлениям",
|
||||
});
|
||||
|
||||
const tiles: CostTile[] = [];
|
||||
if (drop) {
|
||||
tiles.push({
|
||||
key: "drop",
|
||||
value: drop.text,
|
||||
title: "медианное изменение цены за месяц — среди тех, кто снижал",
|
||||
text: [drop.note, drop.sample].filter(Boolean).join(" · "),
|
||||
});
|
||||
}
|
||||
if (share) {
|
||||
tiles.push({
|
||||
key: "share",
|
||||
value: share.text,
|
||||
title: "столько объявлений на Домклике снижали цену",
|
||||
text: [share.note, share.sample].filter(Boolean).join(" · "),
|
||||
});
|
||||
}
|
||||
tiles.push({
|
||||
key: "price",
|
||||
value: `${SERVICE_PRICE_RUB} ₽`,
|
||||
title: "стоит узнать правильную цену заранее",
|
||||
text: "Один платёж. Без подписки. Отчёт остаётся у вас по ссылке.",
|
||||
});
|
||||
|
||||
return (
|
||||
<section aria-labelledby="cost-v3-title" className={styles.costSection}>
|
||||
<div className={styles.costContainer}>
|
||||
|
|
@ -21,16 +77,16 @@ export function CostOfErrorV3() {
|
|||
ПОЧЕМУ ЭТО ВАЖНО
|
||||
</div>
|
||||
<h2 id="cost-v3-title" className={styles.costTitle}>
|
||||
{`${SERVICE_PRICE_RUB} ₽ против двух месяцев вашей жизни`}
|
||||
{`${SERVICE_PRICE_RUB} ₽ против двух месяцев вашей жизни`}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<ul className={styles.costGrid} role="list">
|
||||
{COST_OF_ERROR_PLACEHOLDER.map((tile, index) => {
|
||||
const dark = index === COST_OF_ERROR_PLACEHOLDER.length - 1;
|
||||
{tiles.map((tile, index) => {
|
||||
const dark = index === tiles.length - 1;
|
||||
return (
|
||||
<li
|
||||
key={tile.title}
|
||||
key={tile.key}
|
||||
className={
|
||||
dark
|
||||
? `${styles.costTile} ${styles.costTileDark}`
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* FreeCheckCard — карточка «Бесплатная проверка» на первом экране (макет v3,
|
||||
* `МЕРА для себя v3.dc.html` строки ~129-170).
|
||||
*
|
||||
* ЧТО ОНА ДЕЛАЕТ СЕГОДНЯ. Проверяет пять полей и уводит на `/estimate`, где
|
||||
* ЧТО ОНА ДЕЛАЕТ СЕГОДНЯ. Проверяет шесть полей и уводит на `/estimate`, где
|
||||
* живёт сама проверка. Макетный сценарий «спиннер 1100 мс → готовый
|
||||
* результат прямо в карточке» не воспроизводится и не будет: результату нужно
|
||||
* место рядом с полями, а автокомплиту — место под список; в карточке первого
|
||||
|
|
@ -15,8 +15,8 @@
|
|||
* Раздел «шаг 1 из 2» / полоска прогресса / ярлык рынка (`{{ cityMarket }}`,
|
||||
* `{{ stepLabel }}`) из макета сюда сознательно НЕ перенесены: они требуют
|
||||
* состояния города, которым владеет шапка сайта — вне участка этой задачи
|
||||
* («первый экран: герой и карточка»), и вносили бы числовые литералы (33%,
|
||||
* 66%, 100%), которых нет ни в макете-константах, ни в `marketing-v3.ts`.
|
||||
* («первый экран: герой и карточка»), и вносили бы числовые литералы (доли
|
||||
* прогресса), которых нет ни в макете-константах, ни в замерах.
|
||||
*
|
||||
* Ветка результата ПОСТРОЕНА (`FreeResultV3.tsx`), но не подключена — см.
|
||||
* TODO у `PUBLIC_ESTIMATE_ENABLED` ниже и JSDoc в `FreeResultV3.tsx`.
|
||||
|
|
@ -39,6 +39,11 @@
|
|||
* не использует (см. `CoverageProbeInput` на бэке), но выбросить уже
|
||||
* полученный ответ и спросить его второй раз на платном шаге — хуже, чем
|
||||
* донести его в `sessionStorage` до того шага.
|
||||
*
|
||||
* ПОДПИСЬ ПОД КНОПКОЙ. В ней стояло «30 СЕКУНД» — замера времени заполнения
|
||||
* формы нет и не было. Его место занял `STEP1_FIELDS_LABEL`: число полей —
|
||||
* проверяемый факт, и оно приезжает из `landing-facts.ts`, а не вписано сюда
|
||||
* второй раз (иначе с бейджем шага 1 они разъехались бы молча).
|
||||
*/
|
||||
|
||||
import { useId, useRef, useState } from "react";
|
||||
|
|
@ -54,6 +59,7 @@ import {
|
|||
SECONDARY_CITIES,
|
||||
} from "../../content";
|
||||
import { saveDraft } from "../../estimate-draft";
|
||||
import { STEP1_FIELDS_LABEL } from "../../landing-facts";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
/** Значение <option> «моего города нет в списке» (то же, что в v1-форме). */
|
||||
|
|
@ -359,7 +365,7 @@ export function FreeCheckCard() {
|
|||
Показать данные по моему дому →
|
||||
</button>
|
||||
<p className={styles.heroSubmitCaption}>
|
||||
БЕЗ РЕГИСТРАЦИИ · БЕЗ ЗВОНКОВ · 30 СЕКУНД
|
||||
{`БЕЗ РЕГИСТРАЦИИ · БЕЗ ЗВОНКОВ · ${STEP1_FIELDS_LABEL.toUpperCase()}`}
|
||||
</p>
|
||||
|
||||
{/* Живая область держим в DOM постоянно — регион, добавленный в
|
||||
|
|
|
|||
|
|
@ -12,20 +12,19 @@
|
|||
* `"result"` и рендерить этот компонент с РЕАЛЬНЫМИ `analogs`/`days` из ответа
|
||||
* ручки — не константами.
|
||||
*
|
||||
* Данные приходят пропсами, а не читаются из `marketing-v3.ts` внутри
|
||||
* компонента, — вызывающий код сегодня мог бы по ошибке подставить туда
|
||||
* `FREE_SAMPLE_PLACEHOLDER` для любого адреса, и тогда «14 похожих объявлений»
|
||||
* увидел бы каждый посетитель независимо от того, что он ввёл. Это уже не
|
||||
* продающая формулировка, а обман конкретного человека — см. шапку
|
||||
* `marketing-v3.ts`. Пропсовый контракт держит эту ответственность на
|
||||
* вызывающей стороне и делает её явной в диффе, когда придёт время подключать
|
||||
* реальные данные.
|
||||
* Данные приходят ПРОПСАМИ и только пропсами. Соблазн подставить сюда общую
|
||||
* рыночную медиану (её отдаёт `/stats`) выглядит безобидно, но «12 похожих
|
||||
* объявлений» увидел бы каждый посетитель независимо от того, какой адрес он
|
||||
* ввёл, — а это уже не продающая формулировка, а обман конкретного человека.
|
||||
* Пропсовый контракт держит ответственность на вызывающей стороне и делает её
|
||||
* явной в диффе, когда придёт время подключать реальные данные.
|
||||
*
|
||||
* Замыленная рублёвая цифра из макета («9,75 млн ₽», `filter: blur`) сюда
|
||||
* НЕ перенесена вообще — вместо неё абстрактная плашка-скелет без единой
|
||||
* цифры (см. `.heroResultSkeleton*` в `landing-v3.module.css`).
|
||||
*/
|
||||
|
||||
import { SERVICE_PRICE_RUB } from "../../content";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
export interface FreeResultV3Props {
|
||||
|
|
@ -33,9 +32,14 @@ export interface FreeResultV3Props {
|
|||
analogs: string;
|
||||
/** Подпись под числом объявлений. */
|
||||
analogsLabel: string;
|
||||
/** Средний срок продажи — крупная цифра второй плитки, напр. "47 дн.". */
|
||||
/**
|
||||
* Экспозиция похожих объявлений — крупная цифра второй плитки, напр.
|
||||
* "47 дн.". НЕ срок продажи: считается по тем, кто ещё висит, и снятие
|
||||
* объявления не означает сделку (см. `median_listing_age_days` в
|
||||
* public-api.ts).
|
||||
*/
|
||||
days: string;
|
||||
/** Подпись под сроком продажи. */
|
||||
/** Подпись под экспозицией. */
|
||||
daysLabel: string;
|
||||
/** Клик по «Проверить другую квартиру» — вернуть карточку к форме. */
|
||||
onBack: () => void;
|
||||
|
|
@ -73,14 +77,14 @@ export function FreeResultV3({
|
|||
</div>
|
||||
<div className={styles.heroResultOverlay}>
|
||||
<p className={styles.heroResultOverlayTitle}>
|
||||
Ваша цена и срок продажи готовы
|
||||
Ваша цена готова
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.heroResultOverlayBtn}
|
||||
disabled
|
||||
>
|
||||
Открыть за 150 ₽
|
||||
{`Открыть за ${SERVICE_PRICE_RUB}\u00a0₽`}
|
||||
</button>
|
||||
<p className={styles.heroResultOverlayCaption}>
|
||||
Оплата подключается вместе с открытием сервиса — пока недоступна.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export function HeaderV3() {
|
|||
Для бизнеса
|
||||
</a>
|
||||
<a href="#check" className={styles.hdrCta}>
|
||||
Проверить за 30 секунд
|
||||
Проверить квартиру
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,10 +5,34 @@
|
|||
* Серверный компонент: весь текст статичен, единственное состояние живёт в
|
||||
* `FreeCheckCard` (клиентский дочерний компонент).
|
||||
*
|
||||
* Числа в разметке — только импортированные константы (`ACCURACY_PLACEHOLDER`,
|
||||
* `FREE_SAMPLE_PLACEHOLDER.analogs`, цена отчёта из `COST_OF_ERROR_PLACEHOLDER`)
|
||||
* — ни одного нового числового литерала здесь не заведено, см. шапку
|
||||
* `../../marketing-v3.ts`.
|
||||
* ЧИСЛА. Бейдж — сверка прогноза с ценой ДКП из `landing-facts.ts` (значение
|
||||
* едет вместе с размером выборки: «14,5 %» без «по 327 сделкам» — это уже
|
||||
* рекламная строка, а не замер; полные оговорки стоят в блоке «Точность»,
|
||||
* куда бейдж и отсылает). Пример по рынку под перками — `/stats`, и он ПРЯМО
|
||||
* назван примером: анонимного расчёта по адресу посетителя сегодня нет, а
|
||||
* одинаковые «14 аналогов» каждому, кто ввёл свой адрес, были бы обманом
|
||||
* конкретного человека. Цена — `SERVICE_PRICE_RUB` (content.ts), один
|
||||
* источник с офертой и политикой возврата.
|
||||
*
|
||||
* ДВЕ СТРОКИ ПРИМЕРА, А НЕ ОДНА. Екатеринбургом ограничена только экспозиция
|
||||
* (`_LISTING_AGE_SQL` фильтрует `listings.city`); медиана аналогов считается
|
||||
* по всей таблице `trade_in_estimates` без фильтра по городу. Стоя под общей
|
||||
* шапкой «пример по рынку Екатеринбурга», она была бы подписана городом, к
|
||||
* которому не привязана, — поэтому у каждой величины своя подпись со своей
|
||||
* границей. Рядом с аналогами печатается их `note` бэкенда («только расчёты,
|
||||
* где аналоги нашлись»): это отбор выживших, расчёты без аналогов в
|
||||
* знаменатель не попали, и без оговорки медиана читается выше, чем есть.
|
||||
*
|
||||
* СРОК. В тексте нет «узнаете срок продажи»: платный отчёт отдаёт
|
||||
* `est_days_on_market` — медиану `days_on_market` аналогов, то есть сколько
|
||||
* похожие объявления УЖЕ висят (`deals.days_on_market` пуст, сверять прогноз
|
||||
* срока не с чем). Обещание приведено к этой величине.
|
||||
*
|
||||
* ЧЕГО В ТЕКСТЕ БОЛЬШЕ НЕТ: обещания «каждый такой прогноз мы потом сверяем с
|
||||
* фактом сделки». Контура сверки прогноза клиента с его сделкой в продукте не
|
||||
* существует (`trade_in_leads` — 4 строки без полей исхода), и обещать его
|
||||
* нельзя. Сверка, которая есть, — бэктест по чужим сделкам Росреестра, она и
|
||||
* показана в бейдже.
|
||||
*
|
||||
* ПОРТ ИЗ v1 (`_components/Hero.tsx`), два блока внизу левой колонки:
|
||||
* - плашка покрытия: полное — `PRIMARY_CITY`, частично — `SECONDARY_CITIES`,
|
||||
|
|
@ -28,16 +52,38 @@ import {
|
|||
PRIMARY_CITY,
|
||||
REGION_NAME,
|
||||
SECONDARY_CITIES,
|
||||
SERVICE_PRICE_RUB,
|
||||
} from "../../content";
|
||||
import { ACCURACY_PLACEHOLDER, COST_OF_ERROR_PLACEHOLDER, FREE_SAMPLE_PLACEHOLDER } from "../../marketing-v3";
|
||||
import { BACKTEST } from "../../landing-facts";
|
||||
import { formatStat, type LandingStats } from "../../public-api";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
import { FreeCheckCard } from "./FreeCheckCard";
|
||||
|
||||
/** Цена отчёта — та же величина, что в третьей плитке блока «Почему это важно». */
|
||||
const REPORT_PRICE = COST_OF_ERROR_PLACEHOLDER[2].value;
|
||||
export function HeroV3({ stats }: { stats: LandingStats }) {
|
||||
const analogs = formatStat(stats, "analogs_median", { digits: 1, sampleWord: "расчётам" });
|
||||
const age = formatStat(stats, "listing_age_median_days", {
|
||||
unit: "дн.",
|
||||
digits: 0,
|
||||
sampleWord: "объявлениям",
|
||||
});
|
||||
const marketLines = [
|
||||
age && {
|
||||
key: "age",
|
||||
label: "Пример по рынку Екатеринбурга, а не расчёт по вашему адресу:",
|
||||
text: [`активное объявление висит ${age.text}`, age.sample && `медиана ${age.sample}`]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
},
|
||||
analogs && {
|
||||
key: "analogs",
|
||||
label: "По всем расчётам МЕРЫ, а не только по Екатеринбургу:",
|
||||
text: [`медиана аналогов на расчёт — ${analogs.text}`, analogs.sample, analogs.note]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
},
|
||||
].filter((line): line is { key: string; label: string; text: string } => Boolean(line));
|
||||
|
||||
export function HeroV3() {
|
||||
return (
|
||||
<section
|
||||
id="check"
|
||||
|
|
@ -49,7 +95,7 @@ export function HeroV3() {
|
|||
<div className={styles.heroBadge}>
|
||||
<span className={styles.heroBadgeDot} aria-hidden="true" />
|
||||
<span className={styles.heroBadgeText}>
|
||||
СРЕДНЯЯ ОШИБКА ПРОГНОЗА — {ACCURACY_PLACEHOLDER.priceError}
|
||||
{`МЕДИАННОЕ РАСХОЖДЕНИЕ С ЦЕНОЙ ДКП — ${BACKTEST.priceError.text} ПО ${BACKTEST.priceError.sampleN} СДЕЛКАМ`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -77,8 +123,8 @@ export function HeroV3() {
|
|||
→
|
||||
</span>
|
||||
<span className={styles.heroListText}>
|
||||
<strong>Не зависнуть:</strong> узнаете, за сколько дней уйдёт
|
||||
квартира по вашей цене
|
||||
<strong>Не зависнуть:</strong> увидите, сколько времени уже
|
||||
висят похожие квартиры рядом
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.heroListItem}>
|
||||
|
|
@ -86,8 +132,8 @@ export function HeroV3() {
|
|||
→
|
||||
</span>
|
||||
<span className={styles.heroListText}>
|
||||
<strong>Не уступать зря:</strong> будет чем ответить на торг —{" "}
|
||||
{FREE_SAMPLE_PLACEHOLDER.analogs} аналогов с ценами
|
||||
<strong>Не уступать зря:</strong> будет чем ответить на торг —
|
||||
список аналогов с ценами, на которых построен расчёт
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -99,7 +145,7 @@ export function HeroV3() {
|
|||
</span>
|
||||
<span className={styles.heroListText}>
|
||||
<strong>Бесплатно:</strong> сколько похожих квартир продаётся
|
||||
рядом и как быстро они уходят
|
||||
рядом и как давно они висят
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.heroListItem}>
|
||||
|
|
@ -109,12 +155,22 @@ export function HeroV3() {
|
|||
<span className={styles.heroListText}>
|
||||
<strong>
|
||||
За{" "}
|
||||
<span className={styles.heroNoBreak}>{REPORT_PRICE}</span>:
|
||||
<span className={styles.heroNoBreak}>{`${SERVICE_PRICE_RUB} ₽`}</span>:
|
||||
</strong>{" "}
|
||||
ваша цена и срок продажи — и каждый такой прогноз мы потом
|
||||
сверяем с фактом сделки
|
||||
ваша цена, экспозиция похожих объявлений и аналоги, на которых
|
||||
построен расчёт
|
||||
</span>
|
||||
</div>
|
||||
{marketLines.map((line) => (
|
||||
<div key={line.key} className={styles.heroListItem}>
|
||||
<span className={styles.heroListIcon} aria-hidden="true">
|
||||
≈
|
||||
</span>
|
||||
<span className={styles.heroListText}>
|
||||
<strong>{line.label}</strong> {line.text}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Покрытие подано неравномерно намеренно — см. COVERED_CITIES в
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ export function ObjectionsV3() {
|
|||
Что обычно спрашивают перед оплатой
|
||||
</h2>
|
||||
<PublicLink to="estimate" className={styles.objCtaBtn}>
|
||||
Проверить за 30 секунд
|
||||
Проверить квартиру
|
||||
</PublicLink>
|
||||
</div>
|
||||
<div className={styles.objList}>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
*/
|
||||
|
||||
import { SERVICE_PRICE_RUB } from "../../content";
|
||||
import { STEP1_DURATION_LABEL } from "../../marketing-v3";
|
||||
import { STEP1_FIELDS_LABEL } from "../../landing-facts";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
interface StepV3 {
|
||||
|
|
@ -30,24 +30,27 @@ const STEPS_V3: readonly StepV3[] = [
|
|||
},
|
||||
{
|
||||
title: "Мы собираем рынок вокруг",
|
||||
text: "Похожие квартиры рядом: сколько их продаётся сейчас и за сколько дней они уходят.",
|
||||
text: "Похожие квартиры рядом: сколько их продаётся сейчас и как давно они висят.",
|
||||
},
|
||||
{
|
||||
title: "Вы получаете цену и срок",
|
||||
text: "Конкретная цифра для объявления, прогноз срока продажи и все аналоги, на которых построен расчёт.",
|
||||
title: "Вы получаете цену и картину рынка",
|
||||
text: "Конкретная цифра для объявления, сколько уже висят похожие объявления и все аналоги, на которых построен расчёт.",
|
||||
accent: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Бейджи «ШАГ N · …» по одному на шаг. Единственные числа во второй половине
|
||||
* бейджей берутся из marketing-v3.ts (`STEP1_DURATION_LABEL`) и content.ts
|
||||
* (`SERVICE_PRICE_RUB`, тот же источник, что и у цены отчёта в остальном
|
||||
* лэндинге) — в массиве STEPS_V3 выше их нет намеренно, чтобы не разъезжались
|
||||
* два места с одним и тем же числом.
|
||||
* Бейджи «ШАГ N · …» по одному на шаг. Числа во второй половине бейджей —
|
||||
* `STEP1_FIELDS_LABEL` (landing-facts.ts) и `SERVICE_PRICE_RUB` (content.ts,
|
||||
* тот же источник, что и у цены отчёта в остальном лэндинге); в массиве
|
||||
* STEPS_V3 выше их нет намеренно, чтобы не разъезжались два места с одним и
|
||||
* тем же числом.
|
||||
*
|
||||
* В первом бейдже стояло «30 СЕКУНД» — замера времени заполнения формы нет.
|
||||
* Число полей, наоборот, видно в `FreeCheckCard.tsx` и проверяется глазами.
|
||||
*/
|
||||
const STEP_BADGES: readonly string[] = [
|
||||
STEP1_DURATION_LABEL.toUpperCase(),
|
||||
STEP1_FIELDS_LABEL.toUpperCase(),
|
||||
"БЕСПЛАТНО",
|
||||
`${SERVICE_PRICE_RUB} ₽`,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -14,6 +14,14 @@
|
|||
* компоненте — так `StickyCtaV3` остаётся самодостаточным независимо от
|
||||
* того, куда именно в дерево страницы его вставят (ожидаемое место — сразу
|
||||
* после подвала, как Header/Footer в текущем `layout.tsx`).
|
||||
*
|
||||
* НА КНОПКЕ НЕТ СРОКА. Здесь стояло «Проверить за 30 секунд» — замера времени
|
||||
* заполнения формы нет и не было (та же причина, по которой «30 СЕКУНД» ушло
|
||||
* из бейджа первого шага, см. `landing-facts.ts`). Панель висит на экране
|
||||
* ВСЕГДА, поэтому именно с неё обещание, которого никто не мерил, читалось
|
||||
* чаще всего. В тексте панели по той же причине нет и «срока продажи»:
|
||||
* платный отчёт отдаёт медиану экспозиции похожих объявлений, а не прогноз
|
||||
* срока (`_estimate_days_on_market`, см. HeroV3).
|
||||
*/
|
||||
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
|
@ -26,10 +34,10 @@ export function StickyCtaV3() {
|
|||
<div className={styles.barSpacer} aria-hidden="true" />
|
||||
<div className={styles.barRoot}>
|
||||
<p className={styles.barText}>
|
||||
Узнайте цену и срок продажи своей квартиры
|
||||
Узнайте цену своей квартиры и что происходит с похожими рядом
|
||||
</p>
|
||||
<PublicLink to="estimate" className={styles.barBtn}>
|
||||
Проверить за 30 секунд
|
||||
Проверить квартиру
|
||||
</PublicLink>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -9,16 +9,23 @@
|
|||
* партнёрской заявки и договора «продажи под ключ» в продукте НЕТ — до их
|
||||
* появления честный путь заявки один, живой канал. Текст пути 2 из макета
|
||||
* сохранён дословно: он аккуратно не обещает ничего сверх «узнайте условия».
|
||||
*
|
||||
* Цена — `SERVICE_PRICE_RUB` (content.ts): один источник с офертой и политикой
|
||||
* возврата. Раньше она приезжала сюда из плейсхолдерной плитки «цена ошибки»,
|
||||
* и разъехаться с офертой могла молча.
|
||||
*
|
||||
* СОСТАВ ОТЧЁТА НАЗВАН ПО ФАКТУ. В перечне пути 1 стоял «срок»: платный отчёт
|
||||
* действительно отдаёт `est_days_on_market`, но это медиана `days_on_market`
|
||||
* аналогов — сколько похожие объявления УЖЕ висят, а не срок до сделки
|
||||
* (`deals.days_on_market` заполнен 0 раз из 108 623, сверять прогноз срока не
|
||||
* с чем). Слово заменено на измеренную величину.
|
||||
*/
|
||||
|
||||
import { SUPPORT_TELEGRAM_URL } from "../../content";
|
||||
import { COST_OF_ERROR_PLACEHOLDER } from "../../marketing-v3";
|
||||
import { SERVICE_PRICE_RUB, SUPPORT_TELEGRAM_URL } from "../../content";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
import { PublicLink } from "../PublicLink";
|
||||
|
||||
const REPORT_PRICE = COST_OF_ERROR_PLACEHOLDER[2].value;
|
||||
|
||||
export function TwoPathsV3() {
|
||||
return (
|
||||
<section
|
||||
|
|
@ -35,9 +42,10 @@ export function TwoPathsV3() {
|
|||
<div className={styles.pathsGrid}>
|
||||
<div className={styles.pathCard}>
|
||||
<div className={styles.pathEyebrow}>ПУТЬ 1 · САМОСТОЯТЕЛЬНО</div>
|
||||
<div className={styles.pathTitle}>Отчёт за {REPORT_PRICE}</div>
|
||||
<div className={styles.pathTitle}>{`Отчёт за ${SERVICE_PRICE_RUB} ₽`}</div>
|
||||
<p className={styles.pathBody}>
|
||||
Цена, срок, аналоги, факторы. Дальше вы ведёте продажу сами — на
|
||||
Цена, экспозиция похожих объявлений, аналоги, факторы. Дальше вы
|
||||
ведёте продажу сами — на
|
||||
руках все цифры, чтобы уверенно держать свою цену в переговорах.
|
||||
</p>
|
||||
<PublicLink to="estimate" className={styles.pathBtnLight}>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -20,9 +20,11 @@
|
|||
*
|
||||
* Поэтому §1 и §4 стоят на СВОЁМ замере (см. поля `source` — там основание и
|
||||
* его границы), кадастровое число снято, а поправки §3 честно помечены как
|
||||
* рыночная практика, а не наш замер. Плейсхолдеров marketing-v3 в статье
|
||||
* больше нет — но строка `articles/articles-content.ts` в `V3_SOURCES` гейта
|
||||
* #2904 оставлена намеренно: вернётся плейсхолдер — гейт его увидит.
|
||||
* рыночная практика, а не наш замер. Модуля `marketing-v3.ts` с числами из
|
||||
* макета больше не существует вовсе (29.08.2026): величины лэндинга уехали в
|
||||
* `/stats`, `/showcase` и `landing-facts.ts`. Гейт `landing-numbers-gate` этот
|
||||
* файл намеренно НЕ сканирует — здесь числа живут в связном тексте, и у
|
||||
* каждого блока стоит своё поле `source`/`footnote` с датой и выборкой.
|
||||
*
|
||||
* ЗАМЕР, НА КОТОРОМ СТОИТ ТЕКСТ. 6202 объявления квартир Домклик в
|
||||
* Екатеринбурге, срез 18.07.2026, парно «цена публикации → цена на дату
|
||||
|
|
@ -147,8 +149,8 @@ export const HOWTO = {
|
|||
},
|
||||
cta: {
|
||||
eyebrow: "ЕСЛИ СЧИТАТЬ ВРУЧНУЮ НЕКОГДА",
|
||||
title: "МЕРА делает всё это за 30 секунд",
|
||||
body: `Мы находим аналоги, считаем медиану, применяем поправки и проверяем цену сроком продажи. Бесплатно покажем рынок вокруг, за ${SERVICE_PRICE_RUB} ₽ — вашу цену и прогноз.`,
|
||||
title: "МЕРА делает всё это за вас",
|
||||
body: `Мы находим аналоги, считаем медиану, применяем поправки и смотрим, сколько похожие объявления уже висят. Бесплатно покажем рынок вокруг, за ${SERVICE_PRICE_RUB} ₽ — вашу цену и аналоги, на которых она построена.`,
|
||||
},
|
||||
s5: {
|
||||
id: "s5",
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ export default function HowToArticlePage() {
|
|||
<div className={styles.artCtaTitle}>{HOWTO.cta.title}</div>
|
||||
<p className={styles.artCtaBody}>{HOWTO.cta.body}</p>
|
||||
<PublicLink to="estimate" className={styles.gameBtnAccent}>
|
||||
Проверить за 30 секунд
|
||||
Проверить квартиру
|
||||
</PublicLink>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ export default function ArticlesPage() {
|
|||
</p>
|
||||
</div>
|
||||
<PublicLink to="estimate" className={styles.gameBtnAccent}>
|
||||
Проверить за 30 секунд
|
||||
Проверить квартиру
|
||||
</PublicLink>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
107
tradein-mvp/frontend/src/app/mera-public/landing-facts.ts
Normal file
107
tradein-mvp/frontend/src/app/mera-public/landing-facts.ts
Normal 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 полей";
|
||||
|
|
@ -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%";
|
||||
|
|
@ -12,6 +12,8 @@ import { StepsV3 } from "./_components/v3/StepsV3";
|
|||
import { StickyCtaV3 } from "./_components/v3/StickyCtaV3";
|
||||
import { TwoPathsV3 } from "./_components/v3/TwoPathsV3";
|
||||
import { WhatYouGetV3 } from "./_components/v3/WhatYouGetV3";
|
||||
import { toGameRound } from "./_components/v3/deal-view";
|
||||
import { fetchLandingStats, fetchShowcase } from "./public-api";
|
||||
import styles from "./landing.module.css";
|
||||
|
||||
/**
|
||||
|
|
@ -30,36 +32,69 @@ import styles from "./landing.module.css";
|
|||
* статьи. Доказательная часть (точность, источники, состав результата) идёт
|
||||
* подряд и целиком ДО первого разговора про деньги.
|
||||
*
|
||||
* ОТКУДА ЧИСЛА (29.08.2026). Страница серверно-асинхронная: витринные величины
|
||||
* приходят из `/api/public/mera/stats` и `/showcase`, а не из литералов —
|
||||
* плейсхолдерного `marketing-v3.ts` больше нет. Обе ручки НЕ бросают:
|
||||
* недоступный бэкенд даёт пустой набор и `null`, и тогда блок, которому нечего
|
||||
* показать, просто не рендерится — ни нуля, ни прочерка, ни прошлого значения.
|
||||
* Единственные числа, живущие в коде, — разовая сверка прогноза с ценой ДКП
|
||||
* (`landing-facts.ts`), у неё там записаны выборка, источник и оговорки.
|
||||
*
|
||||
* ЛЕНТА И ИГРА СТОЯТ НА ТЕХ ЖЕ СДЕЛКАХ, что и таблица сверок: это один набор
|
||||
* `/showcase`, а не три. Игра не рендерится, пока сделок меньше `MIN_ROUNDS` —
|
||||
* «сыграйте три раунда» на одной строке было бы обещанием, которого данные не
|
||||
* покрывают.
|
||||
*
|
||||
* СЕКЦИИ БЕЗ ДАННЫХ (`SourcesV3`, `WhatYouGetV3`) РЕНДЕРЯТСЯ ВСЕГДА: их
|
||||
* содержимое — реестр источников и состав результата из `content.ts`, ручек
|
||||
* они не спрашивают. Гасить их вместе с витринными числами значило бы прятать
|
||||
* дисклеймер 135-ФЗ ровно тогда, когда бэкенд лежит.
|
||||
*
|
||||
* МЕТАДАННЫЕ ЖИВУТ В `layout.tsx`, а не здесь. Заголовок и `robots: { index:
|
||||
* false }` там уже ровно те же, что стояли в `v3/page.tsx`; второй экземпляр
|
||||
* на уровне страницы ничего бы не менял, зато мог бы разъехаться с первым.
|
||||
* NOINDEX НЕ СНИМАТЬ вместе с этим переключением: он держится за
|
||||
* плейсхолдерные числа в `marketing-v3.ts` (гейт #2904), а не за то, какой
|
||||
* вариант дизайна стоит в корне.
|
||||
* NOINDEX НЕ СНИМАТЬ вместе с переездом витрины на замеренные числа: маршрут
|
||||
* наружу открывается отдельным решением владельца (гейт
|
||||
* `landing-numbers-gate.test.ts` держит флаг в layout), а не побочным
|
||||
* эффектом того, что плейсхолдеры кончились.
|
||||
*
|
||||
* ЧЕГО ЗДЕСЬ ПО-ПРЕЖНЕМУ НЕТ:
|
||||
* - модалка проверки — сознательно заменена экраном /estimate (см.
|
||||
* StickyCtaV3), все CTA макетного `openModal` ведут туда;
|
||||
* - партнёрская заявка «продажи под ключ» — кнопка ведёт в Telegram
|
||||
* поддержки до появления формы и договора.
|
||||
*
|
||||
* ЧИСЛА в секциях «Точность» и «Цена ошибки» — плейсхолдеры из макета,
|
||||
* см. `marketing-v3.ts`: они обязаны быть заменены вычисляемыми до снятия
|
||||
* `robots: noindex`.
|
||||
*/
|
||||
export default function MeraPublicPage() {
|
||||
|
||||
/**
|
||||
* Окно перегенерации страницы. Нужно отдельно от кэша самих запросов
|
||||
* (`revalidate: 3600` в public-api.ts): сборка идёт в докере, где бэкенда нет,
|
||||
* поэтому в билд страница уезжает БЕЗ витринных блоков. С часовым окном она
|
||||
* такой и стояла бы час после каждого деплоя. Минута — это задержка появления
|
||||
* чисел после выката; нагрузки на бэкенд она не добавляет, перегенерация
|
||||
* переиспользует тот же часовой кэш ответов.
|
||||
*/
|
||||
export const revalidate = 60;
|
||||
|
||||
/** Раундов в игре. Меньше — секция не показывается вовсе. */
|
||||
const MIN_ROUNDS = 3;
|
||||
|
||||
export default async function MeraPublicPage() {
|
||||
const [stats, showcase] = await Promise.all([fetchLandingStats(), fetchShowcase()]);
|
||||
const deals = showcase?.deals ?? [];
|
||||
const rounds = deals.slice(0, MIN_ROUNDS).map(toGameRound);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderV3 />
|
||||
<main id="content" className={styles.main} tabIndex={-1}>
|
||||
<DealsTickerV3 />
|
||||
<HeroV3 />
|
||||
{deals.length > 0 && <DealsTickerV3 deals={deals} />}
|
||||
<HeroV3 stats={stats} />
|
||||
<StepsV3 />
|
||||
<AccuracyV3 />
|
||||
<AccuracyV3 stats={stats} showcase={showcase} />
|
||||
<SourcesV3 />
|
||||
<WhatYouGetV3 />
|
||||
<GuessGameV3 />
|
||||
<CostOfErrorV3 />
|
||||
{rounds.length >= MIN_ROUNDS && <GuessGameV3 rounds={rounds} />}
|
||||
<CostOfErrorV3 stats={stats} />
|
||||
<TwoPathsV3 />
|
||||
<ObjectionsV3 />
|
||||
<ArticlesV3 />
|
||||
|
|
|
|||
|
|
@ -148,3 +148,180 @@ export async function fetchCoverage(
|
|||
signal: options.signal,
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* СЕРВЕРНАЯ ЧАСТЬ: витринные числа лэндинга.
|
||||
*
|
||||
* Всё выше — браузерное (форма оценки). Всё ниже вызывается ТОЛЬКО из
|
||||
* серверных компонентов, поэтому здесь нет "use client" и нет пользовательских
|
||||
* AbortSignal'ов.
|
||||
*
|
||||
* ПОЧЕМУ АДРЕС ДРУГОЙ, ЧЕМ У ФОРМЫ. `fetch` на сервере не знает origin'а —
|
||||
* относительный `/trade-in/api/...` там просто не резолвится. В дереве это уже
|
||||
* решено ровно одним способом: `BACKEND_URL` (next.config.ts rewrites,
|
||||
* `tradein-mvp/docker-compose.prod.yml:471` — комментарий там дословно
|
||||
* «internal SSR»). Тот же env, тот же адрес контейнера: Next ходит в backend
|
||||
* напрямую, минуя Caddy, поэтому префикса `/trade-in` здесь нет — его
|
||||
* добавляет Caddy для браузера, а не бэкенд (роутер примонтирован на
|
||||
* `/api/public/mera`, backend/app/main.py:298).
|
||||
*
|
||||
* КЭШ — `revalidate: 3600`. Числа пересчитывает ночная задача раз в сутки
|
||||
* (app/tasks/landing_stats.py), поэтому `no-store` (запрос на каждый заход
|
||||
* анонима) слал бы бэкенду одинаковые вопросы и упирался бы в его же лимит
|
||||
* 30/окно — на всех посетителей сразу, включая тех, кому нужна форма.
|
||||
* Кэш без срока — другая крайность: пересчёт прошёл, а витрина показывает
|
||||
* вчерашнее до следующего деплоя. Час — это и потолок отставания от ночного
|
||||
* пересчёта, и, что важнее, потолок времени, на который залипает НЕУДАЧА:
|
||||
* Data Cache Next'а статусы не разбирает, так что промах тоже живёт час, но
|
||||
* не дольше.
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const SSR_API_BASE = `${process.env.BACKEND_URL ?? "http://localhost:8000"}/api/public/mera`;
|
||||
|
||||
/** Одна витринная величина. Зеркалит `LandingStat` бэкенда. */
|
||||
export interface LandingStat {
|
||||
value: number | string | null;
|
||||
sample_n: number | null;
|
||||
note: string | null;
|
||||
computed_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ответ `/stats`. Отсутствие ключа — это отсутствие ВЕЛИЧИНЫ: бэкенд не пишет
|
||||
* строку, когда мерить нечего. Ноль на её месте был бы утверждением, которого
|
||||
* никто не измерял, поэтому тип и допускает `undefined` — читать значения
|
||||
* напрямую нельзя, только через `formatStat`.
|
||||
*/
|
||||
export type LandingStats = Readonly<Record<string, LandingStat | undefined>>;
|
||||
|
||||
export interface ShowcaseDeal {
|
||||
district: string | null;
|
||||
rooms: number;
|
||||
area_m2: number;
|
||||
floor: number | null;
|
||||
total_floors: number | null;
|
||||
deal_quarter: string;
|
||||
predicted_rub: number;
|
||||
fact_rub: number;
|
||||
err_pct: number;
|
||||
n_analogs: number;
|
||||
note: string;
|
||||
}
|
||||
|
||||
/** Подпись под витриной: из чего отобраны показанные строки. */
|
||||
export interface ShowcaseStats {
|
||||
considered: number;
|
||||
priced: number;
|
||||
no_prediction: number;
|
||||
incomplete: number;
|
||||
eligible: number;
|
||||
written: number;
|
||||
with_district: number;
|
||||
rejection_rule: string;
|
||||
}
|
||||
|
||||
export interface ShowcaseResponse {
|
||||
computed_at: string | null;
|
||||
deals: ShowcaseDeal[];
|
||||
stats: ShowcaseStats | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET, который НЕ бросает. Любой сбой — недоступный сервис, 500, не-JSON —
|
||||
* это `null`. Альтернатива (проброс исключения) уронила бы серверный рендер
|
||||
* целиком: посетитель получил бы 500 вместо страницы без одного блока.
|
||||
*/
|
||||
async function fetchOrNull<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
const res = await fetch(`${SSR_API_BASE}${path}`, {
|
||||
// Таймаут, а не бесконечное ожидание: висящий бэкенд иначе держит рендер
|
||||
// страницы, и «блок не показался» превращается в «сайт не открывается».
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Витринные метрики. Недоступная ручка = пустой набор, а не набор нулей:
|
||||
* дальше каждый ключ одинаково честно отвечает «величины нет».
|
||||
*/
|
||||
export async function fetchLandingStats(): Promise<LandingStats> {
|
||||
const data = await fetchOrNull<LandingStats>("/stats");
|
||||
return data !== null && typeof data === "object" ? data : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Витрина сделок. `null` = показывать нечего (ручка молчит, пересчёт ещё не
|
||||
* отработал, строк нет) — секция не рендерится. Пустой массив отдельным
|
||||
* состоянием не является: «витрина из нуля строк» на странице не отличима от
|
||||
* поломки, а `stats` без строк подписывать нечего.
|
||||
*/
|
||||
export async function fetchShowcase(): Promise<ShowcaseResponse | null> {
|
||||
const data = await fetchOrNull<ShowcaseResponse>("/showcase");
|
||||
if (data === null || !Array.isArray(data.deals) || data.deals.length === 0) return null;
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Величина, готовая к выводу. Число и размер выборки едут ОДНИМ объектом
|
||||
* намеренно: другого способа получить отформатированное число нет, поэтому
|
||||
* потерять n по дороге в JSX нельзя — можно только осознанно не отрисовать
|
||||
* поле, и это будет видно в разметке.
|
||||
*/
|
||||
export interface FormattedStat {
|
||||
/** Значение с единицей, если её передали: «1 123», «48,1 %». */
|
||||
text: string;
|
||||
/** Подпись выборки — «по 1 123 оценкам». null, если бэкенд не прислал n. */
|
||||
sample: string | null;
|
||||
/** Что именно измерено — формулировкой бэкенда, не нашей. */
|
||||
note: string | null;
|
||||
computedAt: string;
|
||||
}
|
||||
|
||||
const ru = (digits: number) => new Intl.NumberFormat("ru-RU", { maximumFractionDigits: digits });
|
||||
|
||||
/**
|
||||
* Достаёт метрику по ключу. `null` — во ВСЕХ случаях, когда числа нет: ключа
|
||||
* не прислали, `value` пустой, ручка не ответила. Ни ноля, ни прочерка, ни
|
||||
* последнего известного значения — блок вызывающего просто не рендерится.
|
||||
*
|
||||
* @param unit единица, приклеиваемая к числу («%», «дн.») — через неразрывный
|
||||
* пробел, чтобы не отрывалась переносом строки.
|
||||
* @param sampleWord слово подписи выборки («оценкам», «объявлениям»); без него
|
||||
* подпись всё равно будет, но безымянной — «n = 1 123».
|
||||
*/
|
||||
export function formatStat(
|
||||
stats: LandingStats,
|
||||
key: string,
|
||||
{ unit, digits = 2, sampleWord }: { unit?: string; digits?: number; sampleWord?: string } = {},
|
||||
): FormattedStat | null {
|
||||
const stat = stats[key];
|
||||
if (!stat || stat.value === null || stat.value === undefined) return null;
|
||||
|
||||
let text: string;
|
||||
if (typeof stat.value === "number") {
|
||||
if (!Number.isFinite(stat.value)) return null;
|
||||
text = ru(digits).format(stat.value);
|
||||
} else if (typeof stat.value === "string") {
|
||||
if (stat.value.trim() === "") return null;
|
||||
text = stat.value;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
if (unit) text = `${text} ${unit}`;
|
||||
|
||||
const n = stat.sample_n;
|
||||
const sample =
|
||||
typeof n === "number" && Number.isFinite(n)
|
||||
? sampleWord
|
||||
? `по ${ru(0).format(n)} ${sampleWord}`
|
||||
: `n = ${ru(0).format(n)}`
|
||||
: null;
|
||||
|
||||
return { text, sample, note: stat.note ?? null, computedAt: stat.computed_at };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue