feat(mera/b2c): публичный периметр МЕРЫ — свой API, отдельный экран оценки, короткие адреса #2913

Merged
lekss361 merged 6 commits from feat/mera-public-api into main 2026-08-16 07:25:24 +00:00
34 changed files with 2737 additions and 232 deletions

View file

@ -54,6 +54,15 @@ jobs:
- '.forgejo/workflows/ci-tradein.yml'
frontend:
- 'tradein-mvp/frontend/**'
# Caddyfile — по той же причине, что auth/** у бэкенда: он лежит в
# КОРНЕ репы, но его читает фронтовый тест
# (mera-public/__tests__/public-perimeter.test.ts) — тот сверяет,
# что каждый маршрут публичного сайта действительно раздаётся на
# meraocenka.ru. Без этой строки правка одного лишь Caddyfile не
# запускала бы НИ ОДИН гейт, и удаление короткого адреса из
# allowlist уехало бы на main зелёным — а на сайте кнопка «Проверить»
# стала бы ссылкой в 404.
- 'Caddyfile'
- '.forgejo/workflows/ci-tradein.yml'
browser:
# Сайдкар — сервис ВНЕ uv-воркспейса (tradein-mvp/pyproject.toml

View file

@ -65,6 +65,45 @@ jobs:
python3 scripts/check-workflow-ports.py --selftest
python3 scripts/check-workflow-ports.py
- name: "Guard: Caddyfile синтаксически валиден"
# Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит
# на КАЖДОМ PR, стоит секунды, падение блокирует merge.
#
# ЗАЧЕМ. До 16.08.2026 конфиг прокси не проверял НИКТО — ни один
# workflow не звал `caddy validate`/`adapt` (grep по .forgejo/). При
# этом deploy.yml применяет его не через `reload` (тот отказался бы
# принять битый конфиг и оставил бы старый работать), а через
# `up -d --force-recreate caddy`: синтаксическая ошибка уводит контейнер
# в crash-loop, и ложатся ВСЕ домены сразу — gendsgn.ru, meraocenka.ru,
# obsidian, status. То есть цена опечатки в этом файле — полный
# даунтайм, а гейта на неё не было.
#
# `docker cp`, а НЕ `-v "$PWD:/etc/caddy"`. Job сам исполняется внутри
# контейнера, и `docker run` создаёт КОНТЕЙНЕР-БРАТ на том же демоне:
# путь в `-v` резолвится на ХОСТЕ, а `$PWD` — это путь внутри job-
# контейнера, которого на хосте нет. Первая версия этого шага так и
# упала: `open /etc/caddy/Caddyfile: no such file or directory`.
# Копирование не зависит от того, как смонтирован workspace.
#
# Образ тот же `caddy:2`, что в docker-compose.prod.yml — проверяем ровно
# тем парсером, который будет читать конфиг на проде.
#
# Копируем и `caddy/` — Caddyfile делает `import caddy/users.caddy.snippet`,
# и без него validate упадёт на импорте (файл в репозитории есть).
#
# Плейсхолдеры окружения ({env.*}) при validate резолвятся в пустую
# строку — это нормально, синтаксис от их значений не зависит.
run: |
set -euo pipefail
cid=$(docker create -w /work caddy:2 \
caddy validate --config /work/Caddyfile --adapter caddyfile)
docker cp Caddyfile "$cid:/work/Caddyfile"
docker cp caddy "$cid:/work/caddy"
rc=0
docker start -a "$cid" || rc=$?
docker rm -f "$cid" >/dev/null
exit "$rc"
- name: "Guard: блокирующий DDL без lock_timeout (#2752)"
# Тем же шагом-соседом и по той же причине: гейт бежит на КАЖДОМ PR,
# включая tradein-only (у ci.yml нет paths-фильтра на уровне workflow —

141
Caddyfile
View file

@ -250,44 +250,105 @@ meraocenka.ru {
}
}
# Короткие адреса юридических документов. Именно они напечатаны ВНУТРИ
# самих документов (оферта ссылается на meraocenka.ru/refund, политика
# возврата — на meraocenka.ru/oferta) и уходят в заявку эквайеру, поэтому
# обязаны резолвиться сами по себе, а не только длинным
# /trade-in/mera-public/<doc>. Обратное направление тоже рабочее: длинный
# путь ловит handle ниже — навигация внутри сайта ходит по нему, потому что
# то же поддерево открывается и с gendsgn.ru/trade-in/mera-public, где
# короткого /oferta нет.
# Короткие адреса страниц публичного сайта. Именно они напечатаны ВНУТРИ
# юридических документов (оферта ссылается на meraocenka.ru/refund,
# политика возврата — на meraocenka.ru/oferta) и уходят в заявку эквайеру,
# поэтому обязаны резолвиться сами по себе.
#
# ЭТО ЕДИНСТВЕННЫЙ ВИД АДРЕСА, КОТОРЫЙ ВИДИТ ЧЕЛОВЕК (решение владельца,
# 15.08.2026). Раньше навигация внутри сайта ходила по длинным
# /trade-in/mera-public/... — так короткие адреса и длинные существовали
# параллельно. Теперь длинные отдают 301 на короткие (см. handle ниже), а
# ссылки на страницах эмитятся сразу короткими (см. `PublicLink` во
# фронте — обычный <a>, потому что next/link подставляет basePath).
#
# `rewrite`, а не `redir`: адрес в строке браузера должен остаться коротким
# — модератор эквайера открывает ссылку из заявки и видит ровно тот URL,
# который в ней указан. Каноничность для поисковиков задана отдельно, через
# `alternates.canonical` на каждой из трёх страниц.
# `alternates.canonical` на каждой странице.
#
# Пути перечислены поимённо, а не шаблоном: allowlist-by-default этого
# site-блока — часть периметра (#2545), и превращать его в «любой корневой
# путь проксируется» ради трёх страниц нельзя.
@meraLegalDocs path /oferta /refund /privacy
handle @meraLegalDocs {
# путь проксируется» нельзя. Новая публичная страница = новая строка здесь
# (и проверка в scripts/smoke-mera-perimeter.sh).
#
# NB: корень «/» СЮДА НЕ ВХОДИТ — он выше, отдельным handle. Причина
# техническая: здесь цель собирается как `/trade-in/mera-public{path}`, а
# для «/» это дало бы `/trade-in/mera-public/` со слэшем на конце. Next при
# `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант
# без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на
# «/», и запрос закольцуется.
# `/v3` — ВРЕМЕННОЕ превью второго варианта дизайна, а не публичная
# страница: владелец сравнивает его с текущим лэндингом. Оно `noindex` и
# ни с одной страницы на него нет ссылки. Убрать эту строку в тот момент,
# когда вариант выберут и он станет корнем.
@meraPages path /estimate /oferta /refund /privacy /v3
handle @meraPages {
rewrite * /trade-in/mera-public{path}
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
}
}
# Подстраницы САМОГО лэндинга. Нужны с момента мержа #2615: футер ссылается
# на политику обработки ПДн через next/link (`PRIVACY_PATH`), а Next с
# basePath эмитит её как /trade-in/mera-public/privacy. Без этого handle
# ссылка уходила бы в catch-all 404 ниже — то есть обязательный по 152-ФЗ
# документ был бы недоступен с публичной страницы.
# Тот же адрес со слэшем на конце → 301 на канонический вид без слэша.
# Слэш дописывают мессенджеры, автолинкификаторы и сами люди, а матчер
# `path` требует точного совпадения — без этой ветки `/oferta/` отдавал бы
# голый 404 (так было и до этого PR, с момента #2615). Заодно это
# замыкает цепочку для длинных адресов со слэшем: они приходят на короткий
# со слэшем и здесь нормализуются.
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|v3)/$
handle @meraShortSlash {
redir * /{re.shortslash.1} permanent
}
# Длинные адреса поддерева → 301 на короткие. Один канонический адрес у
# страницы, а не два работающих.
#
# Matcher намеренно узкий — ровно поддерево лэндинга, НЕ /trade-in/*.
# B2B-дерево (/trade-in/v2, /trade-in/api/*, /trade-in/admin/*, /history)
# под него не подпадает и по-прежнему отдаёт 404. Регресс-тест на это —
# в scripts/smoke-mera-perimeter.sh.
handle /trade-in/mera-public/* {
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
# Зачем вообще оставлять длинные: они уже разошлись — ими ссылались подвал
# и шапка до 15.08.2026, они могли попасть в закладки и в переписку. 301
# (а не 404) сохраняет эти ссылки живыми и заодно передаёт поисковикам, что
# канонический адрес один.
#
# ЗДЕСЬ ЖЕ ЧИНИТСЯ БАГ: прежний матчер был `/trade-in/mera-public/*` — со
# слэшем и звёздочкой, поэтому ГОЛЫЙ `/trade-in/mera-public` (без хвоста)
# под него не подпадал и падал в catch-all 404. Ровно на этот адрес вела
# ссылка «Главная» в подвале v3, то есть она была мёртвой (замер на проде
# 15.08.2026). Первый матчер ниже ловит обе формы — со слэшем и без.
#
# `redir * <куда>`, а НЕ `redir <куда>`. Первый аргумент директивы, если он
# начинается со слэша, Caddy разбирает как inline path-matcher — то есть
# `redir / permanent` означает «для пути / редиректить на permanent», а не
# «редиректить на /». Проверено на живом Caddy: без `*` длинные адреса
# отдавали пустой 200 (матчер не совпадал, директива не срабатывала, тело
# пустое) — хуже, чем 404, потому что выглядит как рабочая пустая страница.
@meraLongRoot path /trade-in/mera-public /trade-in/mera-public/
handle @meraLongRoot {
redir * / permanent
}
# Длинные адреса страниц → короткие. Пути перечислены ПОИМЁННО, обе формы
# (со слэшем на конце и без) — не шаблоном и не регекспом.
#
# ПОЧЕМУ НЕ РЕГЕКСП С ЗАХВАТОМ ХВОСТА. Очевидный вариант
# `path_regexp ^/trade-in/mera-public/(.+)$` + `redir /{re.…1}` — открытый
# редирект. Захват берётся из РАСКОДИРОВАННОГО пути, поэтому
# `/trade-in/mera-public/%5Cevil.example/pay` даёт цель `/\evil.example/pay`,
# а браузеры трактуют `/\` как `//` — Location уводит на ЧУЖОЙ хост. Это
# готовая фишинговая заготовка с домена, который напечатан внутри оферты и
# уходит модератору эквайера. Проверено на живом Caddy, воспроизводится.
# С поимённым списком такой путь просто не матчится и падает в 404 ниже.
#
# ПОЧЕМУ `uri strip_prefix` + `{uri}`, А НЕ `redir /oferta` в каждой ветке.
# `{uri}` переносит query-строку: уже размещённые ссылки с UTM-метками
# после редиректа не теряют атрибуцию. Обёртка `route` обязательна —
# порядок директив внутри `handle` определяет Caddy, и без неё `redir`
# выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу
# (бесконечный цикл; поймано на локальном стенде).
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/v3 /trade-in/mera-public/v3/
handle @meraLongPages {
route {
uri strip_prefix /trade-in/mera-public
redir * {uri} permanent
}
}
@ -316,8 +377,38 @@ meraocenka.ru {
}
}
# Публичный API МЕРЫ — ЕДИНСТВЕННЫЙ путь этого домена, доходящий до
# бэкенда. Под /api/public/ по определению не лежит ничего закрытого:
# гарантию даёт структура пакета app/api/public/, а не аккуратность этого
# матчера (разбор — в app/api/public/mera.py). Матчер тем не менее узкий:
# /trade-in/api/v1/* по-прежнему падает в catch-all 404 ниже.
#
# ПОЧЕМУ ПУТЬ С ПРЕФИКСОМ /trade-in, А НЕ КОРОТКИЙ /api/public/*.
# Тот же URL обязан работать и на gendsgn.ru/trade-in/mera-public — ту же
# страницу оттуда открывают для QA (там она за basic_auth). На gendsgn.ru
# корневой /api/* уже занят бэкендом Site Finder, то есть короткий путь
# потребовал бы там ВТОРОГО handle, выигрывающего у существующего по
# специфичности — то есть работоспособность публичной формы зависела бы от
# порядка сортировки матчеров в чужом site-блоке. С префиксом /trade-in
# запрос ловит уже существующий `handle /trade-in/api/*` (:123), и здесь
# нужен ровно один новый handle. Цена — префикс /trade-in виден в devtools
# публичного домена; он там и так виден на всех чанках Next (basePath).
#
# strip_prefix — та же причина, что у B2B-хопа (:127): basePath Next'а не
# часть маршрута FastAPI.
#
# X-Internal-Auth-Secret здесь НЕ подставляется (в отличие от :130):
# публичные ручки его не проверяют, а инжектить внутренний секрет в хоп с
# анонимного домена — расширять доверие без нужды.
handle /trade-in/api/public/* {
uri strip_prefix /trade-in
reverse_proxy tradein-backend:8000 {
header_up -X-Authenticated-User
}
}
# Allowlist-by-default: любой другой путь (включая B2B — /v2, /admin,
# /scrapers/*, /trade-in/api/*, /history, ...) — 404, НЕ проксируется.
# /scrapers/*, /trade-in/api/v1/*, /history, ...) — 404, НЕ проксируется.
handle {
respond 404
}

View file

@ -3,10 +3,16 @@
#
# Проверяет инварианты периметра (см. корневой Caddyfile):
# 1. meraocenka.ru отдаёт 200 анонимно (публичный лэндинг).
# 1b. Подстраница лэндинга /trade-in/mera-public/privacy отдаёт 200 —
# политика ПДн, на которую ссылается футер.
# 1b. Длинный адрес /trade-in/mera-public/privacy отдаёт 301 на короткий
# (у страницы один канонический адрес, старые ссылки не ломаются).
# 1c. Короткие адреса /oferta, /refund, /privacy отдают 200 — эти URL
# напечатаны внутри самих юридических документов и уходят эквайеру.
# 1d. /estimate отдаёт 200 — экран проверки, куда ведут все кнопки лэндинга.
# 1e. Длинные адреса поддерева отдают 301 на короткие (включая ГОЛЫЙ
# /trade-in/mera-public — прежний матчер его не ловил, «Главная» в подвале
# вела в 404).
# 2d. Публичный API /api/public/mera/* доступен анонимно, а /api/v1/* на
# публичном домене по-прежнему 404.
# 2. meraocenka.ru/v2 и /trade-in/v2, /trade-in/api/* (B2B-пути) отдают 404 —
# allowlist-by-default, НЕ были случайно проброшены на B2B-дерево
# tradein-frontend. Проверяются обе формы — с basePath-префиксом и без.
@ -43,16 +49,31 @@ check() {
fi
}
check_post() {
local desc="$1" url="$2" body="$3" expected="$4"
local code
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 \
-X POST -H 'Content-Type: application/json' -d "$body" "$url" 2>/dev/null)
if [ "$code" = "$expected" ]; then
echo "PASS: $desc ($url -> $code)"
else
echo "FAIL: $desc ($url -> got '${code:-<no response>}', expected $expected)"
fail=1
fi
}
echo "== МЕРА B2C perimeter smoke (ЭТАП 1) =="
# 1. Публичный домен отдаёт 200 анонимно.
check "meraocenka.ru root — public 200" "$BASE_MERA/" 200
# 1b. Подстраница лэндинга (политика ПДн) доступна — на неё ссылается футер.
# Путь приезжает с basePath: next/link + basePath=/trade-in эмитит именно
# /trade-in/mera-public/privacy. Если этот handle выпадет из Caddyfile,
# обязательный по 152-ФЗ документ станет недоступен с публичной страницы.
check "meraocenka.ru privacy — public 200" "$BASE_MERA/trade-in/mera-public/privacy" 200
# 1b. Подстраница лэндинга по ДЛИННОМУ адресу теперь отдаёт 301 на короткий, а
# не 200: с 15.08.2026 у публичной страницы один канонический адрес.
# Проверка осталась именно здесь, потому что раньше она сторожила
# доступность обязательного по 152-ФЗ документа — теперь сторожит, что при
# переходе на короткие адреса длинные не превратились в 404 (тогда бы
# сломались уже разосланные ссылки).
check "meraocenka.ru длинная privacy — 301 на короткую" "$BASE_MERA/trade-in/mera-public/privacy" 301
# 1c. Короткие адреса юридических документов. Это НЕ дубль проверки 1b: именно
# эти три URL напечатаны внутри самих документов и уходят в заявку
@ -63,6 +84,18 @@ check "meraocenka.ru/oferta — public 200" "$BASE_MERA/oferta" 200
check "meraocenka.ru/refund — public 200" "$BASE_MERA/refund" 200
check "meraocenka.ru/privacy — public 200" "$BASE_MERA/privacy" 200
# 1d. Экран проверки квартиры — короткий адрес, на который ведут все кнопки
# лэндинга. Отвалится handle — кнопки «Проверить» станут ссылками в 404.
check "meraocenka.ru/estimate — public 200" "$BASE_MERA/estimate" 200
# 1e. Длинные адреса поддерева отдают 301 на короткие: у страницы один
# канонический адрес, а старые ссылки и закладки продолжают работать.
# ГОЛЫЙ /trade-in/mera-public — регресс на баг 15.08.2026: прежний матчер
# `/trade-in/mera-public/*` эту форму не ловил, и ссылка «Главная» в
# подвале v3 вела в 404.
check "meraocenka.ru длинный корень — 301 на /" "$BASE_MERA/trade-in/mera-public" 301
check "meraocenka.ru длинная оферта — 301 на /oferta" "$BASE_MERA/trade-in/mera-public/oferta" 301
# 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401.
check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404
@ -79,6 +112,26 @@ check "meraocenka.ru/trade-in/api/* — must 404 (не проксируем API)
# Ловит расширение матчера обратно до `/trade-in/_next/*`.
check "meraocenka.ru/_next/image — must 404 (не открываем оптимизатор)" "$BASE_MERA/trade-in/_next/image?url=%2Ftest.png&w=64&q=75" 404
# 2d. Публичный API МЕРЫ (#2911). Ровно две ручки под /api/public/mera/*
# доступны анонимно на обоих доменах; ВЕСЬ /api/v1/* на публичном домене
# по-прежнему 404.
#
# Пара проверок ниже неразделима: первая доказывает, что форма вообще
# работает, вторая — что новый handle не расширил периметр до
# `/trade-in/api/*`. Зелёная только первая = API открыт целиком и тест это
# пропустил (ровно та ошибка, ради которой в Caddyfile выбран отдельный
# префикс, а не поимённый проброс v1-путей).
check_post "meraocenka.ru public suggest — 200 anonymous" "$BASE_MERA/trade-in/api/public/mera/suggest" '{"q":"Малышева"}' 200
check_post "meraocenka.ru public coverage — 200 anonymous" "$BASE_MERA/trade-in/api/public/mera/coverage" '{"lat":56.838,"lon":60.597,"rooms":2,"area_m2":54}' 200
check "meraocenka.ru v1 geocode — must stay 404" "$BASE_MERA/trade-in/api/v1/geocode/suggest?q=test" 404
check "meraocenka.ru v1 coverage — must stay 404" "$BASE_MERA/trade-in/api/v1/trade-in/coverage" 404
# Тот же публичный путь на gendsgn.ru: страницу лэндинга открывают и оттуда
# (QA за basic_auth), поэтому URL у формы один на оба домена. Здесь он
# проходит через уже существующий `handle /trade-in/api/*` — проверка ловит
# регресс в rbac._PUBLIC_PATHS (стало бы 401), а не в Caddyfile.
check_post "gendsgn.ru public suggest — 200 anonymous" "$BASE_MAIN/trade-in/api/public/mera/suggest" '{"q":"Малышева"}' 200
# 3. B2B-данные trade-in по-прежнему закрыты анониму.
#
# ВНИМАНИЕ: проверять СТРАНИЦУ (/trade-in/v2) больше нельзя — она отдаёт 200.

View file

@ -0,0 +1,7 @@
"""Публичный (анонимный) API — поверхность, открытая на meraocenka.ru.
Отдельный пакет, а не ещё один модуль в `app/api/v1/`, намеренно: всё, что
лежит здесь, доступно БЕЗ идентичности, поэтому периметр должен читаться по
дереву каталогов, а не по списку исключений в middleware. Разбор в
`app/api/public/mera.py`.
"""

View file

@ -0,0 +1,250 @@
"""Публичный API МЕРЫ (B2C, meraocenka.ru) — анонимный, ровно две ручки.
ЗАЧЕМ ОТДЕЛЬНЫЙ ПРЕФИКС, А НЕ ОТКРЫТИЕ КУСКА /api/v1/*
-------------------------------------------------------
На `meraocenka.ru` действует allowlist-by-default: Caddy проксирует поимённо
перечисленные пути, всё остальное 404 (см. корневой Caddyfile, site-блок
meraocenka.ru; регресс scripts/smoke-mera-perimeter.sh). Чтобы открыть там
API, нужно было выбрать одно из двух:
(а) пробросить `/trade-in/api/v1/trade-in/coverage` и `.../geocode/suggest`
поимённо периметр остаётся узким, но одна опечатка в matcher'е
(`/trade-in/api/*` вместо точного пути) открывает наружу ВЕСЬ v1: ~20
ручек, включая PDF расчётов, фотографии объектов, историю и админку;
(б) завести отдельный префикс, под которым по определению не может лежать
ничего закрытого, и пробрасывать его целиком.
Выбрано (б). Разница не в удобстве, а в цене ошибки: при (а) безопасность
периметра держится на аккуратности матчера, при (б) на структуре кода.
Добавить сюда ручку с приватными данными нужно СПЕЦИАЛЬНО (положить файл в
`app/api/public/`), случайно нельзя.
Тот же принцип, что уже применён на фронте: публичный лэндинг вынесен в
`app/mera-public/` с guard-скриптом на граф импортов, а не помечен флагом
внутри общего дерева.
АНОНИМНОСТЬ
-----------
`rbac_guard` (app/core/rbac.py) требует `X-Authenticated-User` для любого
non-public пути. Обе ручки перечислены в `_PUBLIC_PATHS` ТОЧНЫМИ строками
не префиксом: множество там frozenset с проверкой `path in ...`, и
добавление префиксной ветки ради двух путей расширило бы механизм, которым
пользуется весь бэкенд, ради одной фичи.
ЧТО ЭТИ РУЧКИ НЕ ДЕЛАЮТ
-----------------------
Ни одна из них не пишет в БД строк с адресом пользователя: `/coverage`
чистое чтение (один SELECT), `/suggest` прокси автокомплита. Это не
случайность, а условие, при котором публичная форма может работать ДО того,
как появится контур согласия 152-ФЗ (issue #2895: сегодня адрес физлица
попадает в `trade_in_estimates` раньше любого согласия, а пути удаления
данных в бэкенде нет). Платный расчёт, который писать будет, открывается
отдельно и только вместе с этим контуром.
БЮДЖЕТЫ
-------
Общий `RateLimitMiddleware` (300/60с на IP) здесь недостаточен: `/suggest`
через DaData-тир геокодера платный внешний вызов, то есть абуз стоит денег,
а не только CPU. Поэтому у каждой ручки свой, заведомо более узкий per-IP
бюджет поверх общего тот же приём, что у анонимного чата поддержки
(app/api/v1/support.py, `_anon_ip_limiter`).
Лимитеры in-process: при нескольких репликах бэкенда бюджет умножится на их
число. Сейчас реплика одна (docker-compose, tradein-backend), что и делает
допущение верным; при масштабировании выносить в Redis (issue заводить
тогда же, не раньше: преждевременный вынос добавит зависимость без выигрыша).
"""
from __future__ import annotations
import asyncio
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.v1.geocode import SuggestResponse, suggest_addresses
from app.api.v1.trade_in import coverage_probe
from app.core.db import get_db
from app.core.public_request import install_address_log_redaction, public_request_scope
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
from app.schemas.trade_in import CoverageProbeInput, CoverageProbeResponse
logger = logging.getLogger(__name__)
# Публичная форма обещает, что введённый адрес нигде не сохраняется. По базам
# это так, по журналам не было — геокодер печатал запрос открытым текстом, а
# прод пишет stdout в persistent journald. Ставим редакцию логов в момент
# импорта модуля (его импортирует app/main.py) — то есть ровно тогда, когда
# публичные ручки вообще появляются в приложении. Разбор — в
# app/core/public_request.py.
install_address_log_redaction()
router = APIRouter()
# Бюджеты подобраны от живого сценария, а не «на глаз»: человек набирает адрес
# с debounce'ом — это единицы запросов на один адрес, поэтому 40/мин хватает
# на несколько попыток подряд и режет перебор словарём. Проба покрытия — шаг
# осознанный (нажатие кнопки), 15/мин с запасом покрывает «поправил площадь,
# нажал ещё раз».
_SUGGEST_LIMIT = 20
_COVERAGE_LIMIT = 15
_WINDOW_S = 60.0
_suggest_limiter = SlidingWindowLimiter(limit=_SUGGEST_LIMIT, window_s=_WINDOW_S)
_coverage_limiter = SlidingWindowLimiter(limit=_COVERAGE_LIMIT, window_s=_WINDOW_S)
# ── Общий суточный потолок публичных подсказок ──────────────────────────────
#
# Per-IP окна одного клиента ограничивают, но не ограничивают СУММУ. Считаем:
# 20 запросов/мин с одного адреса — это 28 800 в сутки, а весь бесплатный тир
# DaData у проекта — 10 000 в сутки И ОН ОБЩИЙ с закрытым контуром. То есть без
# этого потолка один настойчивый клиент (или один скрипт) за несколько часов
# выедает квоту, и подсказки перестают работать у ПЛАТЯЩИХ пилотов, а не только
# у него. Найдено состязательным ревью и подтверждено на проде: достаточно
# упомянуть в запросе не-екатеринбургский город, чтобы локальный кадастровый
# тир отключился и запрос гарантированно ушёл во внешний сервис.
#
# 2000/сутки — заведомо меньше десятой доли тира: публичная форма не должна
# уметь навредить закрытому контуру в принципе. Порог достижим только абузом
# (живой посетитель тратит единицы запросов на адрес), поэтому исчерпание —
# сигнал, а не штатный режим: логируем ошибкой.
_DAILY_SUGGEST_BUDGET = 2000
_daily_suggest_limiter = SlidingWindowLimiter(limit=_DAILY_SUGGEST_BUDGET, window_s=86_400.0)
_GLOBAL_KEY = "public-suggest"
# ── Потолок одновременных подсказок ─────────────────────────────────────────
#
# Кадастровый тир геокодера уходит в FDW-скан ЧУЖОЙ базы (gendesign) и на
# коротком вводе занимает около секунды, всё это время удерживая соединение из
# пула. Пул общий с закрытым контуром и невелик (дефолт SQLAlchemy 5+10), так
# что полтора десятка одновременных публичных подсказок способны положить
# B2B-запросы в том же процессе — при том, что per-IP лимиты каждого из них
# формально соблюдены.
#
# Ждём слот недолго и отвечаем 429, а не копим очередь: очередь под нагрузкой
# превращается в те же занятые соединения плюс растущий таймаут у клиента.
_SUGGEST_CONCURRENCY = 4
_SUGGEST_SLOT_WAIT_S = 2.0
_suggest_slots = asyncio.Semaphore(_SUGGEST_CONCURRENCY)
def _enforce(limiter: SlidingWindowLimiter, request: Request, what: str) -> None:
"""429 при превышении per-IP бюджета. Попытку регистрируем ДО работы ручки.
В отличие от отправки сообщения в поддержку (там `record()` только на
успех, чтобы неудача не съедала бюджет), здесь считаем каждую попытку:
внешний вызов геокодера тратится и на запросе, который вернёт пусто,
иначе перебор мусорными строками не стоил бы атакующему ничего.
"""
ip = _client_ip(request)
retry_after = limiter.retry_after(ip)
if retry_after is not None:
logger.info("public mera %s rate-limited for %s", what, ip)
raise HTTPException(
status_code=429,
detail="Слишком много запросов. Попробуйте через минуту.",
headers={"Retry-After": str(int(retry_after) + 1)},
)
limiter.record(ip)
class PublicSuggestInput(BaseModel):
"""Вход публичного автокомплита.
Телом, а не query-параметрами см. `public_suggest`.
"""
q: str = Field(min_length=2, max_length=200)
limit: int = Field(default=8, ge=1, le=10)
city_hint: str | None = Field(default=None, max_length=100)
@router.post("/suggest", response_model=SuggestResponse)
async def public_suggest(
request: Request,
payload: PublicSuggestInput,
db: Annotated[Session, Depends(get_db)],
) -> SuggestResponse:
"""Автокомплит адреса для публичной формы (Свердловская область).
ПОЧЕМУ POST У ЧИТАЮЩЕЙ РУЧКИ. Каноничнее был бы GET с `?q=`. Но на
публичном домене включён access-лог (`/var/log/caddy/meraocenka.ru.log`), а
он пишет URI целиком то есть адрес квартиры лёг бы в файл рядом с IP
посетителя. Мы публично обещаем на этой же странице, что введённый адрес
нигде не сохраняем; лог это сохранение. Тело запроса в лог не попадает,
поэтому обещание остаётся правдой без правки конфигурации логирования (её
легко потерять при следующем рефакторинге Caddyfile а тип запроса
потерять нельзя, сломается сразу и заметно).
Тот же довод, что у черновика с лэндинга: он едет через sessionStorage, а
не через query-параметры (frontend `estimate-draft.ts`).
Делегирует В ТУ ЖЕ функцию, что обслуживает B2B-экран
(`app.api.v1.geocode.suggest_addresses`), а не повторяет её логику:
публичная форма обязана резолвить адрес ровно так же, как платный расчёт,
иначе аноним выберет дом, которого потом «не окажется».
Отличие от v1 ровно одно потолок `limit` 10 вместо 15: выдача сверх
десятка в публичном UI не показывается, а каждый лишний кандидат может
стоить внешнего вызова.
"""
_enforce(_suggest_limiter, request, "suggest")
# Суточный потолок — ПОСЛЕ per-IP: сначала отсекаем одиночного абузера его
# собственным лимитом, и только оставшееся считаем в общий бюджет.
daily_retry = _daily_suggest_limiter.retry_after(_GLOBAL_KEY)
if daily_retry is not None:
logger.error(
"публичные подсказки исчерпали суточный бюджет (%d) — квота геокодера "
"защищена, но форма на лэндинге сейчас без автокомплита",
_DAILY_SUGGEST_BUDGET,
)
raise HTTPException(
status_code=429,
detail="Подсказки адреса временно недоступны. Введите адрес полностью.",
headers={"Retry-After": str(int(daily_retry) + 1)},
)
_daily_suggest_limiter.record(_GLOBAL_KEY)
try:
await asyncio.wait_for(_suggest_slots.acquire(), timeout=_SUGGEST_SLOT_WAIT_S)
except TimeoutError:
raise HTTPException(
status_code=429,
detail="Сервис сейчас занят. Попробуйте ещё раз через несколько секунд.",
headers={"Retry-After": "5"},
) from None
try:
# Пометка публичного запроса нужна ровно здесь: внутри `suggest_addresses`
# геокодер логирует введённую строку, а публичная форма обещает, что
# адрес не попадает в журналы.
with public_request_scope():
return await suggest_addresses(
q=payload.q, limit=payload.limit, db=db, city_hint=payload.city_hint
)
finally:
_suggest_slots.release()
@router.post("/coverage", response_model=CoverageProbeResponse)
def public_coverage(
request: Request,
payload: CoverageProbeInput,
db: Annotated[Session, Depends(get_db)],
) -> CoverageProbeResponse:
"""Бесплатная проба покрытия (issue #2894) для публичной формы.
Делегирует в `app.api.v1.trade_in.coverage_probe` ту же функцию, что
вызывает закрытый контур. Копии SQL здесь нет намеренно: разбор #2894
показал, что стоит когорте пробы разойтись с когортой платного расчёта
проба честно отвечает «есть данные» там, где расчёт увидит ноль.
Ответ не содержит ни одной цены (см. `CoverageProbeResponse`) бесплатный
шаг доказывает наличие данных, цену продаёт платный.
"""
_enforce(_coverage_limiter, request, "coverage")
return coverage_probe(payload=payload, db=db)

View file

@ -0,0 +1,97 @@
"""Пометка «этот запрос пришёл из публичной формы» и её единственное следствие:
адрес, который ввёл аноним, не попадает в журналы.
ЗАЧЕМ ЭТО СУЩЕСТВУЕТ
--------------------
На `meraocenka.ru/estimate` и в политике обработки ПДн сказано, что введённый
адрес нигде не сохраняется. По базам данных это правда (обе публичные ручки
только читают), а по журналам не было: геокодер логирует запрос открытым
текстом на каждый вызов, например
INFO app.services.dadata: dadata suggest: 'онуфриева 24' 5 вариантов
Прод пишет stdout контейнеров в journald с persistent-хранилищем
(`tradein-mvp/docker-compose.prod.yml`), то есть строка ложится на диск и живёт
там неделями. Рядом, в access-логе Caddy, лежит IP того же запроса с той же
меткой времени то есть адрес квартиры фактически сохранён и сопоставим с
человеком. Ровно то, что публичная страница обещает не делать.
Найдено состязательным ревью PR публичного периметра (16.08.2026) и
воспроизведено на проде, а не выведено из чтения кода.
ПОЧЕМУ ФИЛЬТР, А НЕ ПРАВКА КАЖДОГО ВЫЗОВА logger
------------------------------------------------
Мест, где адрес попадает в лог, много (`app/services/dadata.py`,
`app/services/geocoder.py` успех, пустая выдача, сетевая ошибка, таймаут,
кадастровый фолбэк), и любое новое добавится незаметно. Обещание не должно
зависеть от того, вспомнил ли автор следующей правки про эту страницу.
Фильтр единственная точка, которая закрывает и уже написанное, и будущее.
ПОЧЕМУ contextvar
-----------------
Публичный и закрытый контуры обслуживает ОДИН процесс, и один и тот же
`suggest()` вызывают оба. Различить их можно только по текущему запросу.
`ContextVar` то, что переживает `await` и копируется в `asyncio.to_thread`
(им геокодер уходит в синхронный кадастровый тир), в отличие от глобального
флага, который в конкурентной обработке принадлежал бы соседнему запросу.
Для B2B-трафика ничего не меняется: там флаг не выставлен, логи прежние они
нужны, чтобы разбирать жалобы пилотов на подсказки.
"""
from __future__ import annotations
import logging
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar
#: Истинно, пока обрабатывается запрос анонимной публичной формы.
is_public_request: ContextVar[bool] = ContextVar("mera_is_public_request", default=False)
#: Что видно в журнале вместо сообщения. Уровень и логгер сохраняются — по ним
#: по-прежнему видно, что вызов был и чем закончился.
REDACTED_MESSAGE = "<публичный запрос МЕРЫ: содержимое скрыто>"
#: Логгеры, чьи записи могут содержать введённый адрес.
ADDRESS_LOGGERS = ("app.services.dadata", "app.services.geocoder")
@contextmanager
def public_request_scope() -> Iterator[None]:
"""Помечает текущий запрос публичным на время работы блока."""
token = is_public_request.set(True)
try:
yield
finally:
is_public_request.reset(token)
class RedactPublicAddressFilter(logging.Filter):
"""Заменяет сообщение целиком, пока обрабатывается публичный запрос.
Целиком, а не по ключам: в шаблонах сообщений адрес стоит рядом с
безобидными аргументами (`"%r%d вариантов"`), и отличить их друг от
друга внутри фильтра нельзя. Терять текст сообщения на публичном пути
дешевле, чем хранить адреса; на закрытом контуре текст остаётся полным.
"""
def filter(self, record: logging.LogRecord) -> bool:
if is_public_request.get():
record.msg = REDACTED_MESSAGE
record.args = ()
return True
def install_address_log_redaction() -> None:
"""Вешает фильтр на логгеры, видящие адрес. Идемпотентно.
Фильтр ставится на КОНКРЕТНЫЕ логгеры, а не на корневой хендлер: фильтры
логгера применяются к записям этого логгера, а не ко всему, что через
хендлер проходит, то есть посторонние сообщения (пул соединений, старт
приложения) во время публичного запроса не пострадают.
"""
for name in ADDRESS_LOGGERS:
logger = logging.getLogger(name)
if not any(isinstance(f, RedactPublicAddressFilter) for f in logger.filters):
logger.addFilter(RedactPublicAddressFilter())

View file

@ -92,6 +92,20 @@ _PUBLIC_PATHS = frozenset(
# это query-параметр `secret`, который проверяет сам хендлер
# (secrets.compare_digest против TRADEIN_INTERNAL_AUTH_SECRET).
"/api/v1/trade-in/ops/glitchtip-webhook",
# Публичный B2C-периметр МЕРЫ (meraocenka.ru): у посетителя лендинга
# идентичности нет и не будет — Caddy на этом домене вообще без
# basic_auth. Обе ручки только читают (SELECT/прокси автокомплита) и не
# пишут в БД адрес физлица, поэтому открываются ДО контура согласия
# 152-ФЗ (#2895), а платный расчёт — только вместе с ним. Разбор
# периметра и бюджетов — app/api/public/mera.py.
#
# Строки ТОЧНЫЕ, префиксной ветки здесь нет: множество проверяется как
# `path in _PUBLIC_PATHS`, и превращать его в префиксный матчер ради
# двух путей значит расширить механизм, от которого зависит auth-гейт
# всего бэкенда. Гарантия «под /api/public/ не лежит ничего закрытого»
# держится на структуре пакета app/api/public/, а не на матчере.
"/api/public/mera/suggest",
"/api/public/mera/coverage",
}
)
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед

View file

@ -20,6 +20,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from sentry_sdk.integrations.starlette import StarletteIntegration
from app.api.public import mera as public_mera
from app.api.v1 import (
admin,
audit,
@ -70,6 +71,7 @@ if settings.glitchtip_dsn:
from app.observability.sentry_scrub import (
redact_telegram_bot_token,
scrub_payment_request_body,
scrub_public_address,
stabilize_retry_error_fingerprint,
)
@ -92,6 +94,13 @@ if settings.glitchtip_dsn:
(suggest/lookup/reverse), которые ретраят Nominatim через tenacity; см.
sentry_scrub.stabilize_retry_error_fingerprint."""
scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
# Публичный периметр МЕРЫ: тело запроса — это ровно введённый адрес, а
# брэдкрамб исходящего вызова геокодера несёт его же в query. Публичная
# страница обещает, что адрес нигде не сохраняется; GlitchTip — внешний
# сервис, значит обещание распространяется и на него.
scrubbed = scrub_public_address(scrubbed, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
scrubbed = scrub_pii_event(scrubbed, hint) # type: ignore[arg-type]
@ -273,3 +282,9 @@ app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["building
app.include_router(search.router, prefix="/api/v1", tags=["search"])
app.include_router(me.router, prefix="/api/v1", tags=["me"])
app.include_router(team.router, prefix="/api/v1/team", tags=["team"])
# Публичный B2C-периметр МЕРЫ (meraocenka.ru). Префикс НЕ /api/v1/* намеренно —
# на публичном домене он проксируется целиком, поэтому под ним по определению
# не должно лежать ничего закрытого; разбор — в app/api/public/mera.py.
# Обе ручки перечислены в rbac._PUBLIC_PATHS точными строками.
app.include_router(public_mera.router, prefix="/api/public/mera", tags=["public-mera"])

View file

@ -241,6 +241,61 @@ def scrub_payment_request_body(event: Event, _hint: dict[str, Any]) -> Event | N
return event
_PUBLIC_API_URL_SEGMENT = "/api/public/"
#: Хосты геокодеров: их URL несёт введённый адрес прямо в query.
_GEOCODER_HOSTS = ("nominatim.openstreetmap.org", "suggestions.dadata.ru", "dadata.ru")
_ANY_URL_QUERY_RE = re.compile(r"^([^?]*)\?.*$")
def scrub_public_address(event: Event, _hint: dict[str, Any]) -> Event | None:
"""Убрать введённый анонимом адрес из события GlitchTip.
На `meraocenka.ru/estimate` и в политике обработки ПДн сказано, что адрес
нигде не сохраняется. GlitchTip внешний сервис, и до этой правки адрес
доезжал туда двумя путями (оба воспроизведены состязательным ревью
16.08.2026, не выведены из чтения кода):
1. `event.request.data`. sentry_sdk кладёт в событие ПОЛНОЕ тело запроса,
а `send_default_pii=False` этот путь не гейтит он про куки и IP, не
про тело. Тело публичной ручки это ровно `{"q": "<адрес>"}`.
Ключ-based `scrub_pii_event` не помогает: `_PII_KEYS` перечисляет
имена вроде `client_phone`, а поле здесь называется `q`.
2. Брэдкрамб исходящего HTTP-запроса к геокодеру: `HttpxIntegration`
кладёт URL целиком, а адрес там в query (`?q=Малышева+30`).
Стратегия та же, что у платёжного тела: не вычищать отдельные ключи, а
убирать целиком состав полей задаёт не только наш код (у геокодеров свои
параметры), поэтому перечислить безопасное заранее нельзя.
Композировать с остальными шагами, а не вместо них.
"""
if not isinstance(event, dict):
return event
request = event.get("request")
if isinstance(request, dict):
url = request.get("url")
if isinstance(url, str) and _PUBLIC_API_URL_SEGMENT in url.lower():
request.pop("data", None)
crumbs = event.get("breadcrumbs")
values = crumbs.get("values") if isinstance(crumbs, dict) else crumbs
if isinstance(values, list):
for crumb in values:
if not isinstance(crumb, dict):
continue
data = crumb.get("data")
if not isinstance(data, dict):
continue
url = data.get("url")
if isinstance(url, str) and any(h in url for h in _GEOCODER_HOSTS):
data["url"] = _ANY_URL_QUERY_RE.sub(r"\g<1>?" + _REDACTED, url)
return event
def _redact_strings(obj: Any) -> Any:
"""Рекурсивно проходит dict/list/tuple и прогоняет обе токен-регулярки по КАЖДОЙ
строке (не только по конкретным ключам) токен может оказаться в locals

View file

@ -0,0 +1,352 @@
"""Публичный B2C-API МЕРЫ — /api/public/mera/* (issue #2911).
Что здесь запинено и почему именно это:
1. ПЕРИМЕТР. Набор маршрутов под /api/public/ проверяется на РАВЕНСТВО, а не
на вхождение. Третья ручка, добавленная сюда без правки этого теста,
уронит его а решение «эта ручка доступна анониму» не должно приниматься
мимоходом, добавлением файла в каталог.
2. СВЯЗКА С rbac. Публичность даёт не расположение файла, а строка в
`rbac._PUBLIC_PATHS`; тест сверяет, что для каждого маршрута она есть, и
наоборот что `_PUBLIC_PATHS` не содержит /api/public/ путей, которых
больше нет в роутере (иначе освободившийся путь остался бы открытым для
любого, кто его потом займёт).
3. УЗОСТЬ ИСКЛЮЧЕНИЯ. Соседний non-public путь в том же приложении обязан
по-прежнему отдавать 401 анониму. Без этой проверки тест «аноним получает
200» одинаково зелёный и когда исключение точечное, и когда auth-гейт
снят целиком.
4. БЮДЖЕТ. 429 после исчерпания per-IP окна иначе внешний (платный)
геокодер открыт наружу без ограничений.
"""
from __future__ import annotations
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
# Settings требует DATABASE_URL на конструирование — stub до любого app-импорта
# (тот же паттерн, что в остальных tests/*.py).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
# WeasyPrint (GTK) в CI/Windows нет, а app.api.v1.trade_in тянет его на импорте —
# public/mera.py делегирует туда, значит стаб нужен и здесь.
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
import pytest # noqa: E402
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from app.api.public import mera as public_mera # noqa: E402
from app.api.v1.geocode import SuggestResponse # noqa: E402
from app.core.db import get_db # noqa: E402
from app.core.rbac import _PUBLIC_PATHS, rbac_guard # noqa: E402
from app.schemas.trade_in import CoverageProbeResponse # noqa: E402
# Внешний префикс монтирования — ровно тот, что в app/main.py.
PREFIX = "/api/public/mera"
_COVERAGE_BODY = {"lat": 56.838, "lon": 60.597, "rooms": 2, "area_m2": 54.0}
_FAKE_COVERAGE = CoverageProbeResponse(
status="ok",
n_listings=34,
median_listing_age_days=44,
n_with_age=6,
radius_m=1000,
city="Екатеринбург",
threshold=10,
)
@pytest.fixture(autouse=True)
def _reset_limiters():
"""Лимитеры — состояние МОДУЛЯ, переживающее тест.
Без сброса порядок тестов начинает влиять на результат: тест бюджета
выбирает окно, а следующий за ним «аноним получает 200» ловит 429 и падает
не по своей вине.
"""
public_mera._suggest_limiter._hits.clear()
public_mera._coverage_limiter._hits.clear()
yield
public_mera._suggest_limiter._hits.clear()
public_mera._coverage_limiter._hits.clear()
@pytest.fixture()
def client() -> TestClient:
"""Приложение с РЕАЛЬНЫМ rbac_guard — тем же, что регистрирует app/main.py.
Рядом с публичным роутером намеренно висит закрытый маршрут-двойник: он и
делает проверку 3 (узость исключения) возможной внутри одного приложения.
"""
app = FastAPI()
app.middleware("http")(rbac_guard)
app.include_router(public_mera.router, prefix=PREFIX)
@app.get("/api/v1/trade-in/coverage")
def _closed_twin() -> dict[str, str]: # pragma: no cover — до хендлера не доходит
return {"ok": "should never be reachable anonymously"}
def _override_db():
yield MagicMock()
app.dependency_overrides[get_db] = _override_db
return TestClient(app)
# ── 1-2. Периметр и его связка с rbac ────────────────────────────────────────
def test_public_router_exposes_exactly_two_routes() -> None:
paths = {r.path for r in public_mera.router.routes}
assert paths == {"/suggest", "/coverage"}, (
"изменился набор публичных (анонимных) ручек МЕРЫ. Это не рефакторинг: "
"всё под /api/public/ проксируется на meraocenka.ru целиком и доступно "
"без идентичности. Обнови тест ОСОЗНАННО вместе с rbac._PUBLIC_PATHS."
)
def test_every_public_route_is_exempt_in_rbac() -> None:
for route in public_mera.router.routes:
full = f"{PREFIX}{route.path}"
assert full in _PUBLIC_PATHS, (
f"{full} смонтирован в публичном пакете, но rbac его не пропускает — "
"анониму прилетит 401, форма на лэндинге молча сломается"
)
def test_rbac_has_no_stale_public_mera_exemptions() -> None:
"""Обратное направление: в `_PUBLIC_PATHS` нет /api/public/ путей-призраков.
Удалённая ручка, чья строка осталась в исключениях, открывает этот путь
заранее для любого маршрута, который потом займёт то же место.
"""
live = {f"{PREFIX}{r.path}" for r in public_mera.router.routes}
stale = {p for p in _PUBLIC_PATHS if p.startswith("/api/public/")} - live
assert not stale, f"в rbac._PUBLIC_PATHS остались несуществующие пути: {stale}"
# ── 3. Аноним проходит, но исключение узкое ──────────────────────────────────
def test_anonymous_gets_coverage(client: TestClient) -> None:
with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE):
resp = client.post(f"{PREFIX}/coverage", json=_COVERAGE_BODY)
assert resp.status_code == 200, resp.text
assert resp.json()["city"] == "Екатеринбург"
def test_anonymous_gets_suggest(client: TestClient) -> None:
with patch.object(
public_mera,
"suggest_addresses",
AsyncMock(return_value=SuggestResponse(items=[])),
):
resp = client.post(f"{PREFIX}/suggest", json={"q": "Малышева"})
assert resp.status_code == 200, resp.text
assert resp.json() == {"items": []}
def test_suggest_is_post_so_address_never_lands_in_access_log() -> None:
"""Адрес едет ТЕЛОМ, а не в query.
На публичном домене включён access-лог Caddy, и он пишет URI целиком
`GET /suggest?q=Малышева+51` сохранил бы адрес квартиры в файл рядом с IP
посетителя. На той же странице мы обещаем, что введённый адрес нигде не
сохраняем. Метод часть этого обещания, поэтому запинен: вернуть GET
«для каноничности» нельзя, не сломав обещание.
"""
route = next(r for r in public_mera.router.routes if r.path == "/suggest")
assert route.methods == {"POST"}
def test_closed_twin_still_401_for_anonymous(client: TestClient) -> None:
"""Исключение точечное, а не «auth-гейт снят».
Без этой проверки тесты выше остаются зелёными и в случае, когда
rbac_guard перестал требовать идентичность вообще.
"""
resp = client.get("/api/v1/trade-in/coverage")
assert resp.status_code == 401, resp.text
# ── 4. Бюджеты ───────────────────────────────────────────────────────────────
def test_coverage_rate_limited_per_ip(client: TestClient) -> None:
with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE):
codes = [
client.post(f"{PREFIX}/coverage", json=_COVERAGE_BODY).status_code
for _ in range(public_mera._COVERAGE_LIMIT + 2)
]
assert codes[: public_mera._COVERAGE_LIMIT] == [200] * public_mera._COVERAGE_LIMIT
assert codes[-1] == 429, f"бюджет не сработал: {codes}"
def test_rate_limited_response_carries_retry_after(client: TestClient) -> None:
"""429 без Retry-After — это «попробуйте когда-нибудь»: клиент начнёт
долбить сразу же, а лимит существует ровно чтобы этого не было."""
with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE):
for _ in range(public_mera._COVERAGE_LIMIT):
client.post(f"{PREFIX}/coverage", json=_COVERAGE_BODY)
resp = client.post(f"{PREFIX}/coverage", json=_COVERAGE_BODY)
assert resp.status_code == 429
assert int(resp.headers["Retry-After"]) > 0
def test_suggest_and_coverage_budgets_are_independent(client: TestClient) -> None:
"""Общий лимитер на две ручки означал бы, что набор адреса (частые
подсказки) выедает бюджет пробы человек не смог бы нажать кнопку."""
with patch.object(
public_mera,
"suggest_addresses",
AsyncMock(return_value=SuggestResponse(items=[])),
):
for _ in range(public_mera._SUGGEST_LIMIT):
client.post(f"{PREFIX}/suggest", json={"q": "Малышева"})
with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE):
resp = client.post(f"{PREFIX}/coverage", json=_COVERAGE_BODY)
assert resp.status_code == 200, resp.text
# ── Контракт ответа ──────────────────────────────────────────────────────────
def test_public_coverage_reuses_v1_response_model(client: TestClient) -> None:
"""Публичная ручка отдаёт ТОТ ЖЕ тип, что и v1.
Это не косметика: на `CoverageProbeResponse` уже стоит гейт «ни одного
price-подобного поля» (tests/test_coverage_probe_endpoint.py). Заведи здесь
отдельную модель и публичный ответ окажется вне того гейта, а именно он
держит продуктовое правило «бесплатный шаг не отдаёт цену».
"""
route = next(r for r in public_mera.router.routes if r.path == "/coverage")
assert route.response_model is CoverageProbeResponse
def test_suggest_limit_ceiling_is_lower_than_v1(client: TestClient) -> None:
"""Потолок limit публичной ручки — 10 (у v1 15). Каждый лишний кандидат
может стоить внешнего (платного) вызова, а публичный UI больше и не
показывает."""
with patch.object(
public_mera,
"suggest_addresses",
AsyncMock(return_value=SuggestResponse(items=[])),
):
at_ceiling = client.post(f"{PREFIX}/suggest", json={"q": "Малышева", "limit": 10})
above = client.post(f"{PREFIX}/suggest", json={"q": "Малышева", "limit": 15})
assert at_ceiling.status_code == 200, at_ceiling.text
assert above.status_code == 422, above.text
# ── 5. Обещание «адрес нигде не сохраняется» ─────────────────────────────────
#
# Три пути утечки, найденные состязательным ревью 16.08.2026 и воспроизведённые
# на живом коде. Тесты сформулированы от обещания, а не от реализации: пока на
# публичной странице и в политике ПДн написано «не сохраняется», эти проверки
# обязаны быть зелёными.
def test_address_is_redacted_from_logs_inside_public_scope(caplog) -> None:
"""Геокодер логирует введённую строку открытым текстом на каждый вызов, а
прод пишет stdout контейнеров в persistent journald то есть адрес ложился
на диск рядом с IP того же запроса в access-логе Caddy."""
import logging
from app.core.public_request import install_address_log_redaction, public_request_scope
install_address_log_redaction()
dadata_logger = logging.getLogger("app.services.dadata")
with caplog.at_level(logging.INFO):
with public_request_scope():
dadata_logger.info("dadata suggest: %r%d вариантов", "онуфриева 24", 5)
# Вне публичного контура логи прежние — они нужны для разбора жалоб
# пилотов на подсказки.
dadata_logger.info("dadata suggest: %r%d вариантов", "малышева 51", 3)
text = "\n".join(r.getMessage() for r in caplog.records)
assert "онуфриева" not in text.lower(), "адрес анонима попал в журнал"
assert "малышева" in text.lower(), "редакция протекла на закрытый контур"
def test_public_scope_is_not_leaked_after_request() -> None:
"""Флаг обязан сниматься: иначе первый же публичный запрос заглушил бы логи
процесса до перезапуска."""
from app.core.public_request import is_public_request, public_request_scope
assert is_public_request.get() is False
with public_request_scope():
assert is_public_request.get() is True
assert is_public_request.get() is False
def test_sentry_scrub_drops_public_request_body_and_geocoder_query() -> None:
"""sentry_sdk кладёт в событие полное тело запроса, а `send_default_pii=False`
этот путь не гейтит (он про куки и IP). Тело публичной ручки ровно
`{"q": "<адрес>"}`; брэдкрамб httpx несёт тот же адрес в query."""
from app.observability.sentry_scrub import scrub_public_address
event = {
"request": {
"url": "https://meraocenka.ru/trade-in/api/public/mera/suggest",
"data": {"q": "Малышева 30"},
},
"breadcrumbs": {
"values": [
{
"category": "httpx",
"data": {"url": "https://nominatim.openstreetmap.org/search?q=Малышева+30"},
},
{"category": "httpx", "data": {"url": "https://example.com/x?a=1"}},
]
},
}
scrubbed = scrub_public_address(event, {})
assert scrubbed is not None
assert "data" not in scrubbed["request"], "тело запроса с адресом уехало в GlitchTip"
crumbs = scrubbed["breadcrumbs"]["values"]
assert "Малышева" not in crumbs[0]["data"]["url"], "адрес уехал в брэдкрамбе геокодера"
# Посторонние URL не трогаем — иначе разбирать чужие ошибки станет нечем.
assert crumbs[1]["data"]["url"] == "https://example.com/x?a=1"
def test_sentry_scrub_keeps_closed_contour_body() -> None:
"""Редакция узкая: тела запросов закрытого контура нужны для разбора."""
from app.observability.sentry_scrub import scrub_public_address
event = {
"request": {"url": "https://gendsgn.ru/trade-in/api/v1/trade-in/estimate", "data": {"x": 1}}
}
scrubbed = scrub_public_address(event, {})
assert scrubbed is not None
assert scrubbed["request"]["data"] == {"x": 1}
# ── 6. Бюджет внешнего геокодера ─────────────────────────────────────────────
def test_daily_suggest_budget_protects_shared_geocoder_quota(client: TestClient) -> None:
"""Per-IP окна ограничивают одного клиента, но не сумму: 20/мин с адреса —
это 28 800 в сутки при общем бесплатном тире DaData в 10 000, ОБЩЕМ с
закрытым контуром. Без суточного потолка один скрипт оставлял бы без
подсказок платящих пилотов."""
assert public_mera._DAILY_SUGGEST_BUDGET < 10_000, (
"суточный потолок публичных подсказок обязан быть заметно меньше всего "
"тира геокодера — иначе публичная форма может навредить закрытому контуру"
)
assert public_mera._SUGGEST_LIMIT * 60 * 24 > public_mera._DAILY_SUGGEST_BUDGET, (
"если per-IP лимит сам по себе не может исчерпать суточный бюджет, "
"потолок бессмысленен — проверь, что тест сторожит реальный сценарий"
)

View file

@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import { describeCoverage } from "../coverage-copy";
import type { CoverageProbe } from "../public-api";
/**
* Что именно мы утверждаем о своих данных перед человеком, который пришёл
* узнать цену квартиры. Здесь не форматирование здесь честность, поэтому
* тесты сформулированы через утверждения, а не через строки разметки.
*/
function probe(over: Partial<CoverageProbe> = {}): CoverageProbe {
return {
status: "ok",
n_listings: 34,
median_listing_age_days: 44,
n_with_age: 6,
radius_m: 1000,
city: "Екатеринбург",
threshold: 10,
...over,
};
}
describe("describeCoverage", () => {
it("не молчит ни при одном статусе — директива «никогда не блокировать вывод»", () => {
for (const status of ["ok", "thin", "not_covered"] as const) {
const verdict = describeCoverage(probe({ status, n_listings: status === "not_covered" ? 0 : 34 }));
expect(verdict.title.length, `пустой заголовок при ${status}`).toBeGreaterThan(10);
expect(verdict.lines.length, `нет объяснения при ${status}`).toBeGreaterThan(0);
}
});
it("при неизвестном возрасте объявлений плитки нет — ноль вместо «неизвестно» хуже всего", () => {
const verdict = describeCoverage(probe({ median_listing_age_days: null, n_with_age: 2 }));
expect(verdict.tiles).toHaveLength(1);
expect(JSON.stringify(verdict.tiles)).not.toContain("дн.");
});
it("подпись под возрастом говорит про объявление, а не про срок продажи", () => {
// Выборка цензурирована: эти квартиры ЕЩЁ продаются, поэтому «за столько
// продаются» — прямая ложь. Формулировка запинена намеренно.
const [, ageTile] = describeCoverage(probe()).tiles;
expect(ageTile.label).toContain("объявление");
expect(ageTile.label).not.toMatch(/продаётся|продаются|срок продажи|уходят/i);
});
it("город вне покрытия — говорим прямо и не предлагаем платный шаг", () => {
const verdict = describeCoverage(probe({ status: "not_covered", city: "", n_listings: 0 }));
expect(verdict.offerPaidStep).toBe(false);
expect(verdict.title).toMatch(/вне области/i);
});
it("пустая когорта в НАШЕМ городе — это про рынок, а не «сервис не работает»", () => {
const verdict = describeCoverage(
probe({ status: "not_covered", city: "Берёзовский", n_listings: 0 }),
);
expect(verdict.offerPaidStep).toBe(false);
expect(verdict.lines.join(" ")).toContain("Берёзовский");
// Подсказка, что делать дальше, обязана быть — иначе это тупик.
expect(verdict.lines.join(" ")).toMatch(/площадь|комнат/i);
});
it("тонкая выборка не скрывается: называем и найденное, и порог", () => {
const verdict = describeCoverage(probe({ status: "thin", n_listings: 4, threshold: 10 }));
expect(verdict.offerPaidStep).toBe(true);
expect(verdict.lines.join(" ")).toContain("4");
expect(verdict.lines.join(" ")).toContain("10");
});
it("склоняет существительное по числу — «1 похожая квартира», не «1 похожих квартир»", () => {
const one = describeCoverage(probe({ n_listings: 1 })).tiles[0].label;
const two = describeCoverage(probe({ n_listings: 2 })).tiles[0].label;
const five = describeCoverage(probe({ n_listings: 5 })).tiles[0].label;
const eleven = describeCoverage(probe({ n_listings: 11 })).tiles[0].label;
expect(one).toContain("похожая квартира");
expect(two).toContain("похожие квартиры");
expect(five).toContain("похожих квартир");
// 11-14 — исключение из правила последней цифры.
expect(eleven).toContain("похожих квартир");
});
it("радиус показывается в километрах, когда он кратен тысяче метров", () => {
expect(describeCoverage(probe()).tiles[0].label).toContain("1 км");
expect(describeCoverage(probe({ radius_m: 750 })).tiles[0].label).toContain("750 м");
});
it("ни в одном ответе нет рублей — цену продаёт платный шаг", () => {
for (const status of ["ok", "thin", "not_covered"] as const) {
const verdict = describeCoverage(probe({ status }));
expect(JSON.stringify(verdict)).not.toMatch(/₽|руб/i);
}
});
});

View file

@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it } from "vitest";
import { normalizeDraftRooms, saveDraft, takeDraft } from "../estimate-draft";
/**
* Черновик, который лэндинг передаёт на экран проверки.
*
* Тесты на две вещи, каждая из которых уже ломалась при ревью:
* 1. поле «Комнат» на лэндинге свободное, и его значение нельзя подставлять
* в селект как есть;
* 2. черновик не должен переживать свой единственный переход.
*/
const ROOMS = ["0", "1", "2", "3", "4", "5"];
describe("normalizeDraftRooms", () => {
it("пропускает то, что уже совпадает с вариантом селекта", () => {
expect(normalizeDraftRooms("2", ROOMS)).toBe("2");
expect(normalizeDraftRooms("0", ROOMS)).toBe("0");
});
it("понимает, как люди пишут на самом деле", () => {
expect(normalizeDraftRooms("2 комнаты", ROOMS)).toBe("2");
expect(normalizeDraftRooms("3-комн.", ROOMS)).toBe("3");
expect(normalizeDraftRooms(" 1к ", ROOMS)).toBe("1");
expect(normalizeDraftRooms("Студия", ROOMS)).toBe("0");
expect(normalizeDraftRooms("студию", ROOMS)).toBe("0");
});
it("возвращает null вместо мусора — иначе селект покажет пустоту, а сервер получит null", () => {
// Именно этот путь и давал 422 с текстом «сломалось на нашей стороне»:
// селект пустой, parseInt → NaN, rooms: null улетает на бэкенд.
expect(normalizeDraftRooms("много", ROOMS)).toBeNull();
expect(normalizeDraftRooms("", ROOMS)).toBeNull();
expect(normalizeDraftRooms(undefined, ROOMS)).toBeNull();
expect(normalizeDraftRooms("9", ROOMS)).toBeNull();
});
});
describe("черновик", () => {
beforeEach(() => window.sessionStorage.clear());
it("переживает ровно один переход", () => {
saveDraft({ address: "Малышева 51", rooms: "2" });
expect(takeDraft()?.address).toBe("Малышева 51");
// Второй раз — уже пусто: иначе возврат на /estimate через неделю в той же
// вкладке подставил бы чужой по смыслу адрес.
expect(takeDraft()).toBeNull();
});
it("не падает на мусоре в хранилище", () => {
window.sessionStorage.setItem("mera:estimate-draft", "{это не json");
expect(takeDraft()).toBeNull();
window.sessionStorage.setItem("mera:estimate-draft", JSON.stringify({ rooms: "2" }));
expect(takeDraft()).toBeNull();
});
it("не тащит поля неожиданных типов", () => {
window.sessionStorage.setItem(
"mera:estimate-draft",
JSON.stringify({ address: "Ленина 1", rooms: 2, area: null }),
);
const draft = takeDraft();
expect(draft?.address).toBe("Ленина 1");
expect(draft?.rooms).toBeUndefined();
expect(draft?.area).toBeUndefined();
});
});

View file

@ -0,0 +1,153 @@
import { readFileSync, readdirSync, statSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { PUBLIC_ROUTES } from "../content";
import { b2cVars } from "../theme";
/**
* Структурные гейты публичного B2C-периметра. Каждый заведён на РЕАЛЬНО
* случившийся дефект все три нашёл владелец на живом сайте, а не сборка.
*
* Проверки читают исходники с диска намеренно: то, что здесь ломается, это
* связь между файлами (CSS токены, роуты Caddyfile, компоненты basePath),
* а не поведение одной функции. Такую связь юнит-тест на модуле не видит.
*/
const MERA_PUBLIC = path.resolve(__dirname, "..");
const REPO_ROOT = path.resolve(MERA_PUBLIC, "../../../../..");
function collectFiles(dir: string, exts: readonly string[]): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = path.join(dir, entry);
if (statSync(full).isDirectory()) {
out.push(...collectFiles(full, exts));
} else if (exts.includes(path.extname(full))) {
out.push(full);
}
}
return out;
}
describe("палитра v3 доезжает до CSS", () => {
/**
* Дефект 15.08.2026: `b2c-tokens.ts` завёл палитру макета, `landing-v3.module.css`
* начал ссылаться на `var(--b2c-*)` а моста между ними не появилось.
* Переменные не объявлял никто, каскад молча пропускал такие декларации, и
* лэндинг v3 на проде отдавал 200 бесцветным: карточки без фона, кнопки без
* заливки. Ни сборка, ни линт, ни тайпчек этого не видят заметил владелец.
*/
const css = readFileSync(path.join(MERA_PUBLIC, "landing-v3.module.css"), "utf8");
const used = new Set(
[...css.matchAll(/var\(\s*(--b2c-[a-z0-9-]+)/g)].map((m) => m[1]),
);
it("в CSS вообще есть ссылки на переменные (иначе тест бесполезен)", () => {
expect(used.size).toBeGreaterThan(5);
});
it("каждая использованная --b2c-* переменная объявлена в b2cVars", () => {
const declared = new Set(Object.keys(b2cVars));
const missing = [...used].filter((name) => !declared.has(name));
expect(missing, `не объявлены — на странице будут пустые значения: ${missing}`).toEqual([]);
});
});
describe("короткие адреса публичного домена", () => {
/**
* Дефект 15.08.2026: подвал v3 вёл «Главную» на `/mera-public`, а Caddy на
* публичном домене раздавал только `/trade-in/mera-public/*` (со слэшем)
* голый путь падал в catch-all 404. Страница, которой нет в allowlist
* Caddyfile, недоступна независимо от того, что она собралась.
*/
const caddyfile = readFileSync(path.join(REPO_ROOT, "Caddyfile"), "utf8");
const meraBlock = caddyfile.slice(
caddyfile.indexOf("meraocenka.ru {"),
caddyfile.indexOf("merahome.ru {"),
);
it("блок meraocenka.ru найден (иначе проверки ниже пустые)", () => {
expect(meraBlock.length).toBeGreaterThan(500);
});
/**
* Разрешённый «лишний» путь: временное превью второго варианта дизайна.
* Ссылок на него нет, страница noindex; строка удаляется вместе с выбором
* варианта. Держим здесь, чтобы проверка ниже была ДВУСТОРОННЕЙ.
*/
const PREVIEW_ONLY = ["/v3"];
const meraPagesLine = meraBlock.match(/@meraPages path ([^\n]+)/);
it("каждый маршрут из PUBLIC_ROUTES раздаётся публичным доменом", () => {
for (const route of Object.values(PUBLIC_ROUTES)) {
// Корень — отдельным `handle /`, остальные перечислены в матчере @meraPages.
const served =
route === "/"
? /handle\s+\/\s*\{/.test(meraBlock)
: (meraPagesLine?.[1].split(/\s+/) ?? []).includes(route);
expect(served, `${route} не раздаётся на meraocenka.ru — ссылка на него будет 404`).toBe(
true,
);
}
});
/**
* Обратное направление. Односторонняя проверка (каждый роут есть в Caddy)
* пропускает противоположную ошибку: путь, открытый на боевом домене, о
* котором приложение не знает. Так на публичный домен уже уехало `/v3`
* черновой лэндинг с маркетинговыми плейсхолдерами вместо посчитанных чисел.
* Теперь любой такой путь обязан быть либо в PUBLIC_ROUTES, либо в списке
* превью выше то есть названным вслух.
*/
it("на публичном домене не открыто ничего сверх известных страниц", () => {
const served = meraPagesLine?.[1].split(/\s+/).filter(Boolean) ?? [];
const known = new Set([...Object.values(PUBLIC_ROUTES), ...PREVIEW_ONLY]);
const unexpected = served.filter((p) => !known.has(p));
expect(
unexpected,
`открыты наружу, но не объявлены ни в PUBLIC_ROUTES, ни как превью: ${unexpected}`,
).toEqual([]);
});
/**
* Длинные адреса обязаны редиректить на короткие иначе разосланные ссылки
* и закладки превращаются в 404. Список тоже поимённый (регексп с захватом
* хвоста здесь был бы открытым редиректом см. комментарий в Caddyfile),
* поэтому он так же легко расходится с набором страниц.
*/
it("для каждой страницы есть 301 с длинного адреса", () => {
const longLine = meraBlock.match(/@meraLongPages path ([^\n]+)/)?.[1] ?? "";
for (const route of [...Object.values(PUBLIC_ROUTES), ...PREVIEW_ONLY]) {
if (route === "/") continue; // корень ловит отдельный @meraLongRoot
expect(
longLine.includes(`/trade-in/mera-public${route}`),
`нет 301 с длинного адреса на ${route} — старые ссылки станут 404`,
).toBe(true);
}
});
});
describe("basePath не протекает в публичные ссылки", () => {
/**
* `next/link` подставляет `basePath=/trade-in` в каждый href, поэтому
* `<Link href="/estimate">` уехал бы на несуществующий `/trade-in/estimate`.
* Публичное дерево ходит обычным `<a>` (компонент `PublicLink`).
*
* Проверка на импорт, а не на строку `/trade-in` в разметке: именно импорт
* `next/link` источник подстановки, и именно он вернётся, если кто-то
* добавит ссылку «как везде в проекте».
*/
it("ни один файл под mera-public не импортирует next/link", () => {
const offenders = collectFiles(MERA_PUBLIC, [".ts", ".tsx"])
.filter((file) => /from\s+["']next\/link["']/.test(readFileSync(file, "utf8")))
.map((file) => path.relative(MERA_PUBLIC, file));
expect(
offenders,
`next/link подставит basePath и ссылка уедет на /trade-in/...: ${offenders}`,
).toEqual([]);
});
});

View file

@ -29,10 +29,14 @@
* (useMe/useQuota/useHistory и соседей) запрещено публичный экран не должен
* иметь к ним доступа даже теоретически.
*
* Когда бэкенд откроет анонимные ручки: переключить `PUBLIC_ESTIMATE_ENABLED`
* в content.ts и заменить ветку `notLaunched` в `handleSubmit` на реальный
* переход/запрос (комбобокс подсказок по образцу ParamsPanel.tsx, вместе с
* его клавиатурной моделью и sr-live-регионом).
* КУДА ВЕДЁТ КНОПКА (15.08.2026). Раньше сабмит упирался в сообщение «расчёт
* ещё не открыли» анонимных ручек не существовало. Теперь они есть
* (`/api/public/mera/*`), а сама проверка живёт на отдельном экране
* `/estimate`: там у автокомплита есть место под список подсказок, а у
* результата место рядом с полями. Эта форма осталась входной точкой
* лэндинга и делает ровно две полезные вещи не пускает дальше без адреса и
* требует явно назвать город (#2576: «Ленина, 1» без города давало уверенную
* цену по одноимённой улице в ЕКБ жителю Нижнего Тагила).
*/
import { useId, useRef, useState } from "react";
@ -43,10 +47,11 @@ import { detectCityInText } from "@/lib/city-registry";
import {
COVERED_CITIES,
PRIMARY_CITY,
PUBLIC_ESTIMATE_ENABLED,
PUBLIC_ROUTES,
REGION_NAME,
SECONDARY_CITIES,
} from "../content";
import { saveDraft } from "../estimate-draft";
import styles from "../landing.module.css";
/** Значение <option> «моего города нет в списке». */
@ -117,24 +122,12 @@ export function AddressForm() {
setCity(detected);
}
if (PUBLIC_ESTIMATE_ENABLED) {
// TODO(backend-периметр): здесь появится реальный расчёт. Отдельный PR.
return;
}
// Если город уже назван в самом тексте адреса — не дублируем его в эхо
// («Серов, Серов, Ленина 3»).
const echo =
detected === resolvedCity ? trimmed : `${resolvedCity}, ${trimmed}`;
setFeedback({
kind: "info",
title: "Расчёт по адресу мы ещё не открыли для всех",
lines: [
`Адрес выглядит как наш: ${echo}. Но публичная оценка пока выключена — сервис работает по доступу для партнёров, и мы не хотим показывать заглушку вместо цены.`,
"Оценка появится на этой же странице. Если вопрос срочный — напишите нам в поддержку, ссылка в подвале.",
],
});
// Уходим на отдельный экран проверки. Черновик кладём в sessionStorage, а
// НЕ в query-параметры: адрес в URL попал бы в access-логи Caddy рядом с
// IP посетителя — то есть мы бы хранили персональные данные там, где
// публично обещали ничего не хранить (см. estimate-draft.ts).
saveDraft({ address: trimmed, city: resolvedCity });
window.location.assign(PUBLIC_ROUTES.estimate);
}
const addressInvalid =

View file

@ -0,0 +1,38 @@
import type { AnchorHTMLAttributes } from "react";
import { PUBLIC_ROUTES } from "../content";
/**
* Ссылка между страницами публичного сайта обычный `<a>`, не `next/link`.
*
* ПОЧЕМУ ТАК. В образ запечён `basePath=/trade-in`, и `next/link` подставляет
* его в КАЖДЫЙ href: `<Link href="/estimate">` уедет на `/trade-in/estimate`,
* которого не существует. Отключить подстановку для отдельной ссылки нельзя
* это свойство сборки, а не пропс. Значит короткие адреса публичного домена
* (`/estimate`, `/oferta`, ) выразимы только обычным якорем.
*
* Что теряем: клиентскую навигацию и prefetch. На пяти статических страницах
* это незаметно, а prefetch публичному дереву прямо противопоказан он
* подтягивал бы чанки страниц, которых анонимному посетителю видеть незачем.
*
* ЗАЧЕМ ОТДЕЛЬНЫЙ КОМПОНЕНТ, А НЕ ПРОСТО `<a>`. Ради типа `to`: он принимает
* только ключи `PUBLIC_ROUTES`, поэтому опечатка в адресе или ссылка на
* страницу, которой нет в Caddy-allowlist публичного домена, не компилируется.
* Именно такие ссылки и ломались раньше подвал v3 вёл на `/mera-public`,
* который на публичном домене отдавал 404, и заметил это владелец, а не тест.
*/
type PublicRoute = keyof typeof PUBLIC_ROUTES;
interface PublicLinkProps
extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
to: PublicRoute;
}
export function PublicLink({ to, children, ...rest }: PublicLinkProps) {
return (
<a href={PUBLIC_ROUTES[to]} {...rest}>
{children}
</a>
);
}

View file

@ -8,24 +8,23 @@
* оператора по 152-ФЗ. Значения импортируются, а не пишутся строками: тот же
* набор рендерят оферта и политика ПДн, и разъехаться они не должны.
*
* Ссылки на документы ведут по ВНУТРЕННИМ путям (`OFFER_PATH` и соседи), а не
* по коротким meraocenka.ru/oferta: короткие адреса существуют только на
* публичном хосте (rewrite в Caddy), а это же поддерево открывается и с
* gendsgn.ru/trade-in/mera-public там короткая ссылка ушла бы в 404.
* ССЫЛКИ НА ДОКУМЕНТЫ КОРОТКИЕ (изменено 15.08.2026 по решению владельца).
* Раньше здесь стояли внутренние пути `/mera-public/oferta` и соседи, чтобы
* подвал работал и при открытии поддерева с gendsgn.ru. Теперь наоборот:
* человек на публичном сервисе не должен видеть `/trade-in/mera-public/...` в
* адресной строке, и именно короткие адреса напечатаны внутри самих
* документов. Цена решения переходы в подвале работают только на
* meraocenka.ru; разбор и что с этим делать в шапке `PUBLIC_ROUTES`
* (content.ts).
*
* Внешняя ссылка проверяется `safeUrl` (правило frontend.md: ничего в href без
* валидации схемы) и открывается в новой вкладке с rel="noreferrer".
*/
import Link from "next/link";
import { safeUrl } from "@/lib/safeUrl";
import {
LEGAL_ENTITY,
OFFER_PATH,
PRIVACY_PATH,
REFUND_PATH,
REGION_NAME,
SUPPORT_EMAIL,
SUPPORT_TELEGRAM_LABEL,
@ -33,6 +32,8 @@ import {
} from "../content";
import styles from "../landing.module.css";
import { PublicLink } from "./PublicLink";
export function SiteFooter() {
const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL);
const year = new Date().getFullYear();
@ -79,19 +80,19 @@ export function SiteFooter() {
<p className={styles.footerTitle}>Документы</p>
<ul className={styles.footerLinks} role="list">
<li>
<Link className={styles.link} href={OFFER_PATH}>
<PublicLink className={styles.link} to="oferta">
Публичная оферта
</Link>
</PublicLink>
</li>
<li>
<Link className={styles.link} href={REFUND_PATH}>
<PublicLink className={styles.link} to="refund">
Политика возврата
</Link>
</PublicLink>
</li>
<li>
<Link className={styles.link} href={PRIVACY_PATH}>
<PublicLink className={styles.link} to="privacy">
Обработка персональных данных
</Link>
</PublicLink>
</li>
</ul>
</div>

View file

@ -0,0 +1,483 @@
"use client";
/**
* EstimateFlow публичная форма проверки квартиры на `meraocenka.ru/estimate`.
*
* ПОЧЕМУ ОТДЕЛЬНАЯ СТРАНИЦА, А НЕ КАРТОЧКА В ГЕРОЕ. Карточка на первом экране
* (`v3/FreeCheckCard.tsx`) продаёт: показывает, что сервис умеет, и уводит
* читать дальше. Здесь человек уже пришёл считать экран должен принадлежать
* задаче целиком: автокомплит с местом под список подсказок, поля рядом,
* результат на том же месте, без соседних секций, конкурирующих за внимание.
*
* ЧТО ЭТА ФОРМА ДЕЛАЕТ СЕГОДНЯ. Бесплатную пробу покрытия: сколько похожих
* квартир продаётся рядом и как долго висят их объявления. Ни одной рублёвой
* цифры их отдаёт платный шаг, которого пока нет (нужен платёжный контур,
* #2896). Это не заглушка: проба ходит в живой SQL по активным объявлениям и
* отвечает по конкретному дому.
*
* НИЧЕГО НЕ СОХРАНЯЕТСЯ. Обе ручки только читают (см.
* `backend/app/api/public/mera.py`) адрес, который человек ввёл, никуда не
* пишется. Это и позволяет форме работать ДО контура согласия 152-ФЗ (#2895).
* Появится сохранение обязано появиться и согласие, раньше запроса, а не
* после.
*/
import { useCallback, useEffect, useId, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { COVERED_CITIES, PRIMARY_CITY } from "../../content";
import { describeCoverage } from "../../coverage-copy";
import type { CoverageVerdict } from "../../coverage-copy";
import { normalizeDraftRooms, takeDraft } from "../../estimate-draft";
import {
PublicApiError,
fetchAddressSuggestions,
fetchCoverage,
} from "../../public-api";
import type { AddressSuggestion } from "../../public-api";
import styles from "../../landing-v3.module.css";
/** Задержка перед запросом подсказок. Каждый вызов платный (DaData-тир). */
const SUGGEST_DEBOUNCE_MS = 300;
const SUGGEST_MIN_CHARS = 3;
/** Потолок ожидания пробы покрытия. Сам SQL укладывается в ~80 мс. */
const COVERAGE_TIMEOUT_MS = 15_000;
const ROOM_OPTIONS = [
{ value: 0, label: "Студия" },
{ value: 1, label: "1 комната" },
{ value: 2, label: "2 комнаты" },
{ value: 3, label: "3 комнаты" },
{ value: 4, label: "4 комнаты" },
{ value: 5, label: "5 и больше" },
] as const;
type Phase =
| { kind: "form" }
| { kind: "loading" }
| { kind: "result"; verdict: CoverageVerdict }
| { kind: "failed"; title: string; text: string };
const FORM: Phase = { kind: "form" };
function failureCopy(error: unknown): { title: string; text: string } {
if (error instanceof DOMException && error.name === "AbortError") {
return {
title: "Проверка заняла слишком долго",
text: "Мы прервали запрос, чтобы не держать вас в неизвестности. Нажмите «Проверить мой дом» ещё раз — введённое сохранилось.",
};
}
if (error instanceof PublicApiError && error.kind === "rate-limited") {
const wait = error.retryAfterS ? `${error.retryAfterS} сек.` : "минуту";
return {
title: "Слишком много проверок подряд",
// Инструкция, которую человек может выполнить, — а не «сервис сломался».
text: `Подождите ${wait} и попробуйте снова. Ограничение стоит, чтобы форму не выгребали роботами.`,
};
}
if (error instanceof PublicApiError && error.kind === "offline") {
return {
title: "Не получилось связаться с сервисом",
text: "Похоже, пропала связь. Проверьте интернет и попробуйте ещё раз — введённое сохранится.",
};
}
return {
title: "Проверка не прошла",
text: "Что-то сломалось на нашей стороне. Попробуйте ещё раз через пару минут; если повторится — напишите нам, ссылка в подвале.",
};
}
export function EstimateFlow() {
const addressId = useId();
const listboxId = useId();
const cityId = useId();
const roomsId = useId();
const areaId = useId();
const statusId = useId();
const [city, setCity] = useState<string>(PRIMARY_CITY);
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([]);
const [highlighted, setHighlighted] = useState(-1);
const [picked, setPicked] = useState<AddressSuggestion | null>(null);
const [rooms, setRooms] = useState<string>("2");
const [area, setArea] = useState("");
const [phase, setPhase] = useState<Phase>(FORM);
const [fieldError, setFieldError] = useState<"address" | "area" | null>(null);
const [suggestFailed, setSuggestFailed] = useState(false);
const addressRef = useRef<HTMLInputElement>(null);
const areaRef = useRef<HTMLInputElement>(null);
const coverageAbort = useRef<AbortController | null>(null);
// Незавершённый запрос покрытия при уходе со страницы отменяем — иначе
// setState прилетает в размонтированный компонент.
useEffect(() => () => coverageAbort.current?.abort(), []);
// Черновик с лэндинга — то, что человек уже набрал там. Забираем ОДИН раз
// на монтировании; координат в нём нет (и быть не может — на лэндинге нет
// автокомплита), поэтому дом всё равно придётся выбрать из подсказок.
//
// Каждое поле ВАЛИДИРУЕТСЯ, а не подставляется как есть. На лэндинге
// «Комнат» — свободный текст, туда пишут «студия» или «2 комнаты»; такое
// значение не совпадает ни с одним <option>, селект молча показывает
// пустоту, а на сабмите `parseInt` даёт NaN и на сервер уходит `rooms: null`
// → 422, который форма покажет как «сломалось на нашей стороне». Лучше
// оставить значение по умолчанию: оно хотя бы видно и его можно поправить.
useEffect(() => {
const draft = takeDraft();
if (!draft) return;
setQuery(draft.address);
if (draft.area) setArea(draft.area);
if (draft.city && COVERED_CITIES.includes(draft.city)) setCity(draft.city);
const normalized = normalizeDraftRooms(
draft.rooms,
ROOM_OPTIONS.map((o) => String(o.value)),
);
if (normalized) setRooms(normalized);
}, []);
// Подсказки: debounce + отмена предыдущего запроса.
//
// Контроллер создаётся СРАЗУ, а не внутри setTimeout, и отменяется в
// cleanup эффекта. Прежний вариант откладывал отмену до следующего такта
// debounce, и она не наступала вовсе, если человек переставал печатать:
// ответ по старой строке долетал и молча ложился в список. Дальше он мог
// выбрать дом «Ленина 5», отредактировать номер на 50 — и увидеть под новым
// текстом подсказки к старому, с координатами не того дома.
//
// Список НЕ очищается на каждом такте: раньше он схлопывался на каждое
// нажатие клавиши, и клик по уже намеченному пункту промахивался в пустоту.
// Старая выдача висит, пока не пришла новая, — она относится к более
// короткому префиксу той же строки и остаётся осмысленной.
useEffect(() => {
if (picked || query.trim().length < SUGGEST_MIN_CHARS) {
setSuggestions([]);
setSuggestFailed(false);
return;
}
const controller = new AbortController();
const timer = setTimeout(() => {
fetchAddressSuggestions(query.trim(), {
cityHint: city,
signal: controller.signal,
})
.then((items) => {
setSuggestions(items);
setSuggestFailed(false);
setHighlighted(-1);
})
.catch((error) => {
// Отменённый нами же запрос — не сбой.
if (error instanceof DOMException && error.name === "AbortError") return;
// Раньше ошибка глушилась в пустой список, и человек попадал в
// тупик: подсказок нет, объяснения нет, а сабмит требует выбрать
// дом из подсказок. Теперь говорим прямо.
setSuggestions([]);
setSuggestFailed(true);
});
}, SUGGEST_DEBOUNCE_MS);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query, city, picked]);
const choose = useCallback((item: AddressSuggestion) => {
setPicked(item);
setQuery(item.full_address);
setSuggestions([]);
setHighlighted(-1);
setFieldError(null);
}, []);
function handleAddressKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (!suggestions.length) return;
if (event.key === "ArrowDown") {
event.preventDefault();
setHighlighted((i) => (i + 1) % suggestions.length);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setHighlighted((i) => (i <= 0 ? suggestions.length - 1 : i - 1));
} else if (event.key === "Enter" && highlighted >= 0) {
event.preventDefault();
choose(suggestions[highlighted]);
} else if (event.key === "Escape") {
// Сбрасываем и подсветку: иначе aria-activedescendant указывает на
// элемент, которого в DOM уже нет, и скринридер читает пустоту.
setSuggestions([]);
setHighlighted(-1);
}
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
// Координаты берём ТОЛЬКО из выбранной подсказки: сама проба ничего не
// геокодирует (см. CoverageProbeInput на бэке). Набранная руками строка
// без выбора из списка — это адрес без координат, считать по нему нечего.
if (!picked) {
setFieldError("address");
setPhase(FORM);
addressRef.current?.focus();
return;
}
const areaValue = Number.parseFloat(area.replace(",", "."));
if (!Number.isFinite(areaValue) || areaValue <= 10 || areaValue >= 500) {
setFieldError("area");
setPhase(FORM);
areaRef.current?.focus();
return;
}
setFieldError(null);
setPhase({ kind: "loading" });
// Таймаут обязателен: без него оборванное соединение оставляет кнопку в
// «Смотрим данные…» навсегда — ни результата, ни ошибки, ни возможности
// повторить. Выход был только через перезагрузку страницы, а она вдобавок
// теряет черновик (он забирается «на вынос» при монтировании).
coverageAbort.current?.abort();
const controller = new AbortController();
coverageAbort.current = controller;
const timeout = setTimeout(() => controller.abort(), COVERAGE_TIMEOUT_MS);
fetchCoverage(
{
lat: picked.lat,
lon: picked.lon,
rooms: Number.parseInt(rooms, 10),
area_m2: areaValue,
},
{ signal: controller.signal },
)
.then((probe) => setPhase({ kind: "result", verdict: describeCoverage(probe) }))
.catch((error) => setPhase({ kind: "failed", ...failureCopy(error) }))
.finally(() => clearTimeout(timeout));
}
const showList = suggestions.length > 0 && !picked;
return (
<div className={styles.estCard}>
<form className={styles.heroForm} onSubmit={handleSubmit} noValidate>
<label className={styles.heroField} htmlFor={cityId}>
<span className={styles.heroFieldLabel}>Город</span>
<select
id={cityId}
className={styles.heroInput}
value={city}
onChange={(event) => {
setCity(event.target.value);
setPicked(null);
}}
>
{COVERED_CITIES.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</label>
{/* Список закрывается по уходу фокуса: иначе он остаётся раскрытым и
физически перекрывает поля «Комнат» и «Площадь», в которые человек
как раз собрался попасть. onBlur на контейнере, а не на инпуте,
чтобы клик по самой подсказке не считался уходом. */}
<div
className={styles.estAutocomplete}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setSuggestions([]);
setHighlighted(-1);
}
}}
>
<label className={styles.heroField} htmlFor={addressId}>
<span className={styles.heroFieldLabel}>Улица и дом</span>
<input
id={addressId}
ref={addressRef}
className={`${styles.heroInput} ${fieldError === "address" ? styles.heroInputInvalid : ""}`}
type="text"
autoComplete="off"
placeholder="Например, Малышева 51"
value={query}
role="combobox"
aria-expanded={showList}
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={
highlighted >= 0 ? `${listboxId}-${highlighted}` : undefined
}
aria-invalid={fieldError === "address" || undefined}
onChange={(event) => {
setQuery(event.target.value);
// Правка строки после выбора аннулирует координаты — иначе
// человек поправит номер дома, а посчитается прежний.
setPicked(null);
setFieldError(null);
}}
onKeyDown={handleAddressKeyDown}
/>
</label>
{showList && (
<ul className={styles.estSuggestList} id={listboxId} role="listbox">
{suggestions.map((item, index) => (
<li key={`${item.full_address}-${index}`} role="presentation">
<button
type="button"
id={`${listboxId}-${index}`}
role="option"
// Из списка нельзя «выпасть» табом: иначе после семи
// подсказок до поля «Комнат» нужно семь нажатий Tab.
// Управление списком — только стрелками, как и положено
// комбобоксу.
tabIndex={-1}
aria-selected={index === highlighted}
className={`${styles.estSuggestItem} ${index === highlighted ? styles.estSuggestItemActive : ""}`}
onMouseDown={(event) => event.preventDefault()}
onClick={() => choose(item)}
>
<span className={styles.estSuggestMain}>{item.label}</span>
<span className={styles.estSuggestSub}>{item.full_address}</span>
</button>
</li>
))}
</ul>
)}
</div>
<div className={styles.heroFieldRow}>
<label className={styles.heroField} htmlFor={roomsId}>
<span className={styles.heroFieldLabel}>Комнат</span>
<select
id={roomsId}
className={styles.heroInput}
value={rooms}
onChange={(event) => setRooms(event.target.value)}
>
{ROOM_OPTIONS.map((option) => (
<option key={option.value} value={String(option.value)}>
{option.label}
</option>
))}
</select>
</label>
<label className={styles.heroField} htmlFor={areaId}>
<span className={styles.heroFieldLabel}>Площадь, м²</span>
<input
id={areaId}
ref={areaRef}
className={`${styles.heroInput} ${fieldError === "area" ? styles.heroInputInvalid : ""}`}
type="text"
inputMode="decimal"
placeholder="54"
value={area}
aria-invalid={fieldError === "area" || undefined}
onChange={(event) => {
setArea(event.target.value);
setFieldError(null);
}}
/>
</label>
</div>
<button
type="submit"
className={styles.heroSubmitBtn}
disabled={phase.kind === "loading"}
>
{phase.kind === "loading" ? "Смотрим данные…" : "Проверить мой дом"}
</button>
<p className={styles.heroSubmitCaption}>
БЕСПЛАТНО · БЕЗ РЕГИСТРАЦИИ · НИЧЕГО НЕ СОХРАНЯЕМ
</p>
</form>
{/* Живая область постоянно в DOM: регион, добавленный в момент ответа,
часть скринридеров не озвучивает. */}
<div id={statusId} role="status" aria-live="polite">
{fieldError === "address" && (
<div className={`${styles.heroFormFeedback} ${styles.heroFormFeedbackError}`}>
<p className={styles.heroFormFeedbackText}>
Выберите дом из подсказок по ним мы знаем точные координаты.
</p>
</div>
)}
{fieldError === "area" && (
<div className={`${styles.heroFormFeedback} ${styles.heroFormFeedbackError}`}>
<p className={styles.heroFormFeedbackText}>
Укажите площадь квартиры от 11 до 499 м².
</p>
</div>
)}
{phase.kind === "failed" && (
<div className={`${styles.heroFormFeedback} ${styles.heroFormFeedbackError}`}>
<p className={styles.heroFormFeedbackTitle}>{phase.title}</p>
<p className={styles.heroFormFeedbackText}>{phase.text}</p>
</div>
)}
{suggestFailed && phase.kind !== "result" && (
<div className={styles.heroFormFeedback}>
<p className={styles.heroFormFeedbackText}>
Подсказки адреса сейчас не отвечают. Это не значит, что вашего
дома нет в базе попробуйте набрать заново через минуту.
</p>
</div>
)}
{phase.kind === "result" && <CoverageResult verdict={phase.verdict} />}
</div>
</div>
);
}
function CoverageResult({ verdict }: { verdict: CoverageVerdict }) {
return (
<div className={styles.estResult}>
<p className={styles.heroResultEyebrow}>
{verdict.tone === "none" ? "ПРОВЕРИЛИ" : "ГОТОВО · БЕСПЛАТНАЯ ЧАСТЬ"}
</p>
<p className={styles.heroFormFeedbackTitle}>{verdict.title}</p>
{verdict.tiles.length > 0 && (
<div className={styles.heroResultGrid}>
{verdict.tiles.map((tile) => (
<div key={tile.label} className={styles.heroResultTile}>
<div className={styles.heroResultTileValue}>{tile.value}</div>
<div className={styles.heroResultTileLabel}>{tile.label}</div>
</div>
))}
</div>
)}
{verdict.lines.map((line) => (
<p key={line} className={styles.heroFormFeedbackText}>
{line}
</p>
))}
{verdict.offerPaidStep && (
<div className={styles.estPaidStep}>
<p className={styles.heroResultOverlayTitle}>
Цена и срок продажи следующий шаг
</p>
<p className={styles.heroFormFeedbackText}>
Полный расчёт стоит 150&nbsp;: диапазон цены, цена за квадратный
метр и список квартир, на которых он построен. Приём оплаты сейчас
подключается как только заработает, кнопка появится здесь.
</p>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,40 @@
import { B2B_URL } from "../../content";
import styles from "../../landing-v3.module.css";
import { PublicLink } from "../PublicLink";
/**
* Шапка страницы проверки намеренно короче лэндинговой (`v3/HeaderV3.tsx`).
*
* Из неё убраны навигация по секциям лэндинга, селектор города и CTA
* «Проверить за 30 секунд». Причина у всех трёх одна: человек уже на экране
* проверки. Якоря вроде `#accuracy` тут просто не на что навести (секций нет
* на этой странице), город выбирается прямо в форме а второй селектор
* города в шапке означал бы два места, задающих одно значение, а CTA вёл бы
* на страницу, где посетитель уже стоит.
*
* Логотип отсюда ссылка на главную: это единственный обратный путь на
* лэндинг, и он же то, что человек инстинктивно нажимает.
*/
export function EstimateHeader() {
return (
<header className={styles.hdrRoot}>
<div className={styles.hdrInner}>
<PublicLink to="home" className={styles.hdrLogo}>
<span className={styles.hdrLogoIcon} aria-hidden="true">
<span className={styles.hdrLogoDot} />
</span>
<span className={styles.hdrLogoText}>
<span className={styles.hdrLogoName}>МЕРА</span>
<span className={styles.hdrLogoSub}>ДЛЯ СЕБЯ</span>
</span>
</PublicLink>
<div className={styles.hdrRight}>
<a href={B2B_URL} className={styles.hdrBizLink}>
Для бизнеса
</a>
</div>
</div>
</header>
);
}

View file

@ -4,7 +4,7 @@
*
* РЕКВИЗИТЫ И ДОКУМЕНТЫ НЕ из макета (там маска «ООО «МЕРА» · ИНН
* 66700XXXXX», это не настоящие данные), а из content.ts: `LEGAL_ENTITY`,
* `SUPPORT_EMAIL`, `OFFER_PATH`, `REFUND_PATH`, `PRIVACY_PATH` тот же набор,
* `SUPPORT_EMAIL`, короткие адреса документов (`PUBLIC_ROUTES`) тот же набор,
* что уже рендерит `_components/SiteFooter.tsx` (подвал v1/v2), только в
* другой сетке. Документы включают Политику возврата и почту поддержки:
* макет их не показывал, а это обязательные элементы платного контура
@ -15,27 +15,27 @@
* оплаты, которых нет, подпадает под ст. 5 ФЗ «О рекламе».
*
* «Статьи» в «Разделах» неактивны тем же образом, что в шапке (HeaderV3):
* раздела нет в коде. «Проверить квартиру» ведёт на #check (форма в Hero на
* этой же странице) отдельного маршрута отчёта не существует. Оба
* внутренних якоря через `/mera-public#...`: подвал рендерится и на
* оферте/возврате/ПДн, где голый `#check` никуда не долистал бы.
* раздела нет в коде.
*
* ССЫЛКИ КОРОТКИЕ (15.08.2026). «Проверить квартиру» ведёт на `/estimate`
* отдельный экран проверки, а не на якорь формы в герое. «МЕРА для бизнеса»
* стала абсолютной: относительный `/` на публичном домене вёл на сам лэндинг,
* то есть пункт не работал. Все переходы обычным `<a>` через `PublicLink`,
* потому что `next/link` подставляет basePath (см. его шапку).
*/
import Link from "next/link";
import { safeUrl } from "@/lib/safeUrl";
import {
B2B_URL,
LEGAL_ENTITY,
OFFER_PATH,
PRIVACY_PATH,
REFUND_PATH,
REGION_NAME,
SUPPORT_EMAIL,
SUPPORT_TELEGRAM_LABEL,
SUPPORT_TELEGRAM_URL,
} from "../../content";
import styles from "../../landing-v3.module.css";
import { PublicLink } from "../PublicLink";
export function FooterV3() {
const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL);
@ -58,12 +58,12 @@ export function FooterV3() {
<div>
<p className={styles.ftrColTitle}>Разделы</p>
<div className={styles.ftrLinks}>
<Link href="/mera-public" className={styles.ftrLink}>
<PublicLink to="home" className={styles.ftrLink}>
Главная
</Link>
<Link href="/mera-public#check" className={styles.ftrLink}>
</PublicLink>
<PublicLink to="estimate" className={styles.ftrLink}>
Проверить квартиру
</Link>
</PublicLink>
<span
className={styles.ftrLinkDisabled}
aria-disabled="true"
@ -71,9 +71,9 @@ export function FooterV3() {
>
Статьи
</span>
<Link href="/" className={styles.ftrLink}>
<a href={B2B_URL} className={styles.ftrLink}>
МЕРА для бизнеса
</Link>
</a>
</div>
</div>
@ -106,15 +106,15 @@ export function FooterV3() {
<div>
<p className={styles.ftrColTitle}>Документы</p>
<div className={styles.ftrLinks}>
<Link href={OFFER_PATH} className={styles.ftrLink}>
<PublicLink to="oferta" className={styles.ftrLink}>
Публичная оферта
</Link>
<Link href={REFUND_PATH} className={styles.ftrLink}>
</PublicLink>
<PublicLink to="refund" className={styles.ftrLink}>
Политика возврата
</Link>
<Link href={PRIVACY_PATH} className={styles.ftrLink}>
</PublicLink>
<PublicLink to="privacy" className={styles.ftrLink}>
Обработка персональных данных
</Link>
</PublicLink>
</div>
</div>
</div>

View file

@ -4,17 +4,13 @@
* FreeCheckCard карточка «Бесплатная проверка» на первом экране (макет v3,
* `МЕРА для себя v3.dc.html` строки ~129-170).
*
* ЧТО ОНА ДЕЛАЕТ СЕГОДНЯ И ПОЧЕМУ. Ровно то же, чем ограничена
* `../AddressForm.tsx` в закрытом v2-контуре, и по той же причине:
* `rbac_guard` (backend/app/core/rbac.py) не пускает анонима на
* `/api/v1/geocode/suggest` и `/api/v1/trade-in/estimate` их нет в
* `_PUBLIC_PATHS`. Открытие анонимного периметра отдельный backend-PR, вне
* границ этой задачи. Поэтому здесь: честная валидация 5 обязательных полей
* на клиенте + прямое сообщение «проверка ещё не открыта» вместо макетного
* сценария «спиннер 1100&nbsp;мс готовый результат». Тот сценарий на живом
* сайте отдавал бы одинаковые «14 объявлений / 47 дней» на любой введённый
* адрес не продающая формулировка, а обман конкретного человека (см. шапку
* `../../marketing-v3.ts`).
* ЧТО ОНА ДЕЛАЕТ СЕГОДНЯ. Проверяет пять полей и уводит на `/estimate`, где
* живёт сама проверка. Макетный сценарий «спиннер 1100&nbsp;мс готовый
* результат прямо в карточке» не воспроизводится и не будет: результату нужно
* место рядом с полями, а автокомплиту место под список; в карточке первого
* экрана нет ни того, ни другого. Раньше здесь стояло сообщение «проверка ещё
* не открыта» анонимных ручек не существовало; теперь они есть
* (`/api/public/mera/*`).
*
* Раздел «шаг 1 из 2» / полоска прогресса / ярлык рынка (`{{ cityMarket }}`,
* `{{ stepLabel }}`) из макета сюда сознательно НЕ перенесены: они требуют
@ -29,7 +25,8 @@
import { useId, useRef, useState } from "react";
import type { FormEvent } from "react";
import { PRIMARY_CITY, PUBLIC_ESTIMATE_ENABLED } from "../../content";
import { PRIMARY_CITY, PUBLIC_ROUTES } from "../../content";
import { saveDraft } from "../../estimate-draft";
import styles from "../../landing-v3.module.css";
/** Три состояния квартиры — дословно из макета (строка 734 исходника). */
@ -38,10 +35,7 @@ type Condition = (typeof CONDITIONS)[number];
type FieldName = "address" | "area" | "rooms" | "floor" | "condition";
type Feedback =
| { kind: "none" }
| { kind: "error"; field: FieldName; text: string }
| { kind: "info"; title: string; lines: readonly string[] };
type Feedback = { kind: "none" } | { kind: "error"; field: FieldName; text: string };
const NONE: Feedback = { kind: "none" };
@ -128,22 +122,17 @@ export function FreeCheckCard() {
return;
}
if (PUBLIC_ESTIMATE_ENABLED) {
// TODO(backend-периметр): здесь появится реальный запрос к
// POST /trade-in/coverage. По успешному ответу — переключить карточку в
// ветку результата и отрендерить `FreeResultV3` с полученными
// analogs/days (НЕ константами из marketing-v3.ts). Отдельный PR.
return;
}
setFeedback({
kind: "info",
title: "Бесплатную проверку по адресу мы пока не открыли",
lines: [
`Дом принят: ${trimmedArea} м², ${trimmedRooms}-комн., этаж ${trimmedFloor}, состояние «${condition}». Но онлайн-расчёт ещё не запущен — сервис работает по доступу для партнёров.`,
"Проверка появится на этой же странице. Если вопрос срочный — напишите нам в поддержку, ссылка в подвале.",
],
// Уходим на отдельный экран проверки — там живёт автокомплит и результат.
// Черновиком отдаём только то, что использует бесплатная проба покрытия:
// адрес, комнаты, площадь. Этаж и состояние она не использует вовсе (см.
// CoverageProbeInput на бэке) — их спросит платный шаг, когда появится,
// и спросит на том же экране, а не тащит через переход.
saveDraft({
address: trimmedAddress,
rooms: trimmedRooms,
area: trimmedArea,
});
window.location.assign(PUBLIC_ROUTES.estimate);
}
const errorField = feedback.kind === "error" ? feedback.field : null;
@ -285,16 +274,6 @@ export function FreeCheckCard() {
<p className={styles.heroFormFeedbackText}>{feedback.text}</p>
</div>
)}
{feedback.kind === "info" && (
<div className={styles.heroFormFeedback}>
<p className={styles.heroFormFeedbackTitle}>{feedback.title}</p>
{feedback.lines.map((line) => (
<p key={line} className={styles.heroFormFeedbackText}>
{line}
</p>
))}
</div>
)}
</div>
</form>
</div>

View file

@ -2,28 +2,29 @@
* HeaderV3 шапка лэндинга v3 (макет ~ строки 32-78): sticky, blur, лого +
* навигация + селектор города + CTA.
*
* Логотип НЕ ссылка на "/": next.config.ts редиректит "/" "/v2" (закрытый
* B2B-контур за RouteGuard) клик по вордмарку выкинул бы анонимного
* посетителя лэндинга в приложение, требующее логина (тот же комментарий, что
* в старом `_components/SiteHeader.tsx`). «Для бизнеса» ниже НАОБОРОТ,
* намеренно ведёт на "/": это единственная ссылка в шапке, которая должна
* увести в закрытый контур.
* Логотип НЕ ссылка: на публичном домене корень это сам лэндинг, то есть
* ссылка вела бы на текущую же страницу.
*
* «Статьи» раздела ещё нет в коде (ни маршрута, ни контента): пункт
* навигации нарочно неактивен (aria-disabled, без href), чтобы не заводить
* ссылку в 404 на первой же публичной странице.
* НЕАКТИВНЫЕ ПУНКТЫ. «Статьи», «Проверьте себя» и «Продажа под ключ»
* разделов нет в коде: ни маршрута, ни секции. Раньше три якоря вели на
* `/mera-public#game` и `#turnkey`, которых не существует НИГДЕ, то есть
* клик уводил на другую страницу и там ничего не находил. Пункты приведены к
* тому же виду, что «Статьи»: видны, но не кликабельны, пока раздела нет.
*
* Внутренние якоря идут через `/mera-public#...`, а не голый `#...`: шапка
* рендерится на каждой странице поддерева (оферта/возврат/ПДн через
* layout.tsx), и там `#accuracy` без пути к самому лэндингу никуда бы не
* долистал.
* ЯКОРЯ голые (`#accuracy`, `#check`), потому что эта шапка рендерится
* только на странице, где эти секции и лежат. На юридических страницах стоит
* другая шапка (`_components/SiteHeader.tsx`), на `/estimate` своя,
* укороченная (`_components/estimate/EstimateHeader.tsx`): туда переносить
* навигацию по секциям лэндинга незачем, там человек уже пришёл считать.
*
* «Для бизнеса» ведёт на АБСОЛЮТНЫЙ адрес B2B-контура: относительный «/» на
* публичном домене вёл на сам лэндинг, то есть пункт не работал вовсе.
*
* Единственный клиентский остров `CityPicker` (открытие/закрытие, поиск).
* Сама шапка серверная.
*/
import Link from "next/link";
import { B2B_URL } from "../../content";
import styles from "../../landing-v3.module.css";
import { CityPicker } from "./CityPicker";
@ -43,32 +44,29 @@ export function HeaderV3() {
</div>
<nav className={styles.hdrNav} aria-label="Основная навигация">
<Link href="/mera-public#accuracy" className={styles.hdrNavLink}>
<a href="#accuracy" className={styles.hdrNavLink}>
Точность
</Link>
<Link href="/mera-public#game" className={styles.hdrNavLink}>
Проверьте себя
</Link>
<Link href="/mera-public#turnkey" className={styles.hdrNavLink}>
Продажа под ключ
</Link>
<span
className={styles.hdrNavDisabled}
aria-disabled="true"
title="Раздел скоро откроется"
>
Статьи
</span>
</a>
{["Проверьте себя", "Продажа под ключ", "Статьи"].map((name) => (
<span
key={name}
className={styles.hdrNavDisabled}
aria-disabled="true"
title="Раздел скоро откроется"
>
{name}
</span>
))}
</nav>
<div className={styles.hdrRight}>
<CityPicker />
<Link href="/" className={styles.hdrBizLink}>
<a href={B2B_URL} className={styles.hdrBizLink}>
Для бизнеса
</Link>
<Link href="/mera-public#check" className={styles.hdrCta}>
</a>
<a href="#check" className={styles.hdrCta}>
Проверить за 30 секунд
</Link>
</a>
</div>
</div>
</header>

View file

@ -1,12 +1,11 @@
/**
* StickyCtaV3 липкая нижняя CTA-панель (макет ~ строки 518-521).
*
* Кнопка обычная ссылка на `/mera-public#check` (форма расчёта в Hero), а
* не модалка: в этом заходе модального окна нет вовсе (макет вызывал
* `openModal`, которого в коде не существует). Путь с префиксом, а не голый
* `#check`: панель рендерится на каждой странице поддерева (оферта/возврат/
* ПДн через layout.tsx), и там `#check` без пути к лэндингу никуда бы не
* долистал.
* Кнопка ведёт на `/estimate` отдельный экран проверки, а не на якорь формы
* в герое и не в модалку (макет вызывал `openModal`, которого в коде нет).
* Панель липкая и видна в том числе внизу страницы, где формы уже не видно,
* якорь заставлял бы человека прокручивать обратно наверх ради полей, которые
* на отдельном экране и так первое, что он увидит.
*
* `.barSpacer` не декоративный отступ, а компенсация fixed-позиционирования:
* `.barRoot` вырезан из потока документа, поэтому без спейсера он бы лёг
@ -17,10 +16,10 @@
* после подвала, как Header/Footer в текущем `layout.tsx`).
*/
import Link from "next/link";
import styles from "../../landing-v3.module.css";
import { PublicLink } from "../PublicLink";
export function StickyCtaV3() {
return (
<>
@ -29,9 +28,9 @@ export function StickyCtaV3() {
<p className={styles.barText}>
Узнайте цену и срок продажи своей квартиры
</p>
<Link href="/mera-public#check" className={styles.barBtn}>
<PublicLink to="estimate" className={styles.barBtn}>
Проверить за 30 секунд
</Link>
</PublicLink>
</div>
</>
);

View file

@ -34,30 +34,29 @@ import {
// ---------------------------------------------------------------------------
/**
* Включён ли публичный расчёт по адресу.
* Включён ли ПЛАТНЫЙ публичный расчёт по адресу.
*
* Сегодня `false` и это не «недоделка фронта»: анонимный запрос к
* `/api/v1/geocode/suggest` и `/api/v1/trade-in/estimate` отбивается
* `rbac_guard` (backend/app/core/rbac.py::_PUBLIC_PATHS эти пути в белом
* списке отсутствуют), т.е. без отдельного backend-PR любая «живая» форма на
* лэндинге отдавала бы 401. Форма поэтому честно сообщает, что расчёт ещё не
* открыт, вместо имитации загрузки.
*
* Когда бэкенд откроет анонимный периметр переключить в `true` и подключить
* реальный сабмит в `_components/AddressForm.tsx` (там помечено TODO-местом).
* ЗНАЧЕНИЕ ФЛАГА СУЗИЛОСЬ 15.08.2026. Раньше он означал «публичная форма
* вообще что-нибудь делает»: анонимных ручек не существовало, `rbac_guard`
* отбивал любой запрос с лэндинга, и форма честно писала «расчёт не открыт».
* Теперь бесплатная часть ОТКРЫТА `/estimate` ходит в `/api/public/mera/*`
* (см. `backend/app/api/public/mera.py`) и показывает живую картину рынка
* вокруг дома. Флаг остался только на платном шаге, и это правильное место
* для него: бесплатная проба ничего не хранит, а платный расчёт хранит.
*
* ГЕЙТ: это НЕ однострочник. Переключение в `true` делает ложными публичные
* утверждения, которые сегодня правдивы, поэтому вместе с флагом обязаны быть
* сделаны:
* 1. `privacy/page.tsx`, раздел «Что делает эта страница» он УЖЕ условный по
* этому флагу (ветка `true` описывает отправку и сохранение адреса);
* перечитать текст обеих веток перед включением.
* 1. `privacy/page.tsx`: п. 5.5 условен по этому флагу и утверждает, что
* платный расчёт не открыт. Перечитать раздел 5 целиком п. 5.4 уже
* описывает бесплатную часть и переписан под неё.
* 2. Согласие на обработку ПДн должно фиксироваться ДО первого INSERT в
* `trade_in_estimates`: сегодня адрес физлица попадает в БД раньше любого
* согласия (`address` NOT NULL, `expires_at` применяется только на чтении).
* 3. Должен существовать реальный путь удаления данных в бэкенде нет ни
* DELETE-джоба в `app/tasks/**`, ни ручки erasure (проверено grep'ом);
* privacy-страница поэтому и не обещает удаление.
* К бесплатной пробе это не относится она не пишет вовсе.
* 3. Автоматический возврат средств обязан появиться ВМЕСТЕ с кнопкой
* оплаты, а не после: он уже обещан в опубликованной политике возврата
* (пп. 2.1 и 4), то есть является условием договора с момента публикации.
*/
export const PUBLIC_ESTIMATE_ENABLED: boolean = false;
@ -151,11 +150,12 @@ export const LEGAL_ENTITY: {
* (rewrite на это же поддерево), и ИМЕННО короткие напечатаны в самих
* документах и уйдут в заявку эквайеру.
*
* Навигация внутри сайта ходит по внутренним путям, а не по коротким: короткий
* `/oferta` существует только на хосте meraocenka.ru, а то же поддерево
* открывается и с gendsgn.ru/trade-in/mera-public там ссылка на `/oferta`
* ушла бы в 404. Канонический адрес для внешнего мира проставлен на каждой
* странице через `alternates.canonical` (см. PUBLIC_ORIGIN ниже).
* НАВИГАЦИЯ ПО НИМ БОЛЬШЕ НЕ ХОДИТ (решение владельца, 15.08.2026):
* человек не должен видеть `/trade-in/mera-public/...` в адресной строке
* публичного сервиса. Ссылки внутри публичных страниц ведут на короткие
* адреса см. `PUBLIC_ROUTES` ниже. Эти константы остались нужны для двух
* вещей: цель Caddy-rewrite'ов описывается ими же, и на них ссылается B2B
* (чекбокс согласия в v2 LeadForm.tsx), живущий на другом домене.
*/
// PRIVACY_PATH ре-экспортируется из "@/lib/legal-copy" (не литерал здесь) — тот же
// путь нужен B2B trade-in виджетам (чекбокс согласия в v2 LeadForm.tsx), которым
@ -164,13 +164,54 @@ export { PRIVACY_PATH } from "@/lib/legal-copy";
export const OFFER_PATH = "/mera-public/oferta";
export const REFUND_PATH = "/mera-public/refund";
/**
* КОРОТКИЕ АДРЕСА ПУБЛИЧНОГО САЙТА единственное, что должно попадать в
* ссылки на страницах B2C-периметра.
*
* ПОЧЕМУ НЕ `next/link` С ЭТИМИ ЗНАЧЕНИЯМИ. `basePath=/trade-in` запечён в
* образ, и `<Link href="/estimate">` эмитит `/trade-in/estimate` путь,
* которого не существует. Обойти basePath через Link нельзя, поэтому
* публичные страницы ходят обычным `<a>` (компонент `PublicLink`, см.
* `_components/PublicLink.tsx`). Полная перезагрузка на пяти статических
* страницах ничего не стоит, а router-prefetch публичному дереву даже вреден:
* он тянул бы чанки, которых анонимному посетителю видеть незачем.
*
* ЧЕМ ЗА ЭТО ПЛАТИМ. Короткие пути раздаёт ТОЛЬКО хост meraocenka.ru. То же
* поддерево открывается и с `gendsgn.ru/trade-in/mera-public` там переходы
* по этим ссылкам уедут в чужой сайт (`/` корень Site Finder) или в 404.
* Это принято сознательно: публичный сервис живёт на своём домене, и делать
* его адреса некрасивыми ради QA-открытия с соседнего хоста не та цена.
* Открывать лэндинг для проверки нужно на meraocenka.ru (он публичный, логин
* не требуется). Длинные адреса продолжают работать: Caddy отдаёт с них 301
* на короткие, так что старые ссылки и закладки не ломаются.
*/
export const PUBLIC_ROUTES = {
home: "/",
estimate: "/estimate",
oferta: "/oferta",
refund: "/refund",
privacy: "/privacy",
} as const;
/** Канонический публичный origin сервиса — база для canonical-ссылок. */
export const PUBLIC_ORIGIN = "https://meraocenka.ru";
/**
* B2B-контур («МЕРА для бизнеса») АБСОЛЮТНЫЙ адрес, а не относительный путь.
*
* Он живёт на другом домене. Относительная ссылка `/` (как было в подвале и
* шапке v3) на публичном домене вела на сам же лэндинг или в 404 то есть
* пункт меню «для бизнеса» не работал вовсе. Домен здесь захардкожен
* осознанно: это ссылка НАРУЖУ, из одного продукта в другой, и подставлять её
* из текущего хоста было бы неверно.
*/
export const B2B_URL = "https://gendsgn.ru/trade-in/";
/** Короткие публичные адреса тех же страниц (то, что напечатано в документах). */
export const PUBLIC_PRIVACY_URL = `${PUBLIC_ORIGIN}/privacy`;
export const PUBLIC_OFFER_URL = `${PUBLIC_ORIGIN}/oferta`;
export const PUBLIC_REFUND_URL = `${PUBLIC_ORIGIN}/refund`;
export const PUBLIC_PRIVACY_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.privacy}`;
export const PUBLIC_OFFER_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.oferta}`;
export const PUBLIC_REFUND_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.refund}`;
export const PUBLIC_ESTIMATE_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.estimate}`;
/**
* Цена одного расчёта, . Названа в оферте (п. 4.1) и в политике возврата

View file

@ -0,0 +1,130 @@
/**
* Проба покрытия то, что видит человек.
*
* Вынесено из компонента отдельной чистой функцией сознательно: это не
* форматирование, а место, где решается, что именно мы утверждаем о наших
* данных. Такое проверяется тестами напрямую (`__tests__/coverage-copy.test.ts`),
* а не через рендер формы.
*
* ДВА ПРАВИЛА, КОТОРЫЕ ЗДЕСЬ ЗАЩИЩАЮТСЯ
*
* 1. «Никогда не блокировать вывод» (директива владельца, 2026-08-10). Мало
* данных говорим прямо и предлагаем следующий шаг, а не показываем
* экран-заглушку. Пустой экран человек читает как «сервис сломан», а не
* как «по этому дому данных нет».
*
* 2. Честное имя величины. `median_listing_age_days` возраст АКТИВНОГО
* объявления, а не срок продажи: выборка цензурирована, эти квартиры ещё
* продаются, реальная экспозиция проданных всегда меньше. Написать под
* цифрой «за столько продаются» соврать; поэтому подпись здесь одна и
* тестом запинена. И при `median === null` (бэкенд занулил её, когда
* возраст известен меньше чем по пяти объявлениям) плитки просто нет
* ноль вместо неизвестного значения был бы худшей из ошибок.
*/
import type { CoverageProbe } from "./public-api";
export interface CoverageTile {
value: string;
label: string;
}
export interface CoverageVerdict {
/** Влияет только на оформление (цвет плашки), не на смысл. */
tone: "ok" | "thin" | "none";
title: string;
/** Абзацы под заголовком. */
lines: readonly string[];
tiles: readonly CoverageTile[];
/** Показывать ли блок платного шага. */
offerPaidStep: boolean;
}
function pluralize(n: number, one: string, few: string, many: string): string {
const mod100 = n % 100;
if (mod100 >= 11 && mod100 <= 14) return many;
const mod10 = n % 10;
if (mod10 === 1) return one;
if (mod10 >= 2 && mod10 <= 4) return few;
return many;
}
function radiusLabel(radiusM: number): string {
return radiusM % 1000 === 0
? `${radiusM / 1000} км`
: `${radiusM} м`;
}
function tilesFor(probe: CoverageProbe): CoverageTile[] {
const tiles: CoverageTile[] = [
{
value: String(probe.n_listings),
label: `${pluralize(probe.n_listings, "похожая квартира", "похожие квартиры", "похожих квартир")} продаётся в радиусе ${radiusLabel(probe.radius_m)}`,
},
];
if (probe.median_listing_age_days !== null) {
tiles.push({
value: `${probe.median_listing_age_days} дн.`,
// Формулировка запинена тестом — см. шапку модуля.
label: `столько в среднем висит объявление из этих ${probe.n_with_age}`,
});
}
return tiles;
}
export function describeCoverage(probe: CoverageProbe): CoverageVerdict {
const where = probe.city ? `в городе ${probe.city}` : "по этому адресу";
if (probe.status === "not_covered") {
// Город не резолвится ни в один из тех, по которым мы собираем данные.
if (!probe.city) {
return {
tone: "none",
title: "Этот адрес вне области, по которой мы собираем данные",
lines: [
"Сейчас это Свердловская область: Екатеринбург целиком и ещё несколько городов вокруг. По другим регионам данных у нас нет — и придумывать цифру мы не станем.",
"Проверьте, что в адресе указан нужный город, или попробуйте другой дом.",
],
tiles: [],
offerPaidStep: false,
};
}
// Город наш, но когорта пустая.
return {
tone: "none",
title: "Рядом сейчас не продаётся ничего похожего",
lines: [
`Мы искали квартиры ${where} — в радиусе ${radiusLabel(probe.radius_m)} от дома, с тем же числом комнат и близкой площадью. Активных объявлений под эти условия не нашлось.`,
"Это говорит о рынке, а не о вашей квартире: в тихом районе или по редкой планировке такое обычное дело. Попробуйте изменить площадь или число комнат — посмотрим, что есть по соседству.",
],
tiles: [],
offerPaidStep: false,
};
}
if (probe.status === "thin") {
return {
tone: "thin",
title: "Данные есть, но их мало",
lines: [
`${where.charAt(0).toUpperCase()}${where.slice(1)} мы нашли ${probe.n_listings} ${pluralize(probe.n_listings, "подходящее объявление", "подходящих объявления", "подходящих объявлений")} — меньше, чем ${probe.threshold}, с которых мы считаем выборку уверенной.`,
"Расчёт по такой выборке мы сделаем, но разброс будет шире обычного, и в самом отчёте это будет написано — а не спрятано.",
],
tiles: tilesFor(probe),
offerPaidStep: true,
};
}
return {
tone: "ok",
title: "По вашему дому данные есть",
lines: [
`${where.charAt(0).toUpperCase()}${where.slice(1)} нашлось достаточно похожих квартир, чтобы считать по ним, а не по среднему за квадратный метр.`,
],
tiles: tilesFor(probe),
offerPaidStep: true,
};
}

View file

@ -0,0 +1,88 @@
/**
* Черновик заявки: то, что человек уже набрал на лэндинге, чтобы не набирать
* это заново на `/estimate`.
*
* ПОЧЕМУ sessionStorage, А НЕ QUERY-ПАРАМЕТРЫ. Очевидный способ уйти на
* `/estimate?address=Ленина+5`. Так делать нельзя: адрес квартиры в URL
* попадёт в access-логи Caddy (`/var/log/caddy/meraocenka.ru.log`) рядом с IP
* посетителя то есть мы начнём хранить персональные данные ровно там, где
* публично обещали ничего не хранить, и до всякого согласия (#2895).
* sessionStorage не уходит с машины вообще: ни в запрос, ни в лог, ни в
* Referer соседнего сайта. Живёт до закрытия вкладки ровно столько, сколько
* нужно для перехода между двумя страницами.
*
* Не заводить здесь ничего сверх этих трёх полей: чем больше сюда положить,
* тем сильнее соблазн начать хранить черновики «на подольше».
*/
const KEY = "mera:estimate-draft";
export interface EstimateDraft {
address: string;
/** Строкой — это ровно то, что было в поле ввода, без домыслов о числе. */
rooms?: string;
area?: string;
city?: string;
}
/**
* Все обращения обёрнуты: в приватном режиме Safari и при выключенном
* хранилище `sessionStorage` бросает на записи, а на чтении может вернуть
* мусор от постороннего кода. Черновик удобство; уронить из-за него страницу
* нельзя.
*/
export function saveDraft(draft: EstimateDraft): void {
try {
window.sessionStorage.setItem(KEY, JSON.stringify(draft));
} catch {
// Не сохранился — человек просто наберёт адрес заново.
}
}
export function takeDraft(): EstimateDraft | null {
try {
const raw = window.sessionStorage.getItem(KEY);
if (!raw) return null;
// Забираем «на вынос»: черновик нужен ровно на один переход. Иначе
// возврат на /estimate через неделю в той же вкладке подставил бы чужой
// по смыслу адрес.
window.sessionStorage.removeItem(KEY);
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return null;
const value = parsed as Record<string, unknown>;
if (typeof value.address !== "string") return null;
return {
address: value.address,
rooms: typeof value.rooms === "string" ? value.rooms : undefined,
area: typeof value.area === "string" ? value.area : undefined,
city: typeof value.city === "string" ? value.city : undefined,
};
} catch {
return null;
}
}
/**
* Привести «Комнат» из черновика к значению, которое реально есть в селекте.
*
* На лэндинге это поле свободный текст: туда пишут «студия», «2 комнаты»,
* «2-к». Такая строка не совпадает ни с одним `<option>`, селект молча
* показывает пустоту (человек этого может не заметить), а на сабмите
* `parseInt` даёт NaN на сервер уходит `rooms: null` 422, который форма
* покажет как «сломалось на нашей стороне». Возвращаем null, если распознать
* не удалось: вызывающий оставит значение по умолчанию оно хотя бы видно и
* его можно поправить.
*/
export function normalizeDraftRooms(
raw: string | undefined,
allowed: readonly string[],
): string | null {
const value = raw?.trim() ?? "";
if (!value) return null;
if (allowed.includes(value)) return value;
if (/^студи/i.test(value)) return allowed.includes("0") ? "0" : null;
const digits = value.match(/\d+/)?.[0];
return digits && allowed.includes(digits) ? digits : null;
}

View file

@ -0,0 +1,61 @@
import type { Metadata } from "next";
import { EstimateFlow } from "../_components/estimate/EstimateFlow";
import { EstimateHeader } from "../_components/estimate/EstimateHeader";
import { FooterV3 } from "../_components/v3/FooterV3";
import { PUBLIC_ESTIMATE_URL } from "../content";
import styles from "../landing-v3.module.css";
/**
* Страница проверки квартиры `meraocenka.ru/estimate`.
*
* ЗАЧЕМ ОТДЕЛЬНЫМ ЭКРАНОМ. Лэндинг и расчёт решают разные задачи. Лэндинг
* объясняет и убеждает, поэтому форма там витрина: короткая, в окружении
* секций, которые за неё же и агитируют. Пришедший считать человек в этом
* окружении мешает сам себе: подсказкам адреса нужно место под список,
* результату место рядом с полями, а соседние секции в этот момент только
* отвлекают. Отсюда отдельный адрес, на который ведут все кнопки лэндинга.
*
* Побочный выигрыш этот URL можно дать человеку напрямую (в переписке, в
* рекламе), минуя чтение лэндинга.
*
* NOINDEX по той же причине, что и на остальных страницах B2C-дерева:
* маркетинговые метрики на лэндинге пока плейсхолдеры из макета (#2904), а
* приём оплаты не подключён. Снимать вместе с ними, одним решением.
*/
export const metadata: Metadata = {
title: "Проверить квартиру — МЕРА",
description:
"Бесплатно посмотреть, сколько похожих квартир продаётся рядом с вашим домом и как долго висят их объявления.",
alternates: { canonical: PUBLIC_ESTIMATE_URL },
robots: { index: false, follow: false },
};
export default function MeraEstimatePage() {
return (
<>
<EstimateHeader />
<main id="content" className={styles.estSection} tabIndex={-1}>
<div className={styles.estContainer}>
<p className={styles.heroResultEyebrow}>БЕСПЛАТНАЯ ПРОВЕРКА</p>
<h1 className={styles.estTitle}>Что происходит с ценами рядом с вашим домом</h1>
<p className={styles.estLead}>
Укажите дом, число комнат и площадь покажем, сколько похожих
квартир сейчас продаётся вокруг и как долго они висят в продаже. Без
регистрации, без звонков; введённый адрес мы никуда не сохраняем.
</p>
<EstimateFlow />
<p className={styles.estDisclaimer}>
Это рыночная справка по сопоставимым объектам, а не отчёт об оценке
по 135-ФЗ: для банка, суда, опеки или нотариуса нужен отчёт
аккредитованного оценщика.
</p>
</div>
</main>
<FooterV3 />
</>
);
}

View file

@ -1355,3 +1355,130 @@
opacity: 0.72;
margin-top: 8px;
}
/* ==========================================================================
* Страница проверки квартиры (/estimate) префикс est.
* Секция = estimate/page.tsx + _components/estimate/EstimateFlow.tsx.
*
* Переиспользует поля и плитки результата из блока «Герой» (.hero*): это те же
* элементы того же макета, и разводить вторую копию их оформления значило бы
* завести два места, где живёт вид одного и того же поля ввода. Здесь
* только то, чего в герое нет: шапка страницы, выпадашка автокомплита и
* блок платного шага.
* ========================================================================== */
.estSection {
background: var(--b2c-page-bg);
padding-block: clamp(28px, 5vw, 64px) clamp(48px, 8vw, 96px);
min-height: 60vh;
}
.estContainer {
max-width: 760px;
margin-inline: auto;
padding-inline: clamp(20px, 5vw, 56px);
}
.estTitle {
margin: 10px 0 0;
font-size: clamp(28px, 4.4vw, 44px);
line-height: 1.1;
letter-spacing: -0.025em;
font-weight: 800;
color: var(--b2c-ink);
text-wrap: balance;
}
.estLead {
margin: 14px 0 0;
font-size: clamp(15px, 1.6vw, 17px);
line-height: 1.65;
color: var(--b2c-muted);
max-width: 60ch;
}
.estCard {
margin-top: clamp(24px, 4vw, 36px);
background: var(--b2c-surface);
border: 1px solid var(--b2c-line);
border-radius: 14px;
padding: clamp(18px, 3vw, 28px);
box-shadow: 0 24px 64px color-mix(in srgb, var(--b2c-ink) 8%, transparent);
}
.estDisclaimer {
margin-top: clamp(20px, 3vw, 28px);
font-size: 13.5px;
line-height: 1.6;
color: var(--b2c-muted);
}
/* --- Автокомплит ---------------------------------------------------------
* Выпадашка позиционируется абсолютно: список, раздвигающий форму, смещает
* поля под курсором на каждое нажатие клавиши попасть мышью в подсказку
* становится нельзя. */
.estAutocomplete {
position: relative;
}
.estSuggestList {
position: absolute;
z-index: 20;
inset-inline: 0;
top: 100%;
margin: 4px 0 0;
padding: 4px;
list-style: none;
background: var(--b2c-surface);
border: 1px solid var(--b2c-line);
border-radius: 10px;
box-shadow: 0 18px 40px color-mix(in srgb, var(--b2c-ink) 14%, transparent);
max-height: 320px;
overflow-y: auto;
}
.estSuggestItem {
display: block;
width: 100%;
text-align: start;
padding: 9px 11px;
border: 0;
border-radius: 7px;
background: none;
cursor: pointer;
font: inherit;
color: var(--b2c-ink);
}
.estSuggestItem:hover,
.estSuggestItemActive {
background: var(--b2c-surface-soft);
}
.estSuggestMain {
display: block;
font-size: 14.5px;
font-weight: 600;
}
.estSuggestSub {
display: block;
margin-top: 2px;
font-size: 12.5px;
color: var(--b2c-muted);
}
/* --- Результат ----------------------------------------------------------- */
.estResult {
margin-top: clamp(18px, 3vw, 26px);
padding-top: clamp(18px, 3vw, 24px);
border-top: 1px solid var(--b2c-line);
}
.estPaidStep {
margin-top: 18px;
padding: 16px 18px;
border: 1px solid var(--b2c-line);
border-radius: 12px;
background: var(--b2c-surface-soft);
}

View file

@ -3,7 +3,7 @@ import { IBM_Plex_Mono, Manrope } from "next/font/google";
import { REGION_NAME } from "./content";
import styles from "./landing.module.css";
import { landingVars } from "./theme";
import { b2cVars, landingVars } from "./theme";
/**
* ШАПКА И ПОДВАЛ ЖИВУТ В СТРАНИЦАХ, А НЕ ЗДЕСЬ (15.08.2026). Раньше layout
@ -76,7 +76,7 @@ export default function MeraPublicLayout({
return (
<div
className={`${manrope.variable} ${plexMono.variable} ${styles.page}`}
style={landingVars}
style={{ ...landingVars, ...b2cVars }}
>
{/* Цель ссылки <main id="content"> с tabIndex={-1} (см. page.tsx и
privacy/page.tsx). Без tabIndex Safari/VoiceOver не переносит фокус на

View file

@ -1,18 +1,17 @@
import type { Metadata } from "next";
import Link from "next/link";
import {
LEGAL_DOCS_REVISION,
LEGAL_ENTITY,
PRIVACY_PATH,
PUBLIC_OFFER_URL,
PUBLIC_REFUND_URL,
REFUND_PATH,
SERVICE_PRICE_RUB,
SUPPORT_EMAIL,
} from "../content";
import styles from "../landing.module.css";
import { PublicLink } from "../_components/PublicLink";
import { SiteFooter } from "../_components/SiteFooter";
import { SiteHeader } from "../_components/SiteHeader";
@ -62,12 +61,12 @@ export default function MeraPublicOfferPage() {
<SiteHeader />
<main id="content" className={styles.main} tabIndex={-1}>
<div className={`${styles.container} ${styles.doc}`}>
<Link
<PublicLink
className={`${styles.link} ${styles.docBack}`}
href="/mera-public"
to="home"
>
На главную
</Link>
</PublicLink>
<h1 className={styles.h2}>Публичная оферта</h1>
<p className={styles.docMeta}>
@ -315,9 +314,9 @@ export default function MeraPublicOfferPage() {
<p>
8.0. Условия настоящего раздела дублируются и подробно раскрыты в
отдельном документе {" "}
<Link className={styles.link} href={REFUND_PATH}>
<PublicLink className={styles.link} to="refund">
Политике возврата денежных средств
</Link>
</PublicLink>
, размещённой по адресу {PUBLIC_REFUND_URL}. В случае расхождений
между настоящим разделом и указанным документом применяется
настоящая Оферта.
@ -380,9 +379,9 @@ export default function MeraPublicOfferPage() {
<p>
9.1. Обработка персональных данных Заказчика осуществляется
Исполнителем в соответствии с{" "}
<Link className={styles.link} href={PRIVACY_PATH}>
<PublicLink className={styles.link} to="privacy">
Политикой обработки персональных данных
</Link>
</PublicLink>
, размещённой на Сайте и являющейся неотъемлемой частью настоящей
Оферты.
</p>

View file

@ -1,5 +1,4 @@
import type { Metadata } from "next";
import Link from "next/link";
import {
LEGAL_ENTITY,
@ -12,6 +11,8 @@ import {
SUPPORT_TELEGRAM_URL,
} from "../content";
import styles from "../landing.module.css";
import { PublicLink } from "../_components/PublicLink";
import { safeUrl } from "@/lib/safeUrl";
import { SiteFooter } from "../_components/SiteFooter";
@ -29,9 +30,29 @@ import { SiteHeader } from "../_components/SiteHeader";
* стала неудобной, а потому, что перестала быть правдой.
*
* ЧТО ОСТАЛОСЬ ОТ ПРЕЖНЕЙ СТРАНИЦЫ правило честности:
* - Раздел про страницу ввода адреса УСЛОВЕН по `PUBLIC_ESTIMATE_ENABLED`.
* Пока публичный расчёт выключен, адрес физически не покидает браузер, и
* писать обратное нельзя; текст меняется вместе с флагом, а не потом.
* - Раздел про страницу ввода адреса переписан 15.08.2026 ВМЕСТЕ с кодом.
* Прежний п. 5.4 утверждал, что введённый адрес не покидает браузер, и
* это было правдой, пока форма ничего не отправляла. С открытием
* бесплатной проверки (`/estimate` `/api/public/mera/*`) адрес уходит на
* сервер, поэтому утверждение заменено на точное: передаётся, используется
* однократно, нигде не сохраняется. Каждая половина этого утверждения
* обеспечена кодом, а не надеждой:
* - БАЗЫ. `suggest()` работает без кэша (докстринг
* `services/geocoder.py`), проба покрытия один SELECT, `RequestAudit`
* пишет строку только при наличии username, которого у анонима нет.
* - ЖУРНАЛЫ. Геокодер печатал введённую строку открытым текстом, а прод
* пишет stdout контейнеров в persistent journald то есть адрес
* ложился на диск рядом с IP того же запроса в access-логе Caddy.
* Закрыто фильтром логов на время публичного запроса
* (`app/core/public_request.py`).
* - МОНИТОРИНГ. sentry_sdk кладёт в событие полное тело запроса, а
* брэдкрамб httpx несёт адрес в query внешнего геокодера. Закрыто
* `scrub_public_address` (`app/observability/sentry_scrub.py`).
* Две последние утечки найдены состязательным ревью 16.08.2026 и
* воспроизведены на живом коде не выведены из чтения.
* - Пункт про ПЛАТНЫЙ расчёт остался условным по `PUBLIC_ESTIMATE_ENABLED`:
* вот он действительно хранит результат, и его текст обязан меняться
* вместе с флагом, а не после.
* - Срок хранения оплаченного отчёта рендерится из
* `PAID_REPORT_RETENTION_MONTHS`, а НЕ числом, вписанным в текст, это
* гейтится тестом `test_paid_retention_text_consistency.py` (бэкендовая
@ -65,12 +86,12 @@ export default function MeraPublicPrivacyPage() {
<SiteHeader />
<main id="content" className={styles.main} tabIndex={-1}>
<div className={`${styles.container} ${styles.doc}`}>
<Link
<PublicLink
className={`${styles.link} ${styles.docBack}`}
href="/mera-public"
to="home"
>
На главную
</Link>
</PublicLink>
<h1 className={styles.h2}>
Политика обработки персональных данных
@ -197,13 +218,23 @@ export default function MeraPublicPrivacyPage() {
сервисов; статические ресурсы страницы (шрифты и т. п.) подключаются с
домена Оператора.
</p>
<p>
5.4. Бесплатная проверка по адресу выполняется без регистрации и без
оплаты. Введённый адрес передаётся на сервер Оператора без этого
подобрать сопоставимые объекты невозможно, используется однократно
для формирования подсказок адреса и поиска сопоставимых объявлений и
не сохраняется: указанные операции выполняются в режиме чтения и не
создают записей об адресе в базах данных Оператора. Адрес передаётся
телом запроса и не записывается ни в журналы веб-сервера, ни в
технические журналы сервиса, ни в систему мониторинга ошибок. Для
формирования подсказок адрес может передаваться внешним сервисам
геокодирования.
</p>
{PUBLIC_ESTIMATE_ENABLED ? null : (
<p>
5.4. На сегодняшний день публичный расчёт по адресу на Сайте ещё не
открыт: форма адреса на главной странице работает только в браузере
пользователя введённый адрес не передаётся на сервер Оператора и
нигде не сохраняется. Пункты 5.15.2 применяются с момента открытия
публичного расчёта.
5.5. Платный расчёт по адресу на Сайте на сегодняшний день ещё не
открыт. Пункты 5.15.2 в части хранения результата расчёта
применяются с момента его открытия.
</p>
)}

View file

@ -0,0 +1,150 @@
/**
* Клиент публичного (анонимного) API МЕРЫ единственный модуль лэндинга,
* который вообще ходит в сеть.
*
* ПОЧЕМУ НЕ `@/lib/api`. Тот модуль часть закрытого контура: тянет
* `sessionId`, обработку 401 и редирект на /login. Публичное дерево
* `app/mera-public/**` держится пустым по зависимостям (guard
* `scripts/check-mera-public-isolation.mjs`), потому что root-layout
* оборачивает в `RouteGuard` в том числе и лэндинг: всё, что импортировано
* здесь статически, webpack кладёт в чанк, который скачивает анонимный
* посетитель. Поэтому свой тонкий `fetch` без единого импорта.
*
* ПОЧЕМУ ПУТЬ С `/trade-in`, ХОТЯ ДОМЕН ПУБЛИЧНЫЙ. Один и тот же URL обязан
* работать и на `meraocenka.ru/`, и на `gendsgn.ru/trade-in/mera-public`
* (оттуда страницу открывают для QA). На gendsgn.ru корневой `/api/*` занят
* бэкендом Site Finder, поэтому короткий `/api/public/*` потребовал бы там
* второго Caddy-handle, выигрывающего по специфичности у существующего то
* есть работоспособность формы зависела бы от порядка сортировки матчеров в
* чужом site-блоке. С префиксом `/trade-in` запрос ловит уже существующий
* `handle /trade-in/api/*`, а на публичном домене заведён ровно один новый.
* Разбор периметра `backend/app/api/public/mera.py`.
*
* NB: это не `basePath` Next'а. `fetch` его не подставляет префикс здесь
* настоящая часть URL, и хардкодить её приходится осознанно.
*/
const API_BASE = "/trade-in/api/public/mera";
/** Кандидат автокомплита. Поля — как в `SuggestItem` бэкенда. */
export interface AddressSuggestion {
label: string;
full_address: string;
lat: number;
lon: number;
kind: string;
fias_id: string | null;
}
/**
* Ответ пробы покрытия. Зеркалит `CoverageProbeResponse` (backend
* app/schemas/trade_in.py) с той же оговоркой про честность имён:
*
* `median_listing_age_days` возраст АКТИВНОГО объявления, а не срок продажи.
* Выборка цензурирована (эти квартиры ещё продаются), поэтому называть её
* «за сколько продаются» нельзя. Бэкенд принудительно отдаёт null, если
* возраст известен меньше чем по пяти объявлениям, UI обязан этот null
* пережить и просто не показывать плитку, а не рисовать «0 дней».
*/
export interface CoverageProbe {
status: "ok" | "thin" | "not_covered";
n_listings: number;
median_listing_age_days: number | null;
n_with_age: number;
radius_m: number;
city: string;
threshold: number;
}
export interface CoverageQuery {
lat: number;
lon: number;
rooms: number;
area_m2: number;
}
/** Почему запрос не удался — от этого зависит текст, который увидит человек. */
export type ApiFailureKind = "rate-limited" | "offline" | "server";
export class PublicApiError extends Error {
readonly kind: ApiFailureKind;
/** Сколько секунд ждать (только для `rate-limited`). */
readonly retryAfterS: number | null;
constructor(kind: ApiFailureKind, message: string, retryAfterS: number | null = null) {
super(message);
this.name = "PublicApiError";
this.kind = kind;
this.retryAfterS = retryAfterS;
}
}
/**
* Единая обработка ответа. 429 отделён от прочих ошибок намеренно: «слишком
* часто, подождите минуту» это инструкция, которую человек может выполнить,
* а «что-то пошло не так» на том же месте выглядит поломкой сервиса и уводит
* его со страницы.
*/
async function parse<T>(res: Response): Promise<T> {
if (res.status === 429) {
const header = res.headers.get("Retry-After");
const seconds = header ? Number.parseInt(header, 10) : Number.NaN;
throw new PublicApiError(
"rate-limited",
"Слишком много запросов подряд.",
Number.isFinite(seconds) ? seconds : null,
);
}
if (!res.ok) {
throw new PublicApiError("server", `Сервис ответил ошибкой (${res.status}).`);
}
return (await res.json()) as T;
}
/** `fetch`, который бросает наш тип и на сетевом сбое тоже. */
async function request<T>(url: string, init?: RequestInit): Promise<T> {
let res: Response;
try {
res = await fetch(url, init);
} catch (cause) {
// AbortError — это не сбой, а наш же отменённый предыдущий запрос:
// пробрасываем как есть, вызывающий код его молча глотает.
if (cause instanceof DOMException && cause.name === "AbortError") {
throw cause;
}
throw new PublicApiError("offline", "Не получилось связаться с сервисом.");
}
return parse<T>(res);
}
/**
* Подсказки адреса. POST у читающей ручки намеренно: на публичном домене
* включён access-лог Caddy, и он пишет URI целиком, поэтому `?q=Малышева+51`
* сохранил бы адрес квартиры в файл рядом с IP посетителя. На странице мы
* обещаем обратное. Тело в лог не попадает. Тот же довод, что у черновика с
* лэндинга (`estimate-draft.ts` sessionStorage вместо query-параметров).
*/
export async function fetchAddressSuggestions(
query: string,
options: { cityHint?: string; signal?: AbortSignal } = {},
): Promise<AddressSuggestion[]> {
const data = await request<{ items: AddressSuggestion[] }>(`${API_BASE}/suggest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ q: query, city_hint: options.cityHint ?? null }),
signal: options.signal,
});
return data.items;
}
export async function fetchCoverage(
query: CoverageQuery,
options: { signal?: AbortSignal } = {},
): Promise<CoverageProbe> {
return request<CoverageProbe>(`${API_BASE}/coverage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(query),
signal: options.signal,
});
}

View file

@ -1,10 +1,8 @@
import type { Metadata } from "next";
import Link from "next/link";
import {
LEGAL_DOCS_REVISION,
LEGAL_ENTITY,
OFFER_PATH,
PUBLIC_OFFER_URL,
PUBLIC_REFUND_URL,
SERVICE_PRICE_RUB,
@ -12,6 +10,8 @@ import {
} from "../content";
import styles from "../landing.module.css";
import { PublicLink } from "../_components/PublicLink";
import { SiteFooter } from "../_components/SiteFooter";
import { SiteHeader } from "../_components/SiteHeader";
@ -53,12 +53,12 @@ export default function MeraPublicRefundPage() {
<SiteHeader />
<main id="content" className={styles.main} tabIndex={-1}>
<div className={`${styles.container} ${styles.doc}`}>
<Link
<PublicLink
className={`${styles.link} ${styles.docBack}`}
href="/mera-public"
to="home"
>
На главную
</Link>
</PublicLink>
<h1 className={styles.h2}>Политика возврата денежных средств</h1>
<p className={styles.docMeta}>
@ -75,9 +75,9 @@ export default function MeraPublicRefundPage() {
<p>
1.2. Настоящая Политика размещена в свободном доступе в сети Интернет
по адресу {PUBLIC_REFUND_URL} и является неотъемлемой частью{" "}
<Link className={styles.link} href={OFFER_PATH}>
<PublicLink className={styles.link} to="oferta">
Публичной оферты
</Link>
</PublicLink>
, размещённой по адресу {PUBLIC_OFFER_URL}. Оферта и настоящая
Политика применяются совместно; в случае противоречий между ними
приоритет имеет Оферта.

View file

@ -21,6 +21,8 @@ import type { CSSProperties } from "react";
import { tokens } from "@/components/trade-in/v2/tokens";
import { b2c } from "./b2c-tokens";
/**
* Набор CSS-переменных лэндинга. Приводится к CSSProperties: TS не знает про
* произвольные `--*` ключи, но React их корректно проставляет в style.
@ -63,3 +65,49 @@ export const landingVars = {
"--m-font-sans": tokens.font.sans,
"--m-font-mono": tokens.font.mono,
} as CSSProperties;
/**
* Переменные палитры v3 (`--b2c-*`) для `landing-v3.module.css`.
*
* ЧЕГО ЗДЕСЬ НЕ ХВАТАЛО. `b2c-tokens.ts` завёл палитру макета и запретил hex в
* CSS но моста между ними не появилось: файл не импортировал НИКТО, ни одна
* `--b2c-*` переменная нигде не объявлялась, и `var(--b2c-page-bg)` на живой
* странице резолвился в пустоту. Каскад при этом не падает он просто
* пропускает такое объявление, поэтому страница отдавала 200, проходила
* сборку и выглядела при этом бесцветной: карточки без фона и рамок, кнопки
* без заливки, акцент отсутствует (замер на
* `meraocenka.ru/trade-in/mera-public/v3`, 15.08.2026).
*
* Отсюда правило на будущее: TS-файл токенов, который никто не импортирует,
* не источник правды, а мёртвый код. Единственное, что делает палитру живой,
* это вот такой мост, применённый к корневому узлу.
*
* Оба набора (`--m-*` и `--b2c-*`) вешаются на ОДИН и тот же div лэндинга:
* v1-страницы читают первый, v3 второй, и лишние объявления ничего не
* стоят. Разделять их по разным обёрткам значило бы городить два layout'а
* ради экономии полутора десятков строк в атрибуте style.
*/
export const b2cVars = {
"--b2c-page-bg": b2c.pageBg,
"--b2c-surface": b2c.surface,
"--b2c-surface-soft": b2c.surfaceSoft,
"--b2c-surface-dark": b2c.surfaceDark,
"--b2c-ink": b2c.ink,
"--b2c-muted": b2c.muted,
"--b2c-line": b2c.line,
"--b2c-line-soft": b2c.lineSoft,
// accent — только заливки/обводки; текст красится accentText (5.8:1).
// Разбор контраста — в шапке b2c-tokens.ts.
"--b2c-accent": b2c.accent,
"--b2c-accent-deep": b2c.accentDeep,
"--b2c-accent-text": b2c.accentText,
"--b2c-on-accent": b2c.onAccent,
"--b2c-danger": b2c.danger,
"--b2c-font-sans": b2c.font.sans,
"--b2c-font-mono": b2c.font.mono,
} as CSSProperties;