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
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:
commit
aba07b581f
28 changed files with 1215 additions and 59 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/, а не аккуратность этого
|
||||
|
|
|
|||
|
|
@ -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 до бэкенда).
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) не
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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. Список тоже поимённый (регексп с захватом
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
79
tradein-mvp/frontend/src/app/mera-public/_lib/analytics.ts
Normal file
79
tradein-mvp/frontend/src/app/mera-public/_lib/analytics.ts
Normal 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 {
|
||||
// См. докстринг выше: аналитика не должна ронять страницу.
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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="Хлебные крошки">
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 г.";
|
||||
|
||||
/**
|
||||
* Сколько месяцев на нашей стороне хранится ссылка/строка оплаченного отчёта
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (см.
|
||||
|
|
|
|||
|
|
@ -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:{" "}
|
||||
|
|
|
|||
86
tradein-mvp/frontend/src/app/mera-public/sitemap.ts
Normal file
86
tradein-mvp/frontend/src/app/mera-public/sitemap.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
42
tradein-mvp/frontend/src/app/robots.ts
Normal file
42
tradein-mvp/frontend/src/app/robots.ts
Normal 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`,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue