ПУТЬ 1 · САМОСТОЯТЕЛЬНО
-
Отчёт за {REPORT_PRICE}
+
{`Отчёт за ${SERVICE_PRICE_RUB} ₽`}
Цена, срок, аналоги, факторы. Дальше вы ведёте продажу сами — на
руках все цифры, чтобы уверенно держать свою цену в переговорах.
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts
new file mode 100644
index 00000000..33a868ae
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts
@@ -0,0 +1,98 @@
+/**
+ * Форматирование строк витрины `/showcase` — общее для ленты, таблицы сверок и
+ * игры. Держится в одном месте, потому что «объект» в этих данных описывается
+ * ТОЛЬКО так: комнаты, площадь, этаж, район (может отсутствовать). Улицы и
+ * дома в данных нет вовсе — номер дома известен у 2.7% сделок, — и три
+ * компонента, каждый со своей склейкой, рано или поздно разъехались бы в том,
+ * что именно они выдают за адрес.
+ */
+
+import type { ShowcaseDeal } from "../../public-api";
+
+const RUB = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 });
+const PCT = new Intl.NumberFormat("ru-RU", {
+ maximumFractionDigits: 1,
+ signDisplay: "exceptZero",
+});
+const AREA = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 1 });
+
+export const rub = (value: number): string => `${RUB.format(value)} ₽`;
+
+/** Счётчик прогона витрины — тем же разделителем тысяч, что и суммы. */
+export const count = (value: number): string => RUB.format(value);
+
+/** Отклонение прогноза от факта — со знаком: плюс = МЕРА назвала дороже. */
+export const errPct = (value: number): string => `${PCT.format(value)} %`;
+
+/** «2-к, 52 м²» — всё, что про объект известно наверняка. */
+export const dealTitle = (deal: ShowcaseDeal): string =>
+ `${deal.rooms}-к, ${AREA.format(deal.area_m2)} м²`;
+
+/**
+ * Район / этаж / число аналогов через точку. Отсутствующие части ВЫПАДАЮТ, а
+ * не заменяются прочерком или правдоподобной подстановкой: `district` и
+ * `floor` в ДКП-данных бывают пустыми.
+ */
+export function dealMeta(deal: ShowcaseDeal): string {
+ const parts: string[] = [];
+ if (deal.district) parts.push(deal.district);
+ if (deal.floor !== null) {
+ parts.push(deal.total_floors !== null ? `${deal.floor}/${deal.total_floors} этаж` : `${deal.floor} этаж`);
+ }
+ parts.push(`аналогов: ${deal.n_analogs}`);
+ return parts.join(" · ");
+}
+
+/** Раунд игры «Угадай цену» — та же сделка витрины, подготовленная к слайдеру. */
+export interface GameRound {
+ readonly key: string;
+ /** «2-к, 52 м²». */
+ readonly title: string;
+ readonly meta: string;
+ /** Район или прямое «район не указан» — подстановки нет. */
+ readonly districtLabel: string;
+ readonly quarter: string;
+ /** Границы слайдера, млн ₽. */
+ readonly min: number;
+ readonly max: number;
+ readonly step: number;
+ readonly start: number;
+ /** Прогноз МЕРЫ и факт ДКП, млн ₽. */
+ readonly mera: number;
+ readonly fact: number;
+ /** Отклонение прогноза от факта ИМЕННО этой сделки. */
+ readonly errPctText: string;
+}
+
+const MLN = 1_000_000;
+const STEP_MLN = 0.05;
+/** Полуширина коридора слайдера вокруг сделки — чтобы ответ не лежал на краю. */
+const SPAN = 0.35;
+
+const toHalf = (v: number, up: boolean): number => (up ? Math.ceil(v * 2) / 2 : Math.floor(v * 2) / 2);
+
+/**
+ * Границы слайдера считаются от самой сделки — другого масштаба у раунда нет.
+ * Старт кладётся в СЕРЕДИНУ коридора, а не в прогноз МЕРЫ: иначе первое же
+ * положение ручки выдавало бы ответ, который игра предлагает угадать.
+ */
+export function toGameRound(deal: ShowcaseDeal, index: number): GameRound {
+ const mera = deal.predicted_rub / MLN;
+ const fact = deal.fact_rub / MLN;
+ const min = Math.max(toHalf(Math.min(mera, fact) * (1 - SPAN), false), STEP_MLN);
+ const max = toHalf(Math.max(mera, fact) * (1 + SPAN), true);
+ return {
+ key: `${deal.deal_quarter}-${index}`,
+ title: dealTitle(deal),
+ meta: dealMeta(deal),
+ districtLabel: deal.district ?? "район не указан",
+ quarter: deal.deal_quarter,
+ min,
+ max,
+ step: STEP_MLN,
+ start: Math.round((min + max) / 2 / STEP_MLN) * STEP_MLN,
+ mera,
+ fact,
+ errPctText: errPct(deal.err_pct),
+ };
+}
diff --git a/tradein-mvp/frontend/src/app/mera-public/articles/articles-content.ts b/tradein-mvp/frontend/src/app/mera-public/articles/articles-content.ts
index da7996db..e4941909 100644
--- a/tradein-mvp/frontend/src/app/mera-public/articles/articles-content.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/articles/articles-content.ts
@@ -20,9 +20,11 @@
*
* Поэтому §1 и §4 стоят на СВОЁМ замере (см. поля `source` — там основание и
* его границы), кадастровое число снято, а поправки §3 честно помечены как
- * рыночная практика, а не наш замер. Плейсхолдеров marketing-v3 в статье
- * больше нет — но строка `articles/articles-content.ts` в `V3_SOURCES` гейта
- * #2904 оставлена намеренно: вернётся плейсхолдер — гейт его увидит.
+ * рыночная практика, а не наш замер. Модуля `marketing-v3.ts` с числами из
+ * макета больше не существует вовсе (29.08.2026): величины лэндинга уехали в
+ * `/stats`, `/showcase` и `landing-facts.ts`. Гейт `landing-numbers-gate` этот
+ * файл намеренно НЕ сканирует — здесь числа живут в связном тексте, и у
+ * каждого блока стоит своё поле `source`/`footnote` с датой и выборкой.
*
* ЗАМЕР, НА КОТОРОМ СТОИТ ТЕКСТ. 6202 объявления квартир Домклик в
* Екатеринбурге, срез 18.07.2026, парно «цена публикации → цена на дату
diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts
new file mode 100644
index 00000000..b718b2c1
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts
@@ -0,0 +1,94 @@
+/**
+ * landing-facts — величины лэндинга, которые НЕ приходят из `/stats`, но
+ * измерены. Пришло на смену `marketing-v3.ts` (числа из дизайн-макета).
+ *
+ * ЧЕМ ЭТОТ ФАЙЛ ОТЛИЧАЕТСЯ ОТ ПРЕДЫДУЩЕГО. В `marketing-v3.ts` лежали
+ * продающие формулировки, и шапка честно признавала, что ни одна из них не
+ * подтверждена замером. Здесь наоборот: каждая запись — результат конкретного
+ * запроса к боевой базе, у неё есть размер выборки и оговорки, без которых
+ * число врёт. Записи без `source`/`sampleN` завести нельзя — тип не даст, а
+ * гейт `__tests__/landing-numbers-gate.test.ts` проверяет, что поля не пустые.
+ *
+ * ПОЧЕМУ ЭТО НЕ В `/stats`. Всё, что считается ночной задачей
+ * (`app/tasks/landing_stats.py`), фронт берёт через `fetchLandingStats()` —
+ * так число не может отстать от базы. Сверка прогноза с ценой ДКП такой
+ * задачи пока не имеет: это разовый замер владельца на проде, и до появления
+ * регулярного пересчёта честнее держать его здесь, вместе с датой замера,
+ * чем показывать «свежим» на витрине.
+ *
+ * ЧЕГО ЗДЕСЬ НЕТ. Точности прогноза по СРОКУ продажи: `deals.days_on_market`
+ * заполнен 0 раз из 108 623, сверять прогноз срока не с чем. На витрине её
+ * место занимает `listing_age_median_days` из `/stats` — экспозиция активного
+ * объявления, и она названа своим именем, без слова «точность».
+ * Счётчика «сверено с фактом сделки клиента» тоже нет: контура такой сверки в
+ * продукте не существует (`trade_in_leads` — 4 строки без полей исхода).
+ */
+
+/**
+ * Замеренная величина витрины.
+ *
+ * `caveats` — не украшение: у обоих чисел бэктеста есть границы, без которых
+ * они читаются лучше, чем есть. Рендерятся рядом со значением (AccuracyV3),
+ * а не в комментарии.
+ */
+export interface MeasuredValue {
+ /** Значение с единицей, готовое к выводу. */
+ readonly text: string;
+ /** Размер выборки, по которой значение посчитано. */
+ readonly sampleN: number;
+ /** Откуда взято: запрос, база, дата замера. */
+ readonly source: string;
+ /** Оговорки, без которых величина вводит в заблуждение. */
+ readonly caveats: readonly string[];
+}
+
+/** Общий источник трёх величин ниже — один и тот же прогон сверки. */
+const BACKTEST_SOURCE =
+ "Ручная сверка на проде (poincare, 29.08.2026): прогноз МЕРЫ против цены ДКП " +
+ "Росреестра по Екатеринбургу, сделки с 06.2025";
+
+/**
+ * Сверка «прогноз → цена ДКП». Три величины идут КОМПЛЕКТОМ и показываются
+ * вместе: попадание в коридор без ширины коридора и без уровня уверенности
+ * читается как точность, которой нет.
+ */
+export const BACKTEST: Readonly> = {
+ priceError: {
+ text: "14,5 %",
+ sampleN: 327,
+ source: BACKTEST_SOURCE,
+ caveats: [
+ "Прогноз считается по объявлениям, активным НА МОМЕНТ РАСЧЁТА, а сделка — прошлая: " +
+ "это не point-in-time замер. Честный будет не раньше конца 2026 года по календарю данных.",
+ "Цена ДКП бывает занижена ради налога — она нижняя граница правды, а не сама правда.",
+ ],
+ },
+ coverage: {
+ text: "88 %",
+ sampleN: 327,
+ source: BACKTEST_SOURCE,
+ caveats: [
+ "Попадание достигнуто шириной коридора ±37 %, а не точностью точки: " +
+ "коридор такой ширины накрывает почти любую сделку.",
+ ],
+ },
+ confidenceLow: {
+ text: "325 из 327",
+ sampleN: 327,
+ source: BACKTEST_SOURCE,
+ caveats: ["Оценки «уверенность высокая» в выборке не встретилось ни разу."],
+ },
+};
+
+/** Ширина коридора — та же сверка; стоит в подписи к `coverage`, не отдельной плиткой. */
+export const BACKTEST_CORRIDOR_LABEL = "±37 %";
+
+/** Окно сделок бэктеста. Окно РАСЧЁТОВ — другое, оно приходит из `/stats`. */
+export const BACKTEST_PERIOD_LABEL = "сделки с июня 2025 года";
+
+/**
+ * Бейдж первого шага. Раньше здесь стояло «30 секунд» — замера времени
+ * заполнения формы нет и не было. Число полей формы, наоборот, проверяется
+ * глазами в `FreeCheckCard.tsx`: адрес, площадь, комнаты, этаж, состояние.
+ */
+export const STEP1_FIELDS_LABEL = "5 полей";
diff --git a/tradein-mvp/frontend/src/app/mera-public/marketing-v3.ts b/tradein-mvp/frontend/src/app/mera-public/marketing-v3.ts
deleted file mode 100644
index 866c05ab..00000000
--- a/tradein-mvp/frontend/src/app/mera-public/marketing-v3.ts
+++ /dev/null
@@ -1,249 +0,0 @@
-/**
- * marketing-v3 — ПЛЕЙСХОЛДЕРНЫЕ маркетинговые величины лэндинга (макет v3).
- *
- * ⚠️ ЧИТАТЬ ПЕРЕД ИСПОЛЬЗОВАНИЕМ. Ни одно число в этом файле не подтверждено
- * замером продукта. Они взяты из дизайн-макета владельца (15.08.2026) как
- * продающие формулировки и держатся ЗДЕСЬ, в одном месте, ровно для того,
- * чтобы их можно было заменить одной правкой, когда появятся вычисляемые
- * метрики.
- *
- * Что говорят наши собственные замеры на ту же тему (зафиксировано, чтобы
- * расхождение не потерялось — issue «compliance: заменить маркетинговые
- * метрики лэндинга на вычисляемые до снятия noindex»):
- *
- * | здесь | факт по замеру |
- * |------------------------|-----------------------------------------------|
- * | ошибка 1,8% | 13,2% медиана |%| (бэктест n=277), held-out 16,1% |
- * | 83% в диапазон | 81,95% — но коридор шириной 74% от точки |
- * | ±6 дн по сроку | сверки срока с фактом НЕ СУЩЕСТВУЕТ |
- * | 42 700 проверок за год | 1 084 оценки всего (база живёт 2,5 месяца) |
- * | 42 города РФ | 6 городов области, полное покрытие — 1 (ЕКБ) |
- *
- * ГЕЙТ: пока `robots: { index: false }` в `layout.tsx` стоит и домен не открыт
- * наружу, эти строки никому не показываются. ПЕРЕД снятием noindex каждая
- * величина обязана быть либо заменена вычисляемой, либо снята со страницы —
- * публикация недостоверных характеристик платной услуги подпадает под ст. 5
- * ФЗ «О рекламе» и ЗоЗПП.
- *
- * Правило для новых секций: не вписывай число в JSX руками — заведи константу
- * здесь. Иначе замена «одной правкой» перестанет работать ровно тогда, когда
- * понадобится.
- */
-
-/** Точность прогноза — блок «Точность» и бейдж героя. */
-export const ACCURACY_PLACEHOLDER = {
- /** Средняя ошибка по цене. */
- priceError: "1,8%",
- priceErrorNote: "медиана расхождения прогноза и цены сделки",
- /** Точность по сроку продажи. */
- daysError: "±6 дн.",
- daysErrorNote: "отклонение факта от прогнозного окна",
- /** Доля сделок внутри рекомендованного коридора. */
- coverage: "83%",
- coverageNote: "цена сделки внутри рекомендованного коридора",
- /** Объём проверок за год. */
- checks: "42 700",
- checksNote: "из них 3 180 доведены до сделки и сверены",
-} as const;
-
-/** Блок «Почему это важно» — цена ошибки в цене. */
-export const COST_OF_ERROR_PLACEHOLDER = [
- {
- value: "−4,3%",
- title: "теряет цена за каждый месяц простоя",
- text: "Покупатели видят «зависшее» объявление и начинают торговаться сильнее.",
- },
- {
- value: "×2,4",
- title: "дольше продаётся квартира с завышенной ценой",
- text: "Даже +7% к рыночной цене выбивают вас из выдачи по фильтрам покупателей.",
- },
- {
- value: "150 ₽",
- title: "стоит узнать правильную цену заранее",
- text: "Один платёж. Без подписки. Отчёт остаётся у вас по ссылке.",
- },
-] as const;
-
-/**
- * Пример бесплатной части в макете. Это ИЛЛЮСТРАЦИЯ на статичной странице, а
- * не расчёт по введённому адресу: анонимного расчёта сегодня нет вовсе
- * (`PUBLIC_ESTIMATE_ENABLED = false`, ручки закрыты RBAC).
- *
- * Как только появится `POST /trade-in/coverage`, эти значения обязаны уехать —
- * показывать одинаковые «14 объявлений» любому, кто ввёл свой адрес, это уже
- * не продающая формулировка, а обман конкретного человека.
- */
-export const FREE_SAMPLE_PLACEHOLDER = {
- analogs: "14",
- analogsLabel: "похожих объявлений рядом",
- days: "47 дн.",
- daysLabel: "средний срок продажи",
-} as const;
-
-/** Отчётный период сводки в блоке «Точность» (лид над KPI-плитками, id="accuracy"). */
-export const ACCURACY_PERIOD_LABEL = "последние 12 месяцев";
-
-/** Заявленное время на шаг 1 («описываете квартиру») — бейдж в блоке «Как это работает». */
-export const STEP1_DURATION_LABEL = "30 секунд";
-
-/**
- * Строки таблицы сверок в блоке «Точность» (id="accuracy"). Это ИЛЛЮСТРАЦИЯ
- * формата отчёта из дизайн-макета (адреса, суммы и расхождения придуманы для
- * примера), а не реальные сделки — макет не содержал заполненных данных для
- * этой таблицы (только шаблон `{{ r.addr }}` и т.д.), реальных проверок под
- * ней нет. `checks`/`err`/`gain` здесь НЕ обязаны биться с
- * `ACCURACY_PLACEHOLDER` арифметически — это витринные примеры, а не выборка,
- * из которой посчитана средняя ошибка.
- *
- * Как и весь файл: до снятия noindex каждая строка обязана быть либо заменена
- * реальной сверенной сделкой, либо явно помечена как иллюстрация в самой
- * вёрстке (см. подпись под таблицей в AccuracyV3.tsx).
- */
-export interface ProofRow {
- /** Адрес/район объекта. */
- readonly addr: string;
- /** Тип, площадь, этаж. */
- readonly meta: string;
- /** Прогноз «Меры». */
- readonly said: string;
- /** Фактическая цена сделки. */
- readonly fact: string;
- /** Расхождение прогноза и факта, %. */
- readonly err: string;
- /** Превышение над средней ценой по району, ₽. */
- readonly gain: string;
- /** То же превышение, %. */
- readonly gainPct: string;
-}
-
-export const PROOF_ROWS_PLACEHOLDER: readonly ProofRow[] = [
- {
- addr: "Втузгородок, ул. Ясная, 8",
- meta: "1-к, 34 м², 6/16 этаж",
- said: "3 640 000 ₽",
- fact: "3 705 000 ₽",
- err: "1,8%",
- gain: "+165 000 ₽",
- gainPct: "+4,7%",
- },
- {
- addr: "Юго-Западный, ул. Юмашева, 15",
- meta: "2-к, 52 м², 3/9 этаж",
- said: "5 210 000 ₽",
- fact: "5 180 000 ₽",
- err: "0,6%",
- gain: "+96 000 ₽",
- gainPct: "+1,9%",
- },
- {
- addr: "Ботанический, ул. Академика Бардина, 23",
- meta: "3-к, 71 м², 8/16 этаж",
- said: "7 480 000 ₽",
- fact: "7 640 000 ₽",
- err: "2,1%",
- gain: "+310 000 ₽",
- gainPct: "+4,2%",
- },
- {
- addr: "Уралмаш, ул. Кировградская, 42",
- meta: "2-к, 46 м², 5/5 этаж",
- said: "4 120 000 ₽",
- fact: "4 065 000 ₽",
- err: "1,3%",
- gain: "+58 000 ₽",
- gainPct: "+1,4%",
- },
- {
- addr: "Центр, ул. Малышева, 51",
- meta: "1-к, 38 м², 11/25 этаж",
- said: "6 350 000 ₽",
- fact: "6 490 000 ₽",
- err: "2,2%",
- gain: "+215 000 ₽",
- gainPct: "+3,4%",
- },
-] as const;
-
-// ---------------------------------------------------------------------------
-// Дополнение по макету 29.08 («B2C модуль для МЕРА», Макс) — секции «лента
-// сделок», «игра "Угадай цену"», «возражения», «статьи». Тот же контракт, что
-// у величин выше: ничего из этого НЕ подтверждено замером, контура сверки
-// «прогноз → факт» в продукте не существует (см. шапку модуля и гейт-тест) —
-// поэтому данные живут здесь, под noindex, до появления вычисляемых.
-// ---------------------------------------------------------------------------
-
-/** Раунд мини-игры «Угадай цену»: реквизит из макета, НЕ реальная сделка. */
-export interface GameRound {
- readonly district: string;
- readonly addr: string;
- readonly meta: string;
- readonly tags: readonly string[];
- readonly question: string;
- readonly hint: string;
- /** Слайдер: млн ₽. */
- readonly min: number;
- readonly max: number;
- readonly step: number;
- readonly start: number;
- /** «МЕРА сказала» и «факт сделки», млн ₽ — числа из макета. */
- readonly mera: number;
- readonly fact: number;
- /** Позиция точки на декоративной карте, в процентах. */
- readonly dotX: number;
- readonly dotY: number;
-}
-
-export const GAME_ROUNDS_PLACEHOLDER: readonly GameRound[] = [
- {
- district: "ЦЕНТРАЛЬНЫЙ Р-Н",
- addr: "1-комнатная, 34 м²",
- meta: "Центральный р-н · 4/9 этаж · панель",
- tags: ["ХОРОШЕЕ СОСТОЯНИЕ", "9 АНАЛОГОВ", "БЛИЗКО К МЕТРО"],
- question: "Какую цену поставить, чтобы продать за 30 дней?",
- hint: "Продано в марте 2026. Ваш ответ — в миллионах рублей.",
- min: 4.5,
- max: 7.5,
- step: 0.05,
- start: 5.4,
- mera: 5.55,
- fact: 5.5,
- dotX: 32,
- dotY: 42,
- },
- {
- district: "ЮГО-ЗАПАД",
- addr: "2-комнатная, 52 м²",
- meta: "Юго-Западный р-н · 7/16 этаж · монолит",
- tags: ["КОСМЕТИКА", "14 АНАЛОГОВ", "ДВОР БЕЗ МАШИН"],
- question: "За сколько её реально купили?",
- hint: "Продано в мае 2026. Ваш ответ — в миллионах рублей.",
- min: 5.5,
- max: 9.5,
- step: 0.05,
- start: 7.2,
- mera: 7.4,
- fact: 7.55,
- dotX: 58,
- dotY: 61,
- },
- {
- district: "УРАЛМАШ",
- addr: "3-комнатная, 74 м²",
- meta: "Орджоникидзевский р-н · 2/5 этаж · кирпич",
- tags: ["ТРЕБУЕТ РЕМОНТА", "6 АНАЛОГОВ", "ТИХИЙ ДВОР"],
- question: "Какая цена ушла бы за 45 дней?",
- hint: "Продано в июне 2026. Ваш ответ — в миллионах рублей.",
- min: 4.0,
- max: 8.0,
- step: 0.05,
- start: 6.0,
- mera: 5.85,
- fact: 5.95,
- dotX: 44,
- dotY: 28,
- },
-];
-
-/** Ошибка МЕРЫ в финале игры — та же величина, что в бейдже героя. */
-export const GAME_MERA_ERROR_LABEL = "1,8%";
diff --git a/tradein-mvp/frontend/src/app/mera-public/v3/page.tsx b/tradein-mvp/frontend/src/app/mera-public/v3/page.tsx
index 5a50b038..655a33a9 100644
--- a/tradein-mvp/frontend/src/app/mera-public/v3/page.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/v3/page.tsx
@@ -12,6 +12,8 @@ import { ObjectionsV3 } from "../_components/v3/ObjectionsV3";
import { StepsV3 } from "../_components/v3/StepsV3";
import { StickyCtaV3 } from "../_components/v3/StickyCtaV3";
import { TwoPathsV3 } from "../_components/v3/TwoPathsV3";
+import { toGameRound } from "../_components/v3/deal-view";
+import { fetchLandingStats, fetchShowcase } from "../public-api";
import styles from "../landing.module.css";
/**
@@ -24,22 +26,24 @@ import styles from "../landing.module.css";
* на одном и том же контенте. Переключение — отдельным решением, когда
* версия принята.
*
- * ОБНОВЛЕНИЕ 29.08 (макет «B2C модуль», Макс): добавлены лента
- * «прогноз → факт», игра «Угадай цену», «Два пути», «Возражения» и тизер
- * статей. Данные ленты/игры — реквизит из marketing-v3.ts под тем же гейтом
- * #2904 (контура сверки прогноза с фактом в продукте по-прежнему нет).
+ * ОТКУДА ЧИСЛА (29.08.2026). Страница стала серверно-асинхронной: витринные
+ * величины приходят из `/api/public/mera/stats` и `/showcase`, а не из
+ * литералов. Обе ручки НЕ бросают: недоступный бэкенд даёт пустой набор и
+ * `null`, и тогда блок, которому нечего показать, просто не рендерится —
+ * ни нуля, ни прочерка, ни прошлого значения. Единственные числа, живущие в
+ * коде, — разовая сверка прогноза с ценой ДКП (`landing-facts.ts`), у неё там
+ * записаны выборка, источник и оговорки.
+ *
+ * ЛЕНТА И ИГРА СТОЯТ НА ТЕХ ЖЕ СДЕЛКАХ, что и таблица сверок: это один набор
+ * `/showcase`, а не три. Игра не рендерится, пока сделок меньше `MIN_ROUNDS` —
+ * «сыграйте три раунда» на одной строке было бы обещанием, которого данные не
+ * покрывают.
*
* ЧЕГО ЗДЕСЬ ПО-ПРЕЖНЕМУ НЕТ:
- * - страницы статей («МЕРА статьи»/«МЕРА статья») — карточки тизера
- * рендерятся без ссылок до появления раздела;
* - модалка проверки — сознательно заменена экраном /estimate (см.
* StickyCtaV3), все CTA макетного `openModal` ведут туда;
* - партнёрская заявка «продажи под ключ» — кнопка ведёт в Telegram
* поддержки до появления формы и договора.
- *
- * ЧИСЛА в секции «Точность» и «Цена ошибки» — плейсхолдеры из макета,
- * см. `marketing-v3.ts`: они обязаны быть заменены вычисляемыми до снятия
- * `robots: noindex`.
*/
export const metadata: Metadata = {
@@ -47,20 +51,37 @@ export const metadata: Metadata = {
robots: { index: false, follow: false },
};
-export default function MeraPublicV3Page() {
+/**
+ * Окно перегенерации страницы. Нужно отдельно от кэша самих запросов
+ * (`revalidate: 3600` в public-api.ts): сборка идёт в докере, где бэкенда нет,
+ * поэтому в билд страница уезжает БЕЗ витринных блоков. С часовым окном она
+ * такой и стояла бы час после каждого деплоя. Минута — это задержка появления
+ * чисел после выката; нагрузки на бэкенд она не добавляет, перегенерация
+ * переиспользует тот же часовой кэш ответов.
+ */
+export const revalidate = 60;
+
+/** Раундов в игре. Меньше — секция не показывается вовсе. */
+const MIN_ROUNDS = 3;
+
+export default async function MeraPublicV3Page() {
+ const [stats, showcase] = await Promise.all([fetchLandingStats(), fetchShowcase()]);
+ const deals = showcase?.deals ?? [];
+ const rounds = deals.slice(0, MIN_ROUNDS).map(toGameRound);
+
return (
<>
-
-
+ {deals.length > 0 && }
+
-
-
-
-
-
-
+
+ {rounds.length >= MIN_ROUNDS && }
+
+
+
+