(null);
+ // Гард на однократную отправку `address_input_start` за визит страницы —
+ // без него цель улетала бы на каждую введённую букву (см. onChange ниже).
+ const addressStartSent = useRef(false);
// Незавершённый запрос покрытия при уходе со страницы отменяем — иначе
// setState прилетает в размонтированный компонент.
@@ -206,6 +222,7 @@ export function EstimateFlow() {
// дом из подсказок. Теперь говорим прямо.
setSuggestions([]);
setSuggestFailed(true);
+ track("suggest_failed");
});
}, SUGGEST_DEBOUNCE_MS);
@@ -216,6 +233,13 @@ export function EstimateFlow() {
}, [query, city, picked]);
const choose = useCallback((item: AddressSuggestion) => {
+ // Координаты приходят из самого объекта подсказки — проверяем на всякий
+ // случай (а не доверяем типу вслепую): аналитике важно отличить реальный
+ // выбор дома от строки без геокода, если контракт саджеста когда-нибудь
+ // ослабят.
+ track("address_picked", {
+ has_coords: Number.isFinite(item.lat) && Number.isFinite(item.lon),
+ });
setPicked(item);
setQuery(item.full_address);
setSuggestions([]);
@@ -249,6 +273,7 @@ export function EstimateFlow() {
// геокодирует (см. CoverageProbeInput на бэке). Набранная руками строка
// без выбора из списка — это адрес без координат, считать по нему нечего.
if (!picked) {
+ track("validation_error", { field: "address" });
setFieldError("address");
setPhase(FORM);
addressRef.current?.focus();
@@ -257,6 +282,7 @@ export function EstimateFlow() {
const areaValue = Number.parseFloat(area.replace(",", "."));
if (!Number.isFinite(areaValue) || areaValue <= 10 || areaValue >= 500) {
+ track("validation_error", { field: "area" });
setFieldError("area");
setPhase(FORM);
areaRef.current?.focus();
@@ -284,8 +310,20 @@ export function EstimateFlow() {
},
{ signal: controller.signal },
)
- .then((probe) => setPhase({ kind: "result", verdict: describeCoverage(probe) }))
- .catch((error) => setPhase({ kind: "failed", ...failureCopy(error) }))
+ .then((probe) => {
+ const verdict = describeCoverage(probe);
+ // Три цели вместо одной «результат получен»: продукту важно видеть
+ // не только факт ответа, а какой именно вердикт видит человек — это
+ // и есть числитель/знаменатель конверсии в платный шаг.
+ if (verdict.tone === "none") track("free_result_none");
+ else if (verdict.tone === "thin") track("free_result_thin");
+ else track("free_result_ok");
+ setPhase({ kind: "result", verdict });
+ })
+ .catch((error) => {
+ track("calc_error", { kind: calcErrorKind(error) });
+ setPhase({ kind: "failed", ...failureCopy(error) });
+ })
.finally(() => clearTimeout(timeout));
}
@@ -361,6 +399,10 @@ export function EstimateFlow() {
// человек поправит номер дома, а посчитается прежний.
setPicked(null);
setFieldError(null);
+ if (!addressStartSent.current) {
+ addressStartSent.current = true;
+ track("address_input_start", { place: "estimate" });
+ }
}}
onKeyDown={handleAddressKeyDown}
/>
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/estimate/ReportResult.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/estimate/ReportResult.tsx
index 8e9d73c4..3013abeb 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/estimate/ReportResult.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/estimate/ReportResult.tsx
@@ -1,3 +1,5 @@
+"use client";
+
/**
* ReportResult — результат пробы покрытия в облике «отчёта» (макет 29.08,
* «МЕРА отчёт.dc.html»): шапка объекта с чипами параметров, бесплатные
@@ -40,6 +42,9 @@
* бесплатные плитки.
*/
+import { useEffect, useRef } from "react";
+
+import { track } from "../../_lib/analytics";
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";
@@ -193,6 +198,22 @@ function PaidTeaser() {
}
export function ReportResult({ verdict, object, onEdit }: Props) {
+ // Гард от двойного вызова эффекта в React StrictMode (dev-режим монтирует
+ // эффекты дважды нарочно, чтобы ловить не-идемпотентные side-effect'ы) —
+ // без него цель `paid_teaser_shown` в dev считалась бы вдвое чаще прода.
+ const paidTeaserSent = useRef(false);
+
+ useEffect(() => {
+ if (!verdict.offerPaidStep || paidTeaserSent.current) return;
+ paidTeaserSent.current = true;
+ // Цели «перешёл к оплате» здесь намеренно нет: кнопки оплаты в коде не
+ // существует (PAYMENTS_ENABLED=false, вызова checkout во фронте нет, см.
+ // PaidTeaser ниже — там только «приём оплаты подключается»). Эта цель
+ // фиксирует только показ тизера; конверсионная цель появится вместе с
+ // кнопкой, когда платёжный контур будет готов (#2896).
+ track("paid_teaser_shown");
+ }, [verdict.offerPaidStep]);
+
return (
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CookieBanner.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CookieBanner.tsx
new file mode 100644
index 00000000..055ddda2
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CookieBanner.tsx
@@ -0,0 +1,134 @@
+"use client";
+
+/**
+ * CookieBanner — уведомляющая (НЕ блокирующая) нижняя плашка о cookie и
+ * веб-аналитике. Показывается один раз новому посетителю публичного сайта,
+ * пока он не нажмёт «Понятно»; при повторном визите с тем же браузером уже
+ * не появляется.
+ *
+ * ПОЧЕМУ УВЕДОМЛЯЮЩАЯ, А НЕ БЛОКИРУЮЩАЯ (осознанное решение, практика РФ).
+ * В отличие от GDPR-юрисдикций, 152-ФЗ и практика Роскомнадзора не требуют
+ * предварительного opt-in согласия ДО того, как счётчик статистики поставит
+ * cookie — требование в другом: посетителя нужно УВЕДОМИТЬ о сборе (см. новый
+ * раздел «Файлы cookie и веб-аналитика», `/privacy#cookies`). Поэтому
+ * `AnalyticsScripts` (Яндекс.Метрика + GA4, монтируется параллельно в том же
+ * layout.tsx) грузится сразу и не ждёт клика по этой плашке — баннер только
+ * информирует и даёт ссылку на полный текст политики, он не gate для скриптов
+ * и не должен им становиться.
+ *
+ * ПОЧЕМУ ЧТЕНИЕ localStorage ТОЛЬКО В useEffect. Первый рендер компонента —
+ * серверный (SSR), а `localStorage` там не существует. Прочитать его прямо в
+ * теле функции значило бы получить на клиенте другой HTML, чем отрисовал
+ * сервер, — React пометит это как hydration mismatch. Поэтому до первого
+ * эффекта компонент рендерит `null` (см. `show` ниже): для уже согласившегося
+ * посетителя это значит, что баннер не мигнёт на экране перед тем, как скрипт
+ * решит его спрятать, — его не будет с самого начала, а не «был и тут же
+ * исчез».
+ *
+ * `try/catch` вокруг обращений к localStorage — приватный режим Safari и
+ * часть блокировщиков бросают исключение на любое обращение к Storage API.
+ * Тогда согласие считается непоказанным (баннер появится) либо, при ошибке
+ * на записи, просто не запомнится — деградация мягкая, крашить страницу из-за
+ * недоступного стораджа нельзя.
+ *
+ * СОСЕДСТВО СО StickyCtaV3 (тоже фиксированная нижняя панель: `.barRoot`,
+ * z-index 60, высота 68px = `.barSpacer` в landing-v3.module.css).
+ * CookieBanner монтируется в `layout.tsx`, поэтому виден на ВСЕХ страницах
+ * публичного сайта, а StickyCtaV3 — только на лендинге (см. докстринг
+ * layout.tsx: «лэндинг берёт HeaderV3/FooterV3/StickyCtaV3»), значит
+ * физически пересечься они могут только там. Развожу их по вертикали, а не
+ * по z-index: `.ckbRoot` стоит с `bottom: 68px` — ровно НАД липкой
+ * CTA-панелью. Это верно в любом состоянии StickyCtaV3 (видна она сейчас или
+ * спрятана transform'ом через `.barHidden`) и на страницах, где её вовсе нет,
+ * — там просто остаётся пустой отступ снизу. Альтернатива (спрашивать у
+ * StickyCtaV3 её текущую высоту/видимость через общий стейт) добавила бы
+ * связность между двумя независимыми клиентскими компонентами ради проблемы,
+ * которой не было ни разу — договорились не делать.
+ *
+ * ССЫЛКА «ПОДРОБНЕЕ» — ОБЫЧНЫЙ `
`, НЕ `PublicLink`. `PublicLink` типизирован
+ * так, что принимает только ключ `PUBLIC_ROUTES` и рендерит `href` БЕЗ
+ * возможности дописать хвост (`href` намеренно исключён из его пропсов, см.
+ * докстринг `PublicLink.tsx`) — выразить «/privacy#cookies» через него нельзя.
+ * Адрес ниже собран из `PUBLIC_ROUTES.privacy` (не литерала `"/privacy"`),
+ * чтобы не заводить второй источник правды на путь страницы политики.
+ */
+
+import { useEffect, useState } from "react";
+
+import styles from "../../landing-v3.module.css";
+
+import { PUBLIC_ROUTES } from "../../content";
+
+/**
+ * Ключ localStorage. Версия в имени — если текст уведомления когда-нибудь
+ * изменится по существу (например, добавится ещё один сервис аналитики или
+ * сменится цель обработки), можно завести `_v2` и показать баннер заново
+ * всем, кто согласился со старой версией текста.
+ */
+const STORAGE_KEY = "mera_cookie_notice_v1";
+
+export function CookieBanner() {
+ // Рендерить плашку в DOM или нет — узнаём только из эффекта (см. докстринг
+ // выше про SSR). До этого — `false`, то есть ничего не рендерим.
+ const [show, setShow] = useState(false);
+ // Плашку по клику не удаляем из DOM, а скрываем transform'ом — тот же
+ // приём, что в StickyCtaV3 (`.barHidden`): избегаем display:none ради
+ // анимации, которая идёт на композиторе, а не пересчитывает layout.
+ const [dismissed, setDismissed] = useState(false);
+
+ useEffect(() => {
+ try {
+ const stored = window.localStorage.getItem(STORAGE_KEY);
+ if (stored !== "1") {
+ setShow(true);
+ }
+ } catch {
+ // Storage недоступен (приватный режим / блокировщик) — считаем, что
+ // согласия ещё не было, и показываем баннер.
+ setShow(true);
+ }
+ }, []);
+
+ function handleDismiss() {
+ try {
+ window.localStorage.setItem(STORAGE_KEY, "1");
+ } catch {
+ // Не смогли запомнить выбор — не критично: баннер просто появится
+ // снова при следующем визите с этого же браузера.
+ }
+ setDismissed(true);
+ }
+
+ if (!show) {
+ return null;
+ }
+
+ return (
+
+
+
+ Мы используем файлы cookie и сервисы веб-аналитики, чтобы понимать,
+ как посетители пользуются сайтом.{" "}
+
+ Подробнее
+
+
+
+ Понятно
+
+
+
+ );
+}
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx
index 11d5a423..ea877d67 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx
@@ -52,6 +52,7 @@ import type { FormEvent } from "react";
import { detectCityInText } from "@/lib/city-registry";
+import { track } from "../../_lib/analytics";
import {
COVERED_CITIES,
PRIMARY_CITY,
@@ -102,6 +103,10 @@ export function FreeCheckCard() {
const roomsRef = useRef(null);
const floorRef = useRef(null);
const conditionGroupRef = useRef(null);
+ // Гард на одну отправку цели за визит формы: адрес набирают по букве, а
+ // цель должна отмечать сам факт «начал вводить», а не каждое нажатие.
+ // useRef, а не useState, — значение не должно вызывать перерендер.
+ const addressStartSent = useRef(false);
function clearFeedback() {
setFeedback(NONE);
@@ -213,6 +218,10 @@ export function FreeCheckCard() {
condition,
floor: trimmedFloor,
});
+ // ДО assign, не после: переход — это полная перезагрузка страницы
+ // (`window.location.assign`, не next/navigation), а не SPA-роутинг —
+ // любой код после неё не выполнится, текущий JS-контекст будет выгружен.
+ track("cta_estimate", { place: "hero_form" });
window.location.assign(PUBLIC_ROUTES.estimate);
}
@@ -269,6 +278,10 @@ export function FreeCheckCard() {
onChange={(event) => {
setAddress(event.target.value);
clearFeedback();
+ if (!addressStartSent.current) {
+ addressStartSent.current = true;
+ track("address_input_start", { place: "hero" });
+ }
}}
aria-invalid={errorField === "address" || undefined}
aria-describedby={errorField === "address" ? describedBy : undefined}
diff --git a/tradein-mvp/frontend/src/app/mera-public/_lib/analytics.ts b/tradein-mvp/frontend/src/app/mera-public/_lib/analytics.ts
new file mode 100644
index 00000000..34c20061
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/_lib/analytics.ts
@@ -0,0 +1,79 @@
+/**
+ * analytics.ts — единая точка отправки целей в Яндекс.Метрику и GA4 с
+ * публичного (B2C) контура «МЕРЫ».
+ *
+ * ПОЧЕМУ ОТДЕЛЬНЫЙ ЧИСТЫЙ МОДУЛЬ, А НЕ ХУК. `track()` вызывается из мест,
+ * которые с React работают по-разному: из `onChange`/`then`/`catch` внутри
+ * `EstimateFlow.tsx`, из обычного `window.location.assign`-обработчика в
+ * `FreeCheckCard.tsx`, из делегированного DOM-слушателя в
+ * `AnalyticsScripts.tsx`. Хук заставил бы каждое место быть компонентом с
+ * собственным рендером ради побочного эффекта — здесь достаточно функции.
+ * Модуль без импортов из React и без импортов из закрытого B2B-контура —
+ * это прямое требование докстринга `app/mera-public/layout.tsx` (изоляция
+ * публичного дерева, guard — `scripts/check-mera-public-isolation.mjs`).
+ *
+ * СЧЁТЧИКИ МОГУТ ОТСУТСТВОВАТЬ. На стейджинге, в preview-сборках и до того,
+ * как маркетинг заведёт боевые ID, `NEXT_PUBLIC_YM_ID` / `NEXT_PUBLIC_GA_ID`
+ * пусты — `AnalyticsScripts.tsx` тогда не рендерит соответствующий тег вовсе,
+ * и `window.ym` / `window.gtag` не появляются. `track()` — no-op в этом
+ * случае, а не ошибка: цели воронки прошиты в код форм независимо от того,
+ * подключена ли аналитика в данном окружении.
+ *
+ * НИКАКИХ ИСКЛЮЧЕНИЙ НАРУЖУ. Метрика и GA4 — сторонний JS, который может не
+ * успеть загрузиться (`afterInteractive`), упасть на blocklist рекламного
+ * фильтра или отдать неожиданную сигнатуру `window.ym`/`window.gtag`. Ни один
+ * из этих случаев не должен уронить форму проверки квартиры — она главнее
+ * любой аналитики. Отсюда try/catch вокруг каждого вызова.
+ */
+
+/** Ровно те строковые идентификаторы целей, что согласованы с воронкой. */
+export type GoalName =
+ | "cta_estimate"
+ | "address_input_start"
+ | "address_picked"
+ | "free_result_ok"
+ | "free_result_thin"
+ | "free_result_none"
+ | "calc_error"
+ | "validation_error"
+ | "suggest_failed"
+ | "paid_teaser_shown";
+
+declare global {
+ interface Window {
+ /** Яндекс.Метрика: счётчик кладёт себя в window под этим именем. */
+ ym?: (counterId: number, method: string, ...rest: unknown[]) => void;
+ /** GA4 gtag.js: стандартная точка входа, определяется inline-снипетом. */
+ gtag?: (...args: unknown[]) => void;
+ }
+}
+
+const YM_ID = process.env.NEXT_PUBLIC_YM_ID;
+const GA_ID = process.env.NEXT_PUBLIC_GA_ID;
+
+/**
+ * Отправляет цель воронки в оба счётчика, если они подключены.
+ *
+ * `params` не должен содержать персональные данные (адрес, телефон и т.п.) —
+ * это ответственность вызывающего места, здесь только транспорт.
+ */
+export function track(goal: GoalName, params?: Record): void {
+ // SSR/сборка: window нет вовсе, звать некого.
+ if (typeof window === "undefined") return;
+
+ try {
+ if (YM_ID && typeof window.ym === "function") {
+ window.ym(Number(YM_ID), "reachGoal", goal, params);
+ }
+ } catch {
+ // См. докстринг выше: аналитика не должна ронять страницу.
+ }
+
+ try {
+ if (GA_ID && typeof window.gtag === "function") {
+ window.gtag("event", goal, params);
+ }
+ } catch {
+ // См. докстринг выше: аналитика не должна ронять страницу.
+ }
+}
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 e5c68db9..6e995f6c 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
@@ -33,7 +33,7 @@
* моложе трёх недель в выборке нет вовсе (прогон обогащения 18.07 их не
* захватил), а они снижают цену реже всех — поэтому доля по всему живому
* рынку ниже примерно на 3 п.п. Цифра по долгостою от этой дыры не зависит.
- * Дерево mera-public закрыто noindex (layout.tsx).
+ * Дерево mera-public открыто для индексации с 10.09.2026 (layout.tsx).
*/
import { SERVICE_PRICE_RUB } from "../content";
diff --git a/tradein-mvp/frontend/src/app/mera-public/articles/kak-ocenit-kvartiru/page.tsx b/tradein-mvp/frontend/src/app/mera-public/articles/kak-ocenit-kvartiru/page.tsx
index 1e468270..e51b2670 100644
--- a/tradein-mvp/frontend/src/app/mera-public/articles/kak-ocenit-kvartiru/page.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/articles/kak-ocenit-kvartiru/page.tsx
@@ -3,26 +3,64 @@ import type { Metadata } from "next";
import { InnerHeader } from "../../_components/InnerHeader";
import { PublicLink } from "../../_components/PublicLink";
import { FooterV3 } from "../../_components/v3/FooterV3";
+import { PUBLIC_ORIGIN, PUBLIC_ROUTES } from "../../content";
import styles from "../../landing-v3.module.css";
import { ARTICLE_CARDS, HOWTO } from "../articles-content";
/**
* Статья «Как оценить квартиру для продажи» — макет 29.08 («МЕРА статья»),
* текст из макета целиком. Числа — реквизит макета: см. шапку
- * articles-content.ts; страница, как и всё дерево, под noindex из layout.
+ * articles-content.ts.
*
* Серверный компонент без клиентского JS: содержание — якорные ссылки,
* секции — обычные заголовки с id.
+ *
+ * ИНДЕКСАЦИЯ ОТКРЫТА (10.09.2026, см. layout.tsx).
*/
+const PUBLIC_HOWTO_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.articleHowTo}`;
+const DESCRIPTION = HOWTO.lead[0];
+
export const metadata: Metadata = {
title: `${HOWTO.title} — МЕРА`,
- robots: { index: false, follow: false },
+ description: DESCRIPTION,
+ alternates: { canonical: PUBLIC_HOWTO_URL },
+ openGraph: {
+ type: "article",
+ title: `${HOWTO.title} — МЕРА`,
+ description: DESCRIPTION,
+ url: PUBLIC_HOWTO_URL,
+ },
+};
+
+/**
+ * JSON-LD `Article` для сниппетов поисковика. Без `datePublished`/
+ * `dateModified`: в коде нет достоверной даты публикации (текст перенесён из
+ * макета 29.08 без штампа), а выдуманная дата — то самое нечестное
+ * утверждение, которое запрещает шапка content.ts. Поле можно будет добавить,
+ * когда появится реальный источник (например, поле в CMS/frontmatter).
+ */
+const ARTICLE_JSON_LD = {
+ "@context": "https://schema.org",
+ "@type": "Article",
+ headline: HOWTO.title,
+ description: DESCRIPTION,
+ author: { "@type": "Organization", name: "МЕРА" },
+ publisher: { "@type": "Organization", name: "МЕРА" },
+ mainEntityOfPage: { "@type": "WebPage", "@id": PUBLIC_HOWTO_URL },
};
export default function HowToArticlePage() {
return (
<>
+ {/* dangerouslySetInnerHTML тут безопасен: содержимое — JSON.stringify
+ объекта, собранного этим же файлом из констант, а не из
+ пользовательского ввода (в отличие от safeUrl-кейсов для href/src,
+ см. .claude/rules/frontend.md). */}
+
diff --git a/tradein-mvp/frontend/src/app/mera-public/articles/page.tsx b/tradein-mvp/frontend/src/app/mera-public/articles/page.tsx
index cee546f8..e526df69 100644
--- a/tradein-mvp/frontend/src/app/mera-public/articles/page.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/articles/page.tsx
@@ -3,10 +3,14 @@ import type { Metadata } from "next";
import { InnerHeader } from "../_components/InnerHeader";
import { PublicLink } from "../_components/PublicLink";
import { FooterV3 } from "../_components/v3/FooterV3";
-import { SERVICE_PRICE_RUB } from "../content";
+import { PUBLIC_ORIGIN, PUBLIC_ROUTES, SERVICE_PRICE_RUB } from "../content";
import styles from "../landing-v3.module.css";
import { ARTICLE_CARDS } from "./articles-content";
+const PUBLIC_ARTICLES_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.articles}`;
+const DESCRIPTION =
+ "Как отбирать аналоги, считать цену за метр и проверять её сроком продажи — разборы на данных, а не на мнениях.";
+
/**
* Список статей — макет 29.08 («МЕРА статьи»).
*
@@ -25,7 +29,13 @@ import { ARTICLE_CARDS } from "./articles-content";
export const metadata: Metadata = {
title: "Статьи — МЕРА · разборы на данных",
- robots: { index: false, follow: false },
+ description: DESCRIPTION,
+ alternates: { canonical: PUBLIC_ARTICLES_URL },
+ openGraph: {
+ title: "Статьи — МЕРА · разборы на данных",
+ description: DESCRIPTION,
+ url: PUBLIC_ARTICLES_URL,
+ },
};
export default function ArticlesPage() {
diff --git a/tradein-mvp/frontend/src/app/mera-public/business/page.tsx b/tradein-mvp/frontend/src/app/mera-public/business/page.tsx
index a56fcfcf..b1dd63d5 100644
--- a/tradein-mvp/frontend/src/app/mera-public/business/page.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/business/page.tsx
@@ -7,6 +7,8 @@ import { PublicLink } from "../_components/PublicLink";
import { FooterV3 } from "../_components/v3/FooterV3";
import {
B2B_URL,
+ PUBLIC_ORIGIN,
+ PUBLIC_ROUTES,
SUPPORT_EMAIL,
SUPPORT_TELEGRAM_LABEL,
SUPPORT_TELEGRAM_URL,
@@ -35,11 +37,22 @@ import styles from "../landing-v3.module.css";
* пришёл именно за ней, и убрать её значило бы чинить один дефект вторым.
*/
+const DESCRIPTION =
+ "Свяжитесь с командой «МЕРЫ», если вы застройщик, риелторское агентство или банк — расскажем про B2B-контур сервиса.";
+const PUBLIC_BUSINESS_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.business}`;
+
export const metadata: Metadata = {
title: "МЕРА для бизнеса — связаться",
- // Тот же noindex, что у остального публичного дерева (см. layout.tsx и
- // docs/page.tsx): страница открыта по адресу, но в выдачу не идёт.
- robots: { index: false, follow: false },
+ description: DESCRIPTION,
+ // Индексируется (10.09.2026, см. layout.tsx): страница описывает только
+ // канал связи и ссылку «уже есть доступ» — ни цифр, ни платного контура
+ // здесь нет, поэтому открытая выдача ничего не обещает лишнего.
+ alternates: { canonical: PUBLIC_BUSINESS_URL },
+ openGraph: {
+ title: "МЕРА для бизнеса — связаться",
+ description: DESCRIPTION,
+ url: PUBLIC_BUSINESS_URL,
+ },
};
export default function BusinessPage() {
diff --git a/tradein-mvp/frontend/src/app/mera-public/content.ts b/tradein-mvp/frontend/src/app/mera-public/content.ts
index 6dbe4e12..abadac47 100644
--- a/tradein-mvp/frontend/src/app/mera-public/content.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/content.ts
@@ -247,8 +247,13 @@ export const LEGAL_DOCS_REVISION = "13 августа 2026 г.";
* ст. 18.1 152-ФЗ обязана быть именно УТВЕРЖДЁННЫМ документом оператора —
* страница без этой строки (как было до 2026-08-13) заявку эквайера не
* проходит и требованию закона не отвечает.
+ *
+ * РЕДАКЦИЯ ОБНОВЛЕНА 2026-09-10: в политику добавлен раздел «Файлы cookie и
+ * веб-аналитика» (Яндекс.Метрика + GA4 на публичном сайте) — это изменение
+ * содержания документа, поэтому реквизит утверждения переезжает на новый
+ * приказ, а не остаётся под старым номером задним числом.
*/
-export const PRIVACY_APPROVAL = "приказом директора № 1 от 13 августа 2026 г.";
+export const PRIVACY_APPROVAL = "приказом директора № 2 от 10 сентября 2026 г.";
/**
* Сколько месяцев на нашей стороне хранится ссылка/строка оплаченного отчёта
diff --git a/tradein-mvp/frontend/src/app/mera-public/docs/page.tsx b/tradein-mvp/frontend/src/app/mera-public/docs/page.tsx
index 0a71dd91..ceb93b33 100644
--- a/tradein-mvp/frontend/src/app/mera-public/docs/page.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/docs/page.tsx
@@ -7,6 +7,8 @@ import { PublicLink } from "../_components/PublicLink";
import { FooterV3 } from "../_components/v3/FooterV3";
import {
LEGAL_ENTITY,
+ PUBLIC_ORIGIN,
+ PUBLIC_ROUTES,
SERVICE_PRICE_RUB,
SUPPORT_EMAIL,
SUPPORT_TELEGRAM_LABEL,
@@ -39,14 +41,23 @@ import styles from "../landing-v3.module.css";
* автосписаний» описывают порядок оплаты из оферты, а не работающий контур:
* приём платежей не подключён (`PUBLIC_ESTIMATE_ENABLED = false`, таблицы
* `payments`/`payment_entitlements` на проде пусты, см. TODO в FooterV3).
- * Пока дерево под noindex, расхождения «написано против доступно» никто не
- * увидит; открывать страницу наружу можно только вместе с платёжным контуром
- * (#2896) — либо сняв тарифную таблицу.
+ * Дерево открыто для индексации (10.09.2026) — расхождение «написано против
+ * доступно» теперь видно посетителю, но текст его не создаёт: тариф назван
+ * условиями оферты («по договору»), а не кнопкой оплаты, которой здесь нет.
*/
+const DESCRIPTION =
+ "Оферта, политика возврата, обработка персональных данных и тарифы сервиса «МЕРА».";
+
export const metadata: Metadata = {
title: "Документы и условия — МЕРА",
- robots: { index: false, follow: false },
+ description: DESCRIPTION,
+ alternates: { canonical: `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.docs}` },
+ openGraph: {
+ title: "Документы и условия — МЕРА",
+ description: DESCRIPTION,
+ url: `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.docs}`,
+ },
};
const TARIFFS: readonly {
diff --git a/tradein-mvp/frontend/src/app/mera-public/estimate/page.tsx b/tradein-mvp/frontend/src/app/mera-public/estimate/page.tsx
index 42b52822..d434df2d 100644
--- a/tradein-mvp/frontend/src/app/mera-public/estimate/page.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/estimate/page.tsx
@@ -19,17 +19,22 @@ import styles from "../landing-v3.module.css";
* Побочный выигрыш — этот URL можно дать человеку напрямую (в переписке, в
* рекламе), минуя чтение лэндинга.
*
- * NOINDEX — по той же причине, что и на остальных страницах B2C-дерева:
- * маркетинговые метрики на лэндинге пока плейсхолдеры из макета (#2904), а
- * приём оплаты не подключён. Снимать — вместе с ними, одним решением.
+ * ИНДЕКСАЦИЯ ОТКРЫТА (10.09.2026, см. layout.tsx) — страница описывает
+ * реальную бесплатную функцию, плейсхолдеров макета (#2904) в ней нет.
*/
+const DESCRIPTION =
+ "Бесплатно посмотреть, сколько похожих квартир продаётся рядом с вашим домом и как долго висят их объявления.";
+
export const metadata: Metadata = {
title: "Проверить квартиру — МЕРА",
- description:
- "Бесплатно посмотреть, сколько похожих квартир продаётся рядом с вашим домом и как долго висят их объявления.",
+ description: DESCRIPTION,
alternates: { canonical: PUBLIC_ESTIMATE_URL },
- robots: { index: false, follow: false },
+ openGraph: {
+ title: "Проверить квартиру — МЕРА",
+ description: DESCRIPTION,
+ url: PUBLIC_ESTIMATE_URL,
+ },
};
export default function MeraEstimatePage() {
diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css
index 7ad86759..fa5f4963 100644
--- a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css
+++ b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css
@@ -3145,3 +3145,118 @@
color: var(--b2c-ink);
text-wrap: pretty;
}
+
+/* ==========================================================================
+ * Cookie-баннер (CookieBanner) — префикс ckb.
+ * Уведомляющая (не блокирующая) нижняя плашка о cookie и веб-аналитике
+ * (Яндекс.Метрика, GA4). Монтируется в layout.tsx, поэтому видна на ВСЕХ
+ * страницах публичного сайта — в отличие от остальных секций этого файла,
+ * которые живут только на лендинге.
+ * ========================================================================== */
+
+/* `bottom: 68px` = высота `.barSpacer` из блока Sticky CTA выше: плашка
+ стоит НАД липкой CTA-панелью StickyCtaV3, а не поверх неё. Отступ верен и
+ когда панель спрятана transform'ом (`.barHidden`), и на страницах, где
+ StickyCtaV3 вовсе нет, — там он просто остаётся пустым (см. докстринг
+ CookieBanner.tsx). */
+.ckbRoot {
+ position: fixed;
+ left: 0;
+ right: 0;
+ /* 68px — та же величина, что у `.barSpacer` выше, и это НЕ совпадение:
+ баннер садится ровно на липкую панель `.barRoot`, чтобы не перекрыть её
+ кнопку. Число намеренно повторено, а не вынесено в токен: токены b2c —
+ палитра (см. `b2cVars` в theme.ts), заводить там первую в файле
+ величину раскладки ради одной пары правил хуже, чем держать их рядом.
+ Инвариант «панель всегда 68px на любой ширине» уже несущий — на нём
+ стоит `.barSpacer`, и он объяснён в комментарии к нему: на узком экране
+ панель остаётся в одну строку не за счёт переноса, а за счёт скрытого
+ `.barText`. Сломается инвариант — сломается и спейсер, то есть чинить
+ обе величины придётся одним заходом. */
+ bottom: 68px;
+ z-index: 61;
+ border-top: 1px solid var(--b2c-line);
+ /* Фоллбэк на непрозрачный surface для браузеров без color-mix(). */
+ background: var(--b2c-surface);
+ background: color-mix(in srgb, var(--b2c-surface) 96%, transparent);
+ backdrop-filter: blur(8px);
+}
+
+/* Скрытие transform'ом + opacity, а не display:none — тот же приём, что у
+ `.barHidden` в блоке Sticky CTA: уход с экрана происходит на композиторе,
+ без пересчёта layout. */
+.ckbHidden {
+ transform: translateY(100%);
+ opacity: 0;
+ pointer-events: none;
+ transition: transform 180ms ease, opacity 180ms ease;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .ckbHidden {
+ transition: none;
+ }
+}
+
+.ckbInner {
+ max-width: 1280px;
+ margin-inline: auto;
+ padding: 14px clamp(16px, 4vw, 32px);
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 10px 24px;
+}
+
+@media (min-width: 720px) {
+ .ckbInner {
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ }
+}
+
+.ckbText {
+ margin: 0;
+ font-size: 13.5px;
+ line-height: 1.55;
+ color: var(--b2c-ink);
+ max-width: 720px;
+}
+
+.ckbLink {
+ color: var(--b2c-accent-text);
+ font-weight: 600;
+ text-decoration: underline;
+ text-underline-offset: 3px;
+ white-space: nowrap;
+}
+
+.ckbLink:hover {
+ color: var(--b2c-ink);
+}
+
+.ckbBtn {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ white-space: nowrap;
+ border: none;
+ border-radius: 6px;
+ padding: 10px 20px;
+ font-family: var(--b2c-font-sans);
+ font-weight: 700;
+ font-size: 13.5px;
+ cursor: pointer;
+ background: var(--b2c-accent-deep);
+ color: var(--b2c-on-accent);
+}
+
+.ckbBtn:hover {
+ filter: brightness(0.92);
+}
+
+.ckbBtn:focus-visible {
+ outline: 2px solid var(--b2c-ink);
+ outline-offset: 2px;
+}
diff --git a/tradein-mvp/frontend/src/app/mera-public/layout.tsx b/tradein-mvp/frontend/src/app/mera-public/layout.tsx
index f2763a5e..628b7ead 100644
--- a/tradein-mvp/frontend/src/app/mera-public/layout.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/layout.tsx
@@ -1,7 +1,9 @@
import type { Metadata } from "next";
import { IBM_Plex_Mono, Manrope } from "next/font/google";
-import { REGION_NAME } from "./content";
+import { AnalyticsScripts } from "./_components/AnalyticsScripts";
+import { CookieBanner } from "./_components/v3/CookieBanner";
+import { PUBLIC_ORIGIN, REGION_NAME } from "./content";
import styles from "./landing.module.css";
import { b2cVars, landingVars } from "./theme";
@@ -36,9 +38,12 @@ import { b2cVars, landingVars } from "./theme";
* нет. Требование «никаких внешних CDN» соблюдено (в отличие от Leaflet в
* v2-картах, который тянется с unpkg — на лэндинге карт нет).
*
- * robots: noindex — сохранён намеренно. Маршрут наружу ещё не открыт (домен и
- * периметр Caddy делаются отдельным PR); до этого момента страница не должна
- * попадать в индекс. Снять флаг вместе с открытием домена.
+ * ИНДЕКСАЦИЯ ОТКРЫТА (10.09.2026). Домен и периметр Caddy уже сделаны,
+ * флаг «не индексировать, не идти по ссылкам» снят здесь и на всех страницах
+ * поддерева, у которых была своя `metadata.robots`. `metadataBase` даёт
+ * относительным OG-путям и превью абсолютный адрес без ручной склейки в
+ * каждом файле; `verification` читается из env и молча пропускается, если
+ * переменная не задана — пустая строка в мета-теге хуже отсутствующего тега.
*/
const manrope = Manrope({
subsets: ["latin", "cyrillic"],
@@ -62,10 +67,40 @@ const plexMono = IBM_Plex_Mono({
* Заголовки трёх юридических подстраниц (оферта / возврат / ПДн) живут по
* своему шаблону «<Документ> — МЕРА» и этой правкой не затрагиваются.
*/
+const TITLE = "Мера · расчёт стоимости квартиры по рыночным данным";
+const DESCRIPTION = `Узнайте рыночную стоимость квартиры по адресу: сделки Росреестра и объявления площадок. ${REGION_NAME}.`;
+
+/**
+ * `verification` — оба поля опциональны в Next: если добавить ключ со
+ * значением `""`, в HTML уйдёт пустой ` `, а
+ * это хуже отсутствующего тега (площадки верификации трактуют его как
+ * невалидный, а не как «код ещё не выдан»). Поэтому собираем объект
+ * условно, а не подставляем `process.env.X ?? ""` напрямую в литерал.
+ */
+const verification: Metadata["verification"] = {
+ ...(process.env.NEXT_PUBLIC_YANDEX_VERIFICATION
+ ? { yandex: process.env.NEXT_PUBLIC_YANDEX_VERIFICATION }
+ : {}),
+ ...(process.env.NEXT_PUBLIC_GOOGLE_VERIFICATION
+ ? { google: process.env.NEXT_PUBLIC_GOOGLE_VERIFICATION }
+ : {}),
+};
+
export const metadata: Metadata = {
- title: "Мера · расчёт стоимости квартиры по рыночным данным",
- description: `Узнайте рыночную стоимость квартиры по адресу: сделки Росреестра и объявления площадок. ${REGION_NAME}.`,
- robots: { index: false, follow: false },
+ metadataBase: new URL(PUBLIC_ORIGIN),
+ title: TITLE,
+ description: DESCRIPTION,
+ alternates: { canonical: "/" },
+ openGraph: {
+ type: "website",
+ locale: "ru_RU",
+ siteName: "МЕРА",
+ title: TITLE,
+ description: DESCRIPTION,
+ url: PUBLIC_ORIGIN,
+ },
+ twitter: { card: "summary_large_image" },
+ ...(Object.keys(verification).length > 0 ? { verification } : {}),
};
export default function MeraPublicLayout({
@@ -87,6 +122,19 @@ export default function MeraPublicLayout({
Перейти к содержимому
{children}
+
+ {/* AnalyticsScripts/CookieBanner — ВНУТРИ этого div, а не в корневом
+ `src/app/layout.tsx`, по двум разным причинам:
+ 1. CookieBanner использует `--b2c-*` переменные, объявленные инлайном
+ именно на этом узле (b2cVars выше) — вынеси баннер наружу, и var()
+ резолвится в пустоту (см. предупреждение в theme.ts про мёртвый мост).
+ 2. Счётчик Метрики обязан жить ТОЛЬКО в публичном B2C-периметре. Если
+ смонтировать его в корневом layout, он утечёт и в закрытый B2B-контур
+ (/v2, /admin, /scrapers, /history) — там нет анонимных посетителей,
+ считать нечего, а вот приватные маршруты сотрудников в вебвизор
+ Метрики попадать не должны. */}
+
+
);
}
diff --git a/tradein-mvp/frontend/src/app/mera-public/page.tsx b/tradein-mvp/frontend/src/app/mera-public/page.tsx
index f3a753d6..64890309 100644
--- a/tradein-mvp/frontend/src/app/mera-public/page.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/page.tsx
@@ -50,13 +50,12 @@ import styles from "./landing.module.css";
* они не спрашивают. Гасить их вместе с витринными числами значило бы прятать
* дисклеймер 135-ФЗ ровно тогда, когда бэкенд лежит.
*
- * МЕТАДАННЫЕ ЖИВУТ В `layout.tsx`, а не здесь. Заголовок и `robots: { index:
- * false }` там уже ровно те же, что стояли в `v3/page.tsx`; второй экземпляр
+ * МЕТАДАННЫЕ ЖИВУТ В `layout.tsx`, а не здесь. Заголовок, canonical и
+ * OpenGraph там уже ровно те же, что стояли в `v3/page.tsx`; второй экземпляр
* на уровне страницы ничего бы не менял, зато мог бы разъехаться с первым.
- * NOINDEX НЕ СНИМАТЬ вместе с переездом витрины на замеренные числа: маршрут
- * наружу открывается отдельным решением владельца (гейт
- * `landing-numbers-gate.test.ts` держит флаг в layout), а не побочным
- * эффектом того, что плейсхолдеры кончились.
+ * Индексация открыта решением владельца 10.09.2026; своих `metadata` этот
+ * файл по-прежнему не заводит — гейт `landing-numbers-gate.test.ts` держит
+ * оба инварианта (в layout нет noindex, у корня нет собственных метаданных).
*
* ЧЕГО ЗДЕСЬ ПО-ПРЕЖНЕМУ НЕТ:
* - модалка проверки — сознательно заменена экраном /estimate (см.
diff --git a/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx b/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx
index 93a95947..db3e1404 100644
--- a/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx
@@ -264,7 +264,7 @@ export default function MeraPublicPrivacyPage() {
7.1. Пользователь вправе в любой момент отозвать согласие на обработку
персональных данных, направив обращение в поддержку Оператора по
- контактам, указанным в разделе 9 настоящей Политики. С момента
+ контактам, указанным в разделе 10 настоящей Политики. С момента
получения обращения Оператор прекращает использование номера телефона
пользователя для связи по соответствующей заявке.
@@ -292,8 +292,46 @@ export default function MeraPublicPrivacyPage() {
соответствии со ст. 19 Федерального закона № 152-ФЗ.