Merge pull request 'МЕРА B2C: Яндекс.Метрика и GA4 + открытие сайта для поисковой индексации' (#3436) from feat/mera-analytics-seo into main
Some checks failed
Deploy Infra Host / sync-infra-host (push) Successful in 6s
Deploy / changes (push) Successful in 10s
Deploy / deploy-caddy (push) Has been skipped
perimeter-smoke-mera / smoke (push) Failing after 13s
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-browser (push) Successful in 47s
Deploy / build-frontend (push) Successful in 52s
Deploy / build-backend (push) Successful in 54s
Deploy / build-worker (push) Successful in 55s
Deploy / deploy (push) Successful in 1m14s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Failing after 14s
Deploy Trade-In / build-frontend (push) Successful in 3m17s
Deploy Trade-In / test (push) Failing after 4m30s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
Deploy Trade-In / perimeter-smoke (push) Has been skipped
Deploy Trade-In / deploy-status (push) Failing after 1s

This commit is contained in:
lekss361 2026-09-10 14:14:25 +00:00
commit aba07b581f
28 changed files with 1215 additions and 59 deletions

View file

@ -511,12 +511,24 @@ jobs:
# NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js
# инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит,
# см. frontend/Dockerfile комментарий у соответствующих ARG).
# NEXT_PUBLIC_YM_ID/GA_ID/YANDEX_VERIFICATION/GOOGLE_VERIFICATION —
# ПОКА ПУСТЫЕ: владелец ещё не завёл счётчики Метрики/GA4 и
# мета-теги верификации поисковых консолей. Пустая строка = скрипт
# счётчика НЕ рендерится вообще (контракт фронта, см. тот же
# Dockerfile-комментарий). Когда номера появятся — вписать
# литералом сюда И в retry-блок ниже (оба обязательны, иначе
# ретрай без кеша уедет без счётчика), и это ТРЕБУЕТ пересборки
# образа (build-time bake, не runtime-правка на проде).
build-args: |
NEXT_PUBLIC_BASE_PATH=/trade-in
NEXT_PUBLIC_API_BASE_URL=/trade-in
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
NEXT_PUBLIC_YM_ID=
NEXT_PUBLIC_GA_ID=
NEXT_PUBLIC_YANDEX_VERIFICATION=
NEXT_PUBLIC_GOOGLE_VERIFICATION=
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |
@ -540,6 +552,10 @@ jobs:
NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }}
NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }}
NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }}
NEXT_PUBLIC_YM_ID=
NEXT_PUBLIC_GA_ID=
NEXT_PUBLIC_YANDEX_VERIFICATION=
NEXT_PUBLIC_GOOGLE_VERIFICATION=
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_FRONTEND }}:latest

View file

@ -394,7 +394,14 @@ meraocenka.ru {
# `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант
# без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на
# «/», и запрос закольцуется.
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs /business
# /sitemap.xml дописан той же строкой, а не отдельным handle (как
# robots.txt ниже): Next отдаёт его по /trade-in/mera-public/sitemap.xml
# (src/app/mera-public/sitemap.ts) — ровно в форме, которую уже собирает
# rewrite `/trade-in/mera-public{path}` ниже. robots.txt так не смог бы:
# Next отдаёт его КОРНЕВЫМ /trade-in/robots.txt (конвенция Next —
# robots.txt существует только в корне app/, не под mera-public/), эта
# форма под `{path}` не попадает — отсюда отдельный handle у него.
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs /business /sitemap.xml
handle @meraPages {
rewrite * /trade-in/mera-public{path}
reverse_proxy tradein-frontend:3000 {
@ -408,9 +415,28 @@ meraocenka.ru {
# голый 404 (так было и до этого PR, с момента #2615). Заодно это
# замыкает цепочку для длинных адресов со слэшем: они приходят на короткий
# со слэшем и здесь нормализуются.
# БАГ, найденный замером на живом проде 10.09.2026:
# https://meraocenka.ru/articles/?utm_source=vc -> Location:
# https://meraocenka.ru/articles — query терялся, потому что
# `redir * /{re.shortslash.1}` собирает цель заново из regex-захвата
# пути и не переносит исходную query-строку. Вред тихий: статьи
# публикуются с UTM-метками, а мессенджеры и автолинкификаторы дописывают
# слэш к скопированной ссылке — то есть именно трафик по опубликованному
# посту терял атрибуцию, и 301 при этом честно отрабатывал (просто не
# туда), ошибки никто не видел.
#
# Тот же приём, что у @meraLongPages/@meraV3Gone ниже и выше: `uri
# strip_suffix /` снимает ровно завершающий слэш (сам путь уже сужен
# матчером до канонического короткого вида), `redir * {uri} permanent`
# переносит query как есть. Обёртка `route` обязательна по той же
# причине, что у соседей — внутри `handle` порядок директив определяет
# Caddy, и без неё `redir` выполнился бы раньше `uri`.
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|docs|business)/$
handle @meraShortSlash {
redir * /{re.shortslash.1} permanent
route {
uri strip_suffix /
redir * {uri} permanent
}
}
# `/v3` — АДРЕС ПРЕВЬЮ, КОТОРОГО БОЛЬШЕ НЕТ. С 29.08.2026 выбранный вариант
@ -559,6 +585,19 @@ meraocenka.ru {
}
}
# robots.txt — ОТДЕЛЬНЫЙ handle, а не строка в @meraPages выше: у Next
# robots.txt — конвенция корня app/ (src/app/robots.ts), а не
# mera-public/, поэтому rewrite `/trade-in/mera-public{path}` его не
# обслужит — нужен свой rewrite на /trade-in/robots.txt. По образцу
# favicon.ico прямо над этим блоком, включая тот же периметр-скраб
# заголовка.
handle /robots.txt {
rewrite * /trade-in/robots.txt
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
}
}
# Публичный API МЕРЫ — ЕДИНСТВЕННЫЙ путь этого домена, доходящий до
# бэкенда. Под /api/public/ по определению не лежит ничего закрытого:
# гарантию даёт структура пакета app/api/public/, а не аккуратность этого

View file

@ -71,6 +71,36 @@ check_post() {
fi
}
# check_redirect_location: сверяет буквальный заголовок Location редиректа,
# а не только код ответа — нужен там, где регресс не меняет код (301
# остаётся 301), а меняет ТОЛЬКО цель (баг 10.09.2026 у @meraShortSlash в
# apps.caddy: query терялась молча, код ответа был зелёным всю дорогу).
# curl без -L (редирект не проходим), -D - дампит заголовки в stdout.
#
# ФОРМА LOCATION НЕ ФИКСИРУЕТСЯ. Caddy отдаёт цель редиректа так, как её
# собрала директива: для `redir * {uri}` это относительный путь
# (`/articles?utm_source=vc`), а для веток с явным хостом — абсолютный URL
# (`https://meraocenka.ru/articles?utm_source=vc`). Обе формы валидны по
# RFC 7231, браузер разрешает относительную сам, и переписывание одной ветки
# конфига на другую — не регресс, ради которого стоит ронять смоук. Поэтому
# сверяется ХВОСТ: путь с query, с какого бы префикса Location ни начинался.
# Ровно это и есть предмет проверки — что query дожила до цели.
check_redirect_location() {
local desc="$1" url="$2" expected_suffix="$3"
local location
location=$(curl -s -o /dev/null -D - --max-time 15 "$url" 2>/dev/null \
| grep -i '^location:' | tr -d '\r' | sed 's/^[Ll]ocation: *//')
case "$location" in
"$expected_suffix"|*"$expected_suffix")
echo "PASS: $desc ($url -> Location: $location)"
;;
*)
echo "FAIL: $desc ($url -> got Location '${location:-<none>}', expected ending with '$expected_suffix')"
fail=1
;;
esac
}
echo "== МЕРА B2C perimeter smoke (ЭТАП 1) =="
# 1. Публичный домен отдаёт 200 анонимно.
@ -102,6 +132,14 @@ check "meraocenka.ru/estimate — public 200" "$BASE_MERA/estimate" 200
# человека на форму входа; отвалится handle — вернётся 404 вместо неё.
check "meraocenka.ru/business — public 200" "$BASE_MERA/business" 200
# 1d3. robots.txt / sitemap.xml — первое, что запрашивает поисковый краулер
# при индексации. robots.txt отдаётся ОТДЕЛЬНЫМ handle (Next-конвенция:
# файл в корне app/, а не под mera-public/), sitemap.xml — той же строкой
# что и остальные @meraPages (см. комментарии в apps.caddy). Отвалится
# любой из handle — сайт не проиндексируется вовсе.
check "meraocenka.ru/robots.txt — public 200" "$BASE_MERA/robots.txt" 200
check "meraocenka.ru/sitemap.xml — public 200" "$BASE_MERA/sitemap.xml" 200
# 1e. Длинные адреса поддерева отдают 301 на короткие: у страницы один
# канонический адрес, а старые ссылки и закладки продолжают работать.
# ГОЛЫЙ /trade-in/mera-public — регресс на баг 15.08.2026: прежний матчер
@ -110,6 +148,16 @@ check "meraocenka.ru/business — public 200" "$BASE_MERA/business" 200
check "meraocenka.ru длинный корень — 301 на /" "$BASE_MERA/trade-in/mera-public" 301
check "meraocenka.ru длинная оферта — 301 на /oferta" "$BASE_MERA/trade-in/mera-public/oferta" 301
# 1f. Регресс-тест на баг 10.09.2026 (замер на живом проде): редирект со
# слэшем терял query-строку — статьи публикуются с UTM-метками, а
# мессенджеры и автолинкификаторы дописывают слэш к скопированной ссылке,
# то есть именно этот трафик терял атрибуцию молча (код ответа
# оставался 301, поэтому предыдущая версия смоука проблему не ловила).
# Проверяем сам факт 301 (уже покрыт проверками check выше по коду) И
# буквальный Location — только вторая половина ловит регресс.
check_redirect_location "meraocenka.ru/articles/?utm_source=vc — query сохраняется на редиректе" \
"$BASE_MERA/articles/?utm_source=vc" "/articles?utm_source=vc"
# 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401.
check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404
@ -226,6 +274,16 @@ check "trade-in /api/v1/admin/proxies — 404 anonymous (существующа
check "trade-in /api/v1/admin/users — 404 anonymous (несуществующая — тот же ответ)" \
"$BASE_MAIN/trade-in/api/v1/admin/users" 404
# 3b. sitemap.xml публичного лэндинга МЕРЫ не должен просачиваться через
# закрытый B2B-контур gendsgn.ru — корень gendsgn.ru принадлежит Site
# Finder, у него нет своего route на /sitemap.xml под МЕРУ, allowlist-by-
# default этого site-блока (как и у meraocenka.ru) отдаёт 404 на всё
# незаявленное. NB: не проверено вживую на проде (нет approval на prod-
# доступ в рамках этой сессии) — если у Site Finder когда-нибудь появится
# СВОЙ /sitemap.xml, ожидание 404 придётся пересмотреть на 200-с-другим-
# содержимым.
check "gendsgn.ru/sitemap.xml — must NOT serve MERA sitemap (закрытый контур)" "$BASE_MAIN/sitemap.xml" 404
# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (auth gate стоит ДО роутинга
# в FastAPI — конкретный путь неважен, любой /api/v1/admin/* перехватывается
# на уровне Caddy до бэкенда).

View file

@ -59,6 +59,16 @@ services:
build:
context: ./frontend
dockerfile: Dockerfile
# Паритет с prod build-args (.forgejo/workflows/deploy-tradein.yml,
# job build-frontend): те же 4 счётчика/SEO-переменные, тот же пустой
# дефолт (= "счётчик не подключается", см. frontend/Dockerfile).
# Локально включить: экспортировать переменную в shell ДО
# `docker compose build` — Compose подставит её сюда вместо "".
args:
NEXT_PUBLIC_YM_ID: "${NEXT_PUBLIC_YM_ID:-}"
NEXT_PUBLIC_GA_ID: "${NEXT_PUBLIC_GA_ID:-}"
NEXT_PUBLIC_YANDEX_VERIFICATION: "${NEXT_PUBLIC_YANDEX_VERIFICATION:-}"
NEXT_PUBLIC_GOOGLE_VERIFICATION: "${NEXT_PUBLIC_GOOGLE_VERIFICATION:-}"
container_name: tradein-frontend
restart: unless-stopped
environment:

View file

@ -30,6 +30,31 @@ ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ARG NEXT_PUBLIC_ENABLE_PREVIEW=""
ENV NEXT_PUBLIC_ENABLE_PREVIEW=$NEXT_PUBLIC_ENABLE_PREVIEW
# Аналитика и SEO-верификация (Яндекс.Метрика, GA4, Яндекс.Вебмастер,
# Google Search Console) — тот же контракт, что у переменных выше: Next.js
# инлайнит NEXT_PUBLIC_* в статические бандлы на `npm run build`, runtime
# env их НЕ подхватит, поэтому это build-arg, а не рантайм-конфиг.
#
# ПУСТОЙ ДЕФОЛТ — ЧАСТЬ КОНТРАКТА ФРОНТА, не временная заглушка: пустое
# значение означает «счётчик/мета-тег не рендерится вообще» (весь
# соответствующий script/meta отсутствует в разметке) — так решает
# frontend-код, потребляющий эти env (Dockerfile сам их не читает).
#
# СЧЁТЧИКОВ ПОКА НЕТ — владелец заведёт их позже. Когда появятся номера:
# 1) вписать литералом в build-args джоба build-frontend
# (.forgejo/workflows/deploy-tradein.yml) — В ОБОИХ блоках, основном
# И retry-без-кеша, иначе ретрай молча уедет без счётчика;
# 2) пересобрать образ — смена значения ТРЕБУЕТ build (build-time bake),
# одной правки runtime-конфига на проде недостаточно.
ARG NEXT_PUBLIC_YM_ID=""
ENV NEXT_PUBLIC_YM_ID=$NEXT_PUBLIC_YM_ID
ARG NEXT_PUBLIC_GA_ID=""
ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID
ARG NEXT_PUBLIC_YANDEX_VERIFICATION=""
ENV NEXT_PUBLIC_YANDEX_VERIFICATION=$NEXT_PUBLIC_YANDEX_VERIFICATION
ARG NEXT_PUBLIC_GOOGLE_VERIFICATION=""
ENV NEXT_PUBLIC_GOOGLE_VERIFICATION=$NEXT_PUBLIC_GOOGLE_VERIFICATION
# Версия продукта («Мера») + короткий git SHA + дата сборки — ДОЛЖНЫ быть
# build-time ARG (не runtime env): Next.js инлайнит NEXT_PUBLIC_* в статические
# бандлы на `npm run build`, а этот build context (./tradein-mvp/frontend) не

View file

@ -278,28 +278,76 @@ describe("гейт витринных чисел (#2904)", () => {
});
/**
* НАЛИЧИЯ СЛОВА `robots` НЕДОСТАТОЧНО. Проверка на подстроку остаётся
* зелёной при `index: true` то есть ровно тогда, когда страница уходит в
* индекс. Поэтому сверяется значение.
* ГЕЙТ ПЕРЕВЁРНУТ (10.09.2026). До этой правки всё дерево было закрыто от
* поиска (`robots: {index:false, follow:false}` в layout.tsx и на пяти
* подстраницах), и тест следил, чтобы флаг НЕ потерялся раньше времени.
* Индексация открыта решением владельца теперь опасность обратная: кто-то
* по привычке (copy-paste соседней страницы, откат мерджа) вернёт noindex
* молча, и сайт выпадет из поиска без единого сигнала в CI. Поэтому
* проверка развёрнута: падать, если запрещающий индексацию блок ВЕРНУЛСЯ.
*
* ПРОВЕРЯЕТСЯ ПАРА, А НЕ ОДИН ФАЙЛ. Пока витрина жила на `v3/page.tsx`, у
* неё был собственный `export const metadata` и флаг стоял в двух местах
* обоих и требовали. С переездом в корень второй экземпляр снят намеренно
* (см. шапку `page.tsx`): метаданные держит только layout. Требовать
* `noindex` в `page.tsx` теперь значило бы требовать вернуть дубль, а просто
* убрать проверку оставить дыру: страничные `metadata` в Next
* ПЕРЕКРЫВАЮТ layout, и `index: true` на уровне корня погасил бы флаг, не
* тронув файла, за которым тут следят. Отсюда две половины: в layout стоит
* noindex, а корень своих метаданных не заводит вовсе.
* НАЛИЧИЯ СЛОВА `robots` НЕДОСТАТОЧНО регэксп по-прежнему сверяет именно
* значение `index:false, follow:false`, а не факт упоминания ключа: у
* страниц есть легитимный `openGraph`/`alternates` рядом, трогать их эта
* проверка не должна.
*
* СПИСОК ФАЙЛОВ те же места, где раньше стоял флаг: layout.tsx (общий для
* всего поддерева) и пять публичных подстраниц, у каждой из которых
* `metadata.robots` мог быть выставлен персонально и перекрыть layout.
* Корневой `page.tsx` сюда не входит: у него, как и раньше, своих metadata
* нет вовсе (следующая проверка это отдельно охраняет), значит и вернуться
* флагу там неоткуда.
*/
const NOINDEX = /robots:\s*\{\s*index:\s*false\s*,\s*follow:\s*false\s*\}/u;
/**
* ФОРМ ЗАПРЕТА НЕСКОЛЬКО, И ЛОВИТЬ НАДО ВСЕ.
*
* Первая редакция этой проверки сверяла ровно одну запись
* `robots: { index: false, follow: false }`, потому что именно она и
* стояла в снятых флагах. Но Next принимает `metadata.robots` и строкой
* (`robots: "noindex"`), и объектом с одним полем (`robots: { index:
* false }`), и через `googleBot`. Любая из этих форм закрывает страницу от
* индексации ровно так же, а гейт, знающий только третью, пропустил бы их
* молча то есть охранял бы формулировку, а не свойство.
*
* Поэтому две проверки. Первая ловит `index: false` в любом объекте
* `robots` (порядок и состав полей не важны), вторая строковую форму со
* словом `noindex`. `openGraph`/`alternates` рядом ни под одну не подпадают:
* обе требуют именно ключа `robots`.
*/
const NOINDEX_OBJECT = /robots:\s*\{[^}]*\bindex:\s*false/u;
const NOINDEX_STRING = /robots:\s*(['"`])[^'"`]*noindex/u;
it("в layout.tsx стоит noindex, а не просто слово robots", () => {
expect(
read("layout.tsx"),
"layout.tsx: витрина открыта поисковикам — маршрут наружу ещё не открыт",
).toMatch(NOINDEX);
});
/**
* Юридические страницы (`oferta`, `refund`, `privacy`) в список НЕ входят
* намеренно: флага индексации у них не было и до этой правки они
* унаследовали `noindex` от layout, а собственного `metadata.robots` не
* объявляли. Возврат флага им грозит только через layout, а layout здесь
* проверяется. Заводить для них строки значило бы утверждать, что у них
* есть свой `metadata.robots`, которого нет.
*/
const PUBLIC_INDEXED_FILES = [
"layout.tsx",
"estimate/page.tsx",
"articles/page.tsx",
"articles/kak-ocenit-kvartiru/page.tsx",
"docs/page.tsx",
"business/page.tsx",
];
it.each(PUBLIC_INDEXED_FILES)(
"%s не запрещает индексацию — noindex не вернулся",
(rel) => {
const source = read(rel);
expect(
source,
`${rel}: в metadata.robots снова стоит index:false — страница опять закрыта от поиска`,
).not.toMatch(NOINDEX_OBJECT);
expect(
source,
`${rel}: в metadata.robots снова стоит строковый noindex — страница опять закрыта от поиска`,
).not.toMatch(NOINDEX_STRING);
},
);
it("корень не заводит своих metadata — иначе noindex из layout перекрыт незаметно", () => {
expect(

View file

@ -129,6 +129,43 @@ function stripComments(text: string): string {
.join("\n");
}
/**
* ИМЕНОВАНИЕ ОБРАБОТЧИКА В ПОЛИТИКЕ КОНФИДЕНЦИАЛЬНОСТИ ЗАКОННОЕ ИСКЛЮЧЕНИЕ.
*
* Гейт закрывает один конкретный риск: посетитель не должен видеть, ПО КАКИМ
* ПЛОЩАДКАМ собраны данные его оценки. «Яндекс» в разделе про cookie это
* не площадка-источник, а обработчик персональных данных по поручению
* Оператора, и ч. 3 ст. 6 152-ФЗ прямо требует его назвать: политика, в
* которой написано «некий сервис веб-аналитики», раскрытием не является.
* То есть здесь запрет гейта и требование закона направлены в разные
* стороны, и выигрывает закон.
*
* ИСКЛЮЧЕНИЕ НАМЕРЕННО УЗКОЕ не «не проверять privacy/page.tsx». Из строки
* маскируются только сами аналитические словосочетания; ГОЛОЕ «Яндекс» в
* значении площадки остаётся запрещённым и в политике тоже. Поэтому попытка
* написать в юр-документе «данные Яндекс.Недвижимости» гейт по-прежнему
* уронит а именно ради этого он и заведён.
*/
const LEGAL_PROCESSOR_MENTIONS: Array<{ file: string; allow: RegExp }> = [
{
file: join("mera-public", "privacy", "page.tsx"),
// Словоформы падежей («Яндекс.Метрики», «Яндекс.Метрике») — `\p{L}*`.
allow: /Яндекс\.Метрик\p{L}*|ООО\s+«Яндекс»/gu,
},
];
/** Маскирует законные упоминания обработчика ПДн до проверки гейта. */
function maskLegalMentions(file: string, text: string): string {
let masked = text;
for (const { file: suffix, allow } of LEGAL_PROCESSOR_MENTIONS) {
if (!file.endsWith(suffix)) continue;
// Замена одинаковой длины: номера строк в отчёте о находке не должны
// съехать, а «×» не входит в `\p{L}` и новых слов не создаёт.
masked = masked.replace(allow, (m) => "×".repeat(m.length));
}
return masked;
}
/**
* true, если `needle` встречается в `line` как отдельное слово не часть
* более длинного TS-идентификатора (`CianValuationSummary`, `cianId`).
@ -158,7 +195,7 @@ describe("публичная копия не содержит реальных
const hits: string[] = [];
for (const file of files) {
const raw = readFileSync(file, "utf8");
const text = stripComments(raw);
const text = maskLegalMentions(file, stripComments(raw));
text.split("\n").forEach((line, i) => {
if (hasWholeWord(line, needle)) {
hits.push(`${file}:${i + 1}`);

View file

@ -109,6 +109,23 @@ describe("короткие адреса публичного домена", () =
*/
const PREVIEW_ONLY: string[] = [];
/**
* Машинные артефакты для поисковых роботов: их раздаёт публичный домен, но
* страницами они не являются и в `PUBLIC_ROUTES` им не место.
*
* `PUBLIC_ROUTES` не просто перечень открытых адресов, это ТИП цели для
* `PublicLink` (`to` принимает только его ключи). Занеси сюда `sitemap.xml`
* и в разметке станет выразимой ссылка «перейти на карту сайта», которой
* не должно существовать: карта сайта адресована роботу, а не посетителю.
* Поэтому отдельный список, а не строка в реестре маршрутов.
*
* Здесь только `/sitemap.xml`: он единственный живёт в матчере @meraPages,
* который разбирает проверка ниже. `/robots.txt` открыт своим `handle`
* (у Next это конвенция корня `app/`, и форма пути другая), в этот разбор
* не попадает и потому в списке не нужен.
*/
const MACHINE_ONLY = ["/sitemap.xml"];
const meraPagesLine = meraBlock.match(/@meraPages path ([^\n]+)/);
it("каждый маршрут из PUBLIC_ROUTES раздаётся публичным доменом", () => {
@ -134,7 +151,11 @@ describe("короткие адреса публичного домена", () =
*/
it("на публичном домене не открыто ничего сверх известных страниц", () => {
const served = meraPagesLine?.[1].split(/\s+/).filter(Boolean) ?? [];
const known = new Set([...Object.values(PUBLIC_ROUTES), ...PREVIEW_ONLY]);
const known = new Set([
...Object.values(PUBLIC_ROUTES),
...PREVIEW_ONLY,
...MACHINE_ONLY,
]);
const unexpected = served.filter((p) => !known.has(p));
expect(
unexpected,
@ -142,6 +163,41 @@ describe("короткие адреса публичного домена", () =
).toEqual([]);
});
/**
* ВТОРАЯ ДВЕРЬ В ТОТ ЖЕ ПЕРИМЕТР отдельные `handle`, мимо @meraPages.
*
* Проверка выше разбирает ОДНУ строку матчера, поэтому видит только те
* адреса, что открыты через него. Но корневой путь можно открыть и вторым
* способом своим `handle /<путь>`, и таких в блоке уже несколько:
* `/favicon.ico` (браузер просит его сам), `/robots.txt` (у Next это
* конвенция корня `app/`, форма пути не совпадает с rewrite'ом @meraPages),
* `/metrics` (наоборот, явный отказ). Пока их было два и оба завёл один
* человек в один заход, дыра была теоретической; с добавлением robots.txt
* способ стал рабочим приёмом, а значит им воспользуются снова и
* следующий такой `handle` откроет наружу что-нибудь, о чём приложение не
* знает, полностью мимо гейта выше.
*
* Поэтому список именной. Новый корневой `handle` = новая строка здесь,
* ровно как новый путь = строка в PUBLIC_ROUTES. Матчер берёт только
* односегментные пути с точкой (файлы) и `/metrics`: вложенные
* `/trade-in/...`-хопы это внутренняя проводка basePath, а не публичная
* поверхность, у них своя проверка ниже (B2B-пути обязаны отдавать 404).
*/
const ROOT_HANDLES_ALLOWED = ["/metrics", "/favicon.ico", "/robots.txt"];
it("корневые handle публичного домена — только объявленные", () => {
const declared = [...meraBlock.matchAll(/^\s*handle\s+(\/[^\s*{]+)\s*\{/gm)]
.map((m) => m[1])
.filter((p) => p === "/metrics" || /^\/[^/]+\.[^/]+$/.test(p));
const unexpected = declared.filter((p) => !ROOT_HANDLES_ALLOWED.includes(p));
expect(
unexpected,
`корневой handle открыт в Caddy, но не объявлен в тесте: ${unexpected}. ` +
`Если путь действительно должен быть публичным — добавь его в ` +
`ROOT_HANDLES_ALLOWED вместе с объяснением, зачем ему отдельный handle.`,
).toEqual([]);
});
/**
* Длинные адреса обязаны редиректить на короткие иначе разосланные ссылки
* и закладки превращаются в 404. Список тоже поимённый (регексп с захватом

View file

@ -0,0 +1,155 @@
"use client";
/**
* AnalyticsScripts подключение Яндекс.Метрики и GA4 к публичному контуру
* «МЕРЫ». Монтируется ОДИН раз в `app/mera-public/layout.tsx` (другим
* агентом этот файл сам себя никуда не встраивает).
*
* `strategy="afterInteractive"` (а не `beforeInteractive`): счётчики не
* нужны для отрисовки первого экрана и не должны конкурировать за полосу
* пропускания с шрифтами/CSS витрины они догружаются, когда страница уже
* интерактивна. Оба блока рендерятся независимо: пустой `NEXT_PUBLIC_YM_ID`
* или `NEXT_PUBLIC_GA_ID` (стейджинг, превью-сборки, пока не заведён боевой
* счётчик) просто убирает соответствующий тег `ym(undefined, ...)` или
* `gtag.js?id=` с пустым id не отправляются вовсе.
*
* ДЕЛЕГИРОВАННЫЙ КЛИК ПО CTA «ПРОВЕРИТЬ КВАРТИРУ». Ссылка на `/estimate`
* продублирована минимум в восьми местах (HeaderV3, StickyCtaV3, TwoPathsV3,
* ObjectionsV3, GuessGameV3, FooterV3, articles/page.tsx,
* articles/kak-ocenit-kvartiru/page.tsx) и это везде обычный `<a>` через
* `PublicLink`, без onClick. Вешать обработчик в восьми файлах хуже, чем
* здесь один: восемь копий одного и того же кода почти гарантированно
* разъедутся при следующей правке (кто-то поправит формулировку цели в одном
* месте и забудет остальные семь), а при появлении девятой кнопки её тоже
* пришлось бы не забыть подключить руками. Один слушатель `click` на
* `document` в bubble-фазе ловит все восемь мест и любые будущие ссылка
* определяется по факту `href="/estimate"`, а не по компоненту-источнику.
* `capture` не нужен: обычный bubble долетает от любого вложенного элемента
* ссылки (иконки, span'а с текстом) до document, порядок всплытия здесь не
* важен обработчик ничего не отменяет и не блокирует переход.
*/
import Script from "next/script";
import { useEffect } from "react";
import { track } from "../_lib/analytics";
const YM_ID = process.env.NEXT_PUBLIC_YM_ID;
const GA_ID = process.env.NEXT_PUBLIC_GA_ID;
export function AnalyticsScripts() {
useEffect(() => {
// Дедуп двойного клика. Переход на `/estimate` — обычная навигация по
// `<a>`, и до выгрузки страницы проходит заметное время: нетерпеливый
// посетитель успевает щёлкнуть дважды, и в отчёте это выглядит как два
// разных достижения цели. Окно в 1 с отсекает дребезг, но не мешает
// засчитать осмысленный повторный клик после возврата назад — при
// возврате компонент монтируется заново и замыкание обнуляется.
let lastCtaAt = 0;
function handleClick(event: MouseEvent) {
const target = event.target as HTMLElement | null;
const link = target?.closest('a[href="/estimate"]');
if (!link) return;
const now = Date.now();
if (now - lastCtaAt < 1000) return;
lastCtaAt = now;
// Текст ссылки — не адрес и не персональные данные, безопасно слать
// как параметр цели. Обрезаем на случай длинной формулировки CTA.
const place = (link.textContent ?? "").trim().slice(0, 60);
track("cta_estimate", { place });
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
return (
<>
{YM_ID && (
<>
{/* ВЕБВИЗОР ВЫКЛЮЧЕН ОСОЗНАННО, а не забыт.
Он пишет запись действий посетителя, включая ввод в поля формы
(маскируется по умолчанию только `input[type=password]`), а на
`/estimate` человек вводит адрес своей квартиры. Раздел 9
политики конфиденциальности объявляет сбор IP, идентификатора
устройства, параметров сессии и UTM-меток записи действий там
нет. Пока раскрытие не покрывает запись, включать её нельзя:
152-ФЗ требует, чтобы политика описывала фактически собираемое,
а не наоборот.
Задача, ради которой ставились счётчики, увидеть источники
переходов и прохождение воронки; для неё вебвизор не нужен
вовсе. Включать его следует одним заходом с двумя правками:
(1) отдельная фраза про запись действий в п. 9.1
`privacy/page.tsx`, (2) маскировка полей ввода адреса и
телефона через `ym-hide-content` / `data-ym-disable-keys`. */}
<Script id="ym-init" strategy="afterInteractive">
{`
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
ym(${Number(YM_ID)}, "init", {
clickmap:true,
trackLinks:true,
accurateTrackBounce:true,
webvisor:false
});
`}
</Script>
{/* Без JS счётчик всё равно должен считать визит стандартный
пиксель Метрики, обязателен по её же документации.
`next/image` здесь неприменим принципиально, и правило eslint
выключено осознанно, а не ради тишины. Во-первых, внутри
`<noscript>` работает только разметка: `next/image` React-
компонент с рантайм-логикой (srcset, ленивая загрузка,
плейсхолдер), которой в этом контексте некому исполниться.
Во-вторых, `/trade-in/_next/image` на публичном домене закрыт
периметром Caddy намеренно (см. комментарий у
`handle /trade-in/_next/static/*` в caddy/sites/apps.caddy)
оптимизатор картинок анонимам не отдаётся. В-третьих, это не
изображение, а счётчик визита 1×1: оптимизировать в нём нечего,
а любая перезапись URL сломала бы сам замер. */}
<noscript>
<div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`https://mc.yandex.ru/watch/${YM_ID}`}
style={{ position: "absolute", left: "-9999px" }}
alt=""
/>
</div>
</noscript>
</>
)}
{GA_ID && (
<>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
strategy="afterInteractive"
/>
{/* Отдельный тег, а не children у тега выше: gtag.js должен
выполниться и объявить `window.dataLayer`/`gtag` ДО этого кода,
next/script гарантирует порядок между своими тегами одной
стратегии, но не порядок внутри одного children-блока с
внешним src. */}
<Script id="ga4-init" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${GA_ID}');
`}
</Script>
</>
)}
</>
);
}

View file

@ -25,6 +25,7 @@
import { useCallback, useEffect, useId, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { track } from "../../_lib/analytics";
import { COVERED_CITIES, PRIMARY_CITY } from "../../content";
import { describeCityExpectation, describeCoverage } from "../../coverage-copy";
import type { CoverageVerdict } from "../../coverage-copy";
@ -89,6 +90,18 @@ function failureCopy(error: unknown): { title: string; text: string } {
};
}
/**
* Короткий машиночитаемый тип ошибки для цели `calc_error`. НЕ полный текст
* и не сообщение исключения в аналитику персональные данные не попадают
* по построению (адрес в объекте ошибки не участвует), но и просто незачем:
* цель нужна как счётчик по категориям, а не как лог трассировки.
*/
function calcErrorKind(error: unknown): string {
if (error instanceof DOMException && error.name === "AbortError") return "timeout";
if (error instanceof PublicApiError) return error.kind === "rate-limited" ? "rate_limited" : error.kind;
return "unknown";
}
export function EstimateFlow() {
const addressId = useId();
const listboxId = useId();
@ -112,6 +125,9 @@ export function EstimateFlow() {
const areaRef = useRef<HTMLInputElement>(null);
const answerRef = useRef<HTMLDivElement>(null);
const coverageAbort = useRef<AbortController | null>(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}
/>

View file

@ -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 (
<div className={styles.repRoot}>
<ObjectHeader object={object} onEdit={onEdit} />

View file

@ -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 её текущую высоту/видимость через общий стейт) добавила бы
* связность между двумя независимыми клиентскими компонентами ради проблемы,
* которой не было ни разу договорились не делать.
*
* ССЫЛКА «ПОДРОБНЕЕ» ОБЫЧНЫЙ `<a>`, НЕ `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 (
<div
role="region"
aria-label="Уведомление об использовании файлов cookie"
className={`${styles.ckbRoot} ${dismissed ? styles.ckbHidden : ""}`}
// Та же логика, что у StickyCtaV3: пока плашка скрыта, её не должно
// быть и для скринридера/клавиатуры — Tab не должен уводить фокус в
// невидимую кнопку.
aria-hidden={dismissed || undefined}
inert={dismissed || undefined}
>
<div className={styles.ckbInner}>
<p className={styles.ckbText}>
Мы используем файлы cookie и сервисы веб-аналитики, чтобы понимать,
как посетители пользуются сайтом.{" "}
<a
className={styles.ckbLink}
href={`${PUBLIC_ROUTES.privacy}#cookies`}
>
Подробнее
</a>
</p>
<button type="button" className={styles.ckbBtn} onClick={handleDismiss}>
Понятно
</button>
</div>
</div>
);
}

View file

@ -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<HTMLInputElement>(null);
const floorRef = useRef<HTMLInputElement>(null);
const conditionGroupRef = useRef<HTMLDivElement>(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}

View file

@ -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<string, unknown>): 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 {
// См. докстринг выше: аналитика не должна ронять страницу.
}
}

View file

@ -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";

View file

@ -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). */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(ARTICLE_JSON_LD) }}
/>
<InnerHeader />
<main id="content" className={styles.artPage} tabIndex={-1}>
<nav className={styles.artCrumbs} aria-label="Хлебные крошки">

View file

@ -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() {

View file

@ -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() {

View file

@ -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 г.";
/**
* Сколько месяцев на нашей стороне хранится ссылка/строка оплаченного отчёта

View file

@ -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 {

View file

@ -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() {

View file

@ -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;
}

View file

@ -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 уйдёт пустой `<meta name="..." content="">`, а
* это хуже отсутствующего тега (площадки верификации трактуют его как
* невалидный, а не как «код ещё не выдан»). Поэтому собираем объект
* условно, а не подставляем `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({
Перейти к содержимому
</a>
{children}
{/* AnalyticsScripts/CookieBanner ВНУТРИ этого div, а не в корневом
`src/app/layout.tsx`, по двум разным причинам:
1. CookieBanner использует `--b2c-*` переменные, объявленные инлайном
именно на этом узле (b2cVars выше) вынеси баннер наружу, и var()
резолвится в пустоту (см. предупреждение в theme.ts про мёртвый мост).
2. Счётчик Метрики обязан жить ТОЛЬКО в публичном B2C-периметре. Если
смонтировать его в корневом layout, он утечёт и в закрытый B2B-контур
(/v2, /admin, /scrapers, /history) там нет анонимных посетителей,
считать нечего, а вот приватные маршруты сотрудников в вебвизор
Метрики попадать не должны. */}
<AnalyticsScripts />
<CookieBanner />
</div>
);
}

View file

@ -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 (см.

View file

@ -264,7 +264,7 @@ export default function MeraPublicPrivacyPage() {
<p>
7.1. Пользователь вправе в любой момент отозвать согласие на обработку
персональных данных, направив обращение в поддержку Оператора по
контактам, указанным в разделе 9 настоящей Политики. С момента
контактам, указанным в разделе 10 настоящей Политики. С момента
получения обращения Оператор прекращает использование номера телефона
пользователя для связи по соответствующей заявке.
</p>
@ -292,8 +292,46 @@ export default function MeraPublicPrivacyPage() {
соответствии со ст. 19 Федерального закона 152-ФЗ.
</p>
<h2 id="cookies">9. Файлы cookie и веб-аналитика</h2>
<p>
9.1. Для сбора статистики посещаемости и оценки качества работы
Сайта Оператор использует сервисы веб-аналитики: Яндекс.Метрика
(ООО «Яндекс») и Google Analytics 4 (Google LLC), обрабатывающие
данные по поручению Оператора. Указанные сервисы устанавливают в
браузере пользователя файлы cookie и собирают технические данные:
IP-адрес, идентификатор устройства/браузера и параметры сессии, в
том числе UTM-метки источника перехода на Сайт.
</p>
<p>
9.2. Данные, указанные в п. 9.1, используются исключительно для
статистики посещаемости и улучшения качества работы Сайта и не
используются Оператором для принятия каких-либо решений в отношении
конкретного посетителя.
</p>
<p>
9.3. Пользователь вправе отказаться от использования файлов cookie
средствами настроек своего браузера (блокировка cookie), а также
отдельно для Яндекс.Метрики воспользоваться официальным
механизмом отказа:{" "}
<a
className={styles.link}
href="https://yandex.ru/support/metrica/general/opt-out.html"
target="_blank"
rel="noreferrer"
>
yandex.ru/support/metrica/general/opt-out.html
</a>
. Отказ от cookie не ограничивает доступ к функциям Сайта.
</p>
<p>
9.4. Оператор данных, указанных в настоящем разделе, тот же, что
указан в разделе 2 настоящей Политики. Сроки хранения таких данных
определяются сервисами веб-аналитики самостоятельно и не превышают
сроков, указанных в разделе 6 настоящей Политики.
</p>
<h2>
9. Контакты Оператора по вопросам обработки персональных данных
10. Контакты Оператора по вопросам обработки персональных данных
</h2>
<p>
E-mail:{" "}

View file

@ -0,0 +1,86 @@
import type { MetadataRoute } from "next";
import { PUBLIC_ORIGIN, PUBLIC_ROUTES } from "./content";
/**
* sitemap.xml публичного сайта «МЕРА».
*
* ПОЧЕМУ ВЛОЖЕННЫЙ, А НЕ В КОРНЕ `app/`. В отличие от `robots.ts` (один на
* приложение, см. его шапку), sitemap лежит в этом сегменте, потому что весь
* его контент короткие адреса `PUBLIC_ROUTES`, а сам модуль импортирует из
* `./content`. Next отдаёт его по `/trade-in/mera-public/sitemap.xml`; Caddy
* рерайтит на него короткий `meraocenka.ru/sitemap.xml` тем же правилом, что
* и `robots.txt` (devops-агент, параллельная задача).
*
* `lastModified` ФИКСИРОВАННАЯ ДАТА-КОНСТАНТА, А НЕ `new Date()`. Next
* пытается статически пререндерить `sitemap.ts` на этапе сборки; `new Date()`
* в модульной области недетерминированный вызов, который либо запекает
* дату СБОРКИ (что вводит в заблуждение: страницы не менялись в день
* деплоя), либо, если Next решит считать функцию динамической из-за него,
* убирает файл из статического вывода без видимой причины. Дата открытия
* индексации честное значение: именно тогда весь список ссылок стал
* актуальным целиком. Двигать дату вперёд нужно вручную, вместе с реальным
* изменением состава/контента страниц.
*/
const LAST_MODIFIED = new Date("2026-09-10T00:00:00Z");
export default function sitemap(): MetadataRoute.Sitemap {
const url = (path: string) => `${PUBLIC_ORIGIN}${path}`;
return [
{
url: url(PUBLIC_ROUTES.home),
lastModified: LAST_MODIFIED,
changeFrequency: "weekly",
priority: 1,
},
{
url: url(PUBLIC_ROUTES.estimate),
lastModified: LAST_MODIFIED,
changeFrequency: "weekly",
priority: 0.9,
},
{
url: url(PUBLIC_ROUTES.articles),
lastModified: LAST_MODIFIED,
changeFrequency: "weekly",
priority: 0.7,
},
{
url: url(PUBLIC_ROUTES.articleHowTo),
lastModified: LAST_MODIFIED,
changeFrequency: "monthly",
priority: 0.6,
},
{
url: url(PUBLIC_ROUTES.business),
lastModified: LAST_MODIFIED,
changeFrequency: "monthly",
priority: 0.5,
},
{
url: url(PUBLIC_ROUTES.docs),
lastModified: LAST_MODIFIED,
changeFrequency: "monthly",
priority: 0.4,
},
{
url: url(PUBLIC_ROUTES.oferta),
lastModified: LAST_MODIFIED,
changeFrequency: "yearly",
priority: 0.3,
},
{
url: url(PUBLIC_ROUTES.refund),
lastModified: LAST_MODIFIED,
changeFrequency: "yearly",
priority: 0.3,
},
{
url: url(PUBLIC_ROUTES.privacy),
lastModified: LAST_MODIFIED,
changeFrequency: "yearly",
priority: 0.3,
},
];
}

View file

@ -0,0 +1,42 @@
import type { MetadataRoute } from "next";
import { PUBLIC_ORIGIN } from "./mera-public/content";
/**
* robots.txt для публичного сайта «МЕРА» (meraocenka.ru).
*
* ПОЧЕМУ ФАЙЛ ЛЕЖИТ В КОРНЕ `app/`, А НЕ В `app/mera-public/`. Next отдаёт
* `MetadataRoute.Robots` строго по адресу `/robots.txt` относительно
* `basePath` с basePath=/trade-in это `/trade-in/robots.txt`. Короткий
* `meraocenka.ru/robots.txt` (без `/trade-in`) раздаёт Caddy отдельным
* `handle` (делает devops-агент параллельно) он проксирует короткий путь на
* этот же маршрут.
*
* DISALLOW НАМЕРЕННО КОРОТКИЙ ОДНА СТРОКА, А НЕ ПЕРЕЧЕНЬ ЗАКРЫТОГО КОНТУРА.
*
* Первая редакция перечисляла здесь весь B2B-периметр: `/v2`, `/admin`,
* `/scrapers`, `/history`, `/cache`, `/versions`, `/login`, `/team`,
* `/sale-share`, `/ui-preview`. Выглядело как защита, работало как её
* противоположность. Ни один из этих путей на meraocenka.ru не отдаётся
* вовсе: site-блок домена белый список, всё вне его получает 404 (см.
* `handle { respond 404 }` в caddy/sites/apps.caddy). То есть запрещать
* тут нечего, а вот СПИСОК ИМЁН внутренних разделов robots.txt публикует
* всем включая сканеры, которые читают его первым делом именно ради такой
* карты. Периметр держит Caddy, а robots.txt файл для поисковых роботов,
* и лишнее знание в нём это чистый минус без плюса.
*
* `/api` остаётся: это единственное семейство путей, которое публичный домен
* действительно проксирует (`/trade-in/api/public/*`), здесь запрет
* осмысленный, а не декоративный. Индексировать JSON-ответы ручек подсказок
* адреса поисковику незачем.
*/
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/api"],
},
sitemap: `${PUBLIC_ORIGIN}/sitemap.xml`,
};
}

View file

@ -104,6 +104,19 @@ const PUBLIC_SHORT_PATHS = [
"/articles/kak-ocenit-kvartiru",
"/docs",
"/business",
// Машинные артефакты для поисковых роботов, а не страницы. React на них не
// рендерится вовсе — их отдают route handler'ы Next (`app/robots.ts` и
// `app/mera-public/sitemap.ts`), поэтому гвард на них физически не
// монтируется и practically эти две строки — no-op.
//
// И всё-таки они здесь. Гейт `RouteGuard.publicPaths.test.ts` держит
// инвариант «всё, что Caddy открыл наружу, гвард считает публичным»
// МЕХАНИЧЕСКИ, сверяя список с самим Caddyfile. Исключение из инварианта
// пришлось бы описывать отдельным списком, который начнёт протухать; две
// честные строки дешевле, и утверждение в них правдивое — эти адреса
// действительно публичные.
"/robots.txt",
"/sitemap.xml",
];
export function isPublicPath(rawPath: string): boolean {