feat(mera/b2c): публичный периметр МЕРЫ — свой API, отдельный экран оценки, короткие адреса #2913
34 changed files with 2737 additions and 232 deletions
|
|
@ -54,6 +54,15 @@ jobs:
|
||||||
- '.forgejo/workflows/ci-tradein.yml'
|
- '.forgejo/workflows/ci-tradein.yml'
|
||||||
frontend:
|
frontend:
|
||||||
- 'tradein-mvp/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'
|
- '.forgejo/workflows/ci-tradein.yml'
|
||||||
browser:
|
browser:
|
||||||
# Сайдкар — сервис ВНЕ uv-воркспейса (tradein-mvp/pyproject.toml
|
# Сайдкар — сервис ВНЕ uv-воркспейса (tradein-mvp/pyproject.toml
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,45 @@ jobs:
|
||||||
python3 scripts/check-workflow-ports.py --selftest
|
python3 scripts/check-workflow-ports.py --selftest
|
||||||
python3 scripts/check-workflow-ports.py
|
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)"
|
- name: "Guard: блокирующий DDL без lock_timeout (#2752)"
|
||||||
# Тем же шагом-соседом и по той же причине: гейт бежит на КАЖДОМ PR,
|
# Тем же шагом-соседом и по той же причине: гейт бежит на КАЖДОМ PR,
|
||||||
# включая tradein-only (у ci.yml нет paths-фильтра на уровне workflow —
|
# включая tradein-only (у ci.yml нет paths-фильтра на уровне workflow —
|
||||||
|
|
|
||||||
141
Caddyfile
141
Caddyfile
|
|
@ -250,44 +250,105 @@ meraocenka.ru {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Короткие адреса юридических документов. Именно они напечатаны ВНУТРИ
|
# Короткие адреса страниц публичного сайта. Именно они напечатаны ВНУТРИ
|
||||||
# самих документов (оферта ссылается на meraocenka.ru/refund, политика
|
# юридических документов (оферта ссылается на meraocenka.ru/refund,
|
||||||
# возврата — на meraocenka.ru/oferta) и уходят в заявку эквайеру, поэтому
|
# политика возврата — на meraocenka.ru/oferta) и уходят в заявку эквайеру,
|
||||||
# обязаны резолвиться сами по себе, а не только длинным
|
# поэтому обязаны резолвиться сами по себе.
|
||||||
# /trade-in/mera-public/<doc>. Обратное направление тоже рабочее: длинный
|
#
|
||||||
# путь ловит handle ниже — навигация внутри сайта ходит по нему, потому что
|
# ЭТО ЕДИНСТВЕННЫЙ ВИД АДРЕСА, КОТОРЫЙ ВИДИТ ЧЕЛОВЕК (решение владельца,
|
||||||
# то же поддерево открывается и с gendsgn.ru/trade-in/mera-public, где
|
# 15.08.2026). Раньше навигация внутри сайта ходила по длинным
|
||||||
# короткого /oferta нет.
|
# /trade-in/mera-public/... — так короткие адреса и длинные существовали
|
||||||
|
# параллельно. Теперь длинные отдают 301 на короткие (см. handle ниже), а
|
||||||
|
# ссылки на страницах эмитятся сразу короткими (см. `PublicLink` во
|
||||||
|
# фронте — обычный <a>, потому что next/link подставляет basePath).
|
||||||
#
|
#
|
||||||
# `rewrite`, а не `redir`: адрес в строке браузера должен остаться коротким
|
# `rewrite`, а не `redir`: адрес в строке браузера должен остаться коротким
|
||||||
# — модератор эквайера открывает ссылку из заявки и видит ровно тот URL,
|
# — модератор эквайера открывает ссылку из заявки и видит ровно тот URL,
|
||||||
# который в ней указан. Каноничность для поисковиков задана отдельно, через
|
# который в ней указан. Каноничность для поисковиков задана отдельно, через
|
||||||
# `alternates.canonical` на каждой из трёх страниц.
|
# `alternates.canonical` на каждой странице.
|
||||||
#
|
#
|
||||||
# Пути перечислены поимённо, а не шаблоном: allowlist-by-default этого
|
# Пути перечислены поимённо, а не шаблоном: allowlist-by-default этого
|
||||||
# site-блока — часть периметра (#2545), и превращать его в «любой корневой
|
# site-блока — часть периметра (#2545), и превращать его в «любой корневой
|
||||||
# путь проксируется» ради трёх страниц нельзя.
|
# путь проксируется» нельзя. Новая публичная страница = новая строка здесь
|
||||||
@meraLegalDocs path /oferta /refund /privacy
|
# (и проверка в scripts/smoke-mera-perimeter.sh).
|
||||||
handle @meraLegalDocs {
|
#
|
||||||
|
# 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}
|
rewrite * /trade-in/mera-public{path}
|
||||||
reverse_proxy tradein-frontend:3000 {
|
reverse_proxy tradein-frontend:3000 {
|
||||||
header_up -X-Authenticated-User
|
header_up -X-Authenticated-User
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Подстраницы САМОГО лэндинга. Нужны с момента мержа #2615: футер ссылается
|
# Тот же адрес со слэшем на конце → 301 на канонический вид без слэша.
|
||||||
# на политику обработки ПДн через next/link (`PRIVACY_PATH`), а Next с
|
# Слэш дописывают мессенджеры, автолинкификаторы и сами люди, а матчер
|
||||||
# basePath эмитит её как /trade-in/mera-public/privacy. Без этого handle
|
# `path` требует точного совпадения — без этой ветки `/oferta/` отдавал бы
|
||||||
# ссылка уходила бы в catch-all 404 ниже — то есть обязательный по 152-ФЗ
|
# голый 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)
|
# и шапка до 15.08.2026, они могли попасть в закладки и в переписку. 301
|
||||||
# под него не подпадает и по-прежнему отдаёт 404. Регресс-тест на это —
|
# (а не 404) сохраняет эти ссылки живыми и заодно передаёт поисковикам, что
|
||||||
# в scripts/smoke-mera-perimeter.sh.
|
# канонический адрес один.
|
||||||
handle /trade-in/mera-public/* {
|
#
|
||||||
reverse_proxy tradein-frontend:3000 {
|
# ЗДЕСЬ ЖЕ ЧИНИТСЯ БАГ: прежний матчер был `/trade-in/mera-public/*` — со
|
||||||
header_up -X-Authenticated-User
|
# слэшем и звёздочкой, поэтому ГОЛЫЙ `/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,
|
# Allowlist-by-default: любой другой путь (включая B2B — /v2, /admin,
|
||||||
# /scrapers/*, /trade-in/api/*, /history, ...) — 404, НЕ проксируется.
|
# /scrapers/*, /trade-in/api/v1/*, /history, ...) — 404, НЕ проксируется.
|
||||||
handle {
|
handle {
|
||||||
respond 404
|
respond 404
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,16 @@
|
||||||
#
|
#
|
||||||
# Проверяет инварианты периметра (см. корневой Caddyfile):
|
# Проверяет инварианты периметра (см. корневой Caddyfile):
|
||||||
# 1. meraocenka.ru отдаёт 200 анонимно (публичный лэндинг).
|
# 1. meraocenka.ru отдаёт 200 анонимно (публичный лэндинг).
|
||||||
# 1b. Подстраница лэндинга /trade-in/mera-public/privacy отдаёт 200 —
|
# 1b. Длинный адрес /trade-in/mera-public/privacy отдаёт 301 на короткий
|
||||||
# политика ПДн, на которую ссылается футер.
|
# (у страницы один канонический адрес, старые ссылки не ломаются).
|
||||||
# 1c. Короткие адреса /oferta, /refund, /privacy отдают 200 — эти URL
|
# 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 —
|
# 2. meraocenka.ru/v2 и /trade-in/v2, /trade-in/api/* (B2B-пути) отдают 404 —
|
||||||
# allowlist-by-default, НЕ были случайно проброшены на B2B-дерево
|
# allowlist-by-default, НЕ были случайно проброшены на B2B-дерево
|
||||||
# tradein-frontend. Проверяются обе формы — с basePath-префиксом и без.
|
# tradein-frontend. Проверяются обе формы — с basePath-префиксом и без.
|
||||||
|
|
@ -43,16 +49,31 @@ check() {
|
||||||
fi
|
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) =="
|
echo "== МЕРА B2C perimeter smoke (ЭТАП 1) =="
|
||||||
|
|
||||||
# 1. Публичный домен отдаёт 200 анонимно.
|
# 1. Публичный домен отдаёт 200 анонимно.
|
||||||
check "meraocenka.ru root — public 200" "$BASE_MERA/" 200
|
check "meraocenka.ru root — public 200" "$BASE_MERA/" 200
|
||||||
|
|
||||||
# 1b. Подстраница лэндинга (политика ПДн) доступна — на неё ссылается футер.
|
# 1b. Подстраница лэндинга по ДЛИННОМУ адресу теперь отдаёт 301 на короткий, а
|
||||||
# Путь приезжает с basePath: next/link + basePath=/trade-in эмитит именно
|
# не 200: с 15.08.2026 у публичной страницы один канонический адрес.
|
||||||
# /trade-in/mera-public/privacy. Если этот handle выпадет из Caddyfile,
|
# Проверка осталась именно здесь, потому что раньше она сторожила
|
||||||
# обязательный по 152-ФЗ документ станет недоступен с публичной страницы.
|
# доступность обязательного по 152-ФЗ документа — теперь сторожит, что при
|
||||||
check "meraocenka.ru privacy — public 200" "$BASE_MERA/trade-in/mera-public/privacy" 200
|
# переходе на короткие адреса длинные не превратились в 404 (тогда бы
|
||||||
|
# сломались уже разосланные ссылки).
|
||||||
|
check "meraocenka.ru длинная privacy — 301 на короткую" "$BASE_MERA/trade-in/mera-public/privacy" 301
|
||||||
|
|
||||||
# 1c. Короткие адреса юридических документов. Это НЕ дубль проверки 1b: именно
|
# 1c. Короткие адреса юридических документов. Это НЕ дубль проверки 1b: именно
|
||||||
# эти три URL напечатаны внутри самих документов и уходят в заявку
|
# эти три 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/refund — public 200" "$BASE_MERA/refund" 200
|
||||||
check "meraocenka.ru/privacy — public 200" "$BASE_MERA/privacy" 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.
|
# 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401.
|
||||||
check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404
|
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/*`.
|
# Ловит расширение матчера обратно до `/trade-in/_next/*`.
|
||||||
check "meraocenka.ru/_next/image — must 404 (не открываем оптимизатор)" "$BASE_MERA/trade-in/_next/image?url=%2Ftest.png&w=64&q=75" 404
|
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 по-прежнему закрыты анониму.
|
# 3. B2B-данные trade-in по-прежнему закрыты анониму.
|
||||||
#
|
#
|
||||||
# ВНИМАНИЕ: проверять СТРАНИЦУ (/trade-in/v2) больше нельзя — она отдаёт 200.
|
# ВНИМАНИЕ: проверять СТРАНИЦУ (/trade-in/v2) больше нельзя — она отдаёт 200.
|
||||||
|
|
|
||||||
7
tradein-mvp/backend/app/api/public/__init__.py
Normal file
7
tradein-mvp/backend/app/api/public/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Публичный (анонимный) API — поверхность, открытая на meraocenka.ru.
|
||||||
|
|
||||||
|
Отдельный пакет, а не ещё один модуль в `app/api/v1/`, — намеренно: всё, что
|
||||||
|
лежит здесь, доступно БЕЗ идентичности, поэтому периметр должен читаться по
|
||||||
|
дереву каталогов, а не по списку исключений в middleware. Разбор — в
|
||||||
|
`app/api/public/mera.py`.
|
||||||
|
"""
|
||||||
250
tradein-mvp/backend/app/api/public/mera.py
Normal file
250
tradein-mvp/backend/app/api/public/mera.py
Normal 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)
|
||||||
97
tradein-mvp/backend/app/core/public_request.py
Normal file
97
tradein-mvp/backend/app/core/public_request.py
Normal 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())
|
||||||
|
|
@ -92,6 +92,20 @@ _PUBLIC_PATHS = frozenset(
|
||||||
# это query-параметр `secret`, который проверяет сам хендлер
|
# это query-параметр `secret`, который проверяет сам хендлер
|
||||||
# (secrets.compare_digest против TRADEIN_INTERNAL_AUTH_SECRET).
|
# (secrets.compare_digest против TRADEIN_INTERNAL_AUTH_SECRET).
|
||||||
"/api/v1/trade-in/ops/glitchtip-webhook",
|
"/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) перед
|
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration
|
||||||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||||
from sentry_sdk.integrations.starlette import StarletteIntegration
|
from sentry_sdk.integrations.starlette import StarletteIntegration
|
||||||
|
|
||||||
|
from app.api.public import mera as public_mera
|
||||||
from app.api.v1 import (
|
from app.api.v1 import (
|
||||||
admin,
|
admin,
|
||||||
audit,
|
audit,
|
||||||
|
|
@ -70,6 +71,7 @@ if settings.glitchtip_dsn:
|
||||||
from app.observability.sentry_scrub import (
|
from app.observability.sentry_scrub import (
|
||||||
redact_telegram_bot_token,
|
redact_telegram_bot_token,
|
||||||
scrub_payment_request_body,
|
scrub_payment_request_body,
|
||||||
|
scrub_public_address,
|
||||||
stabilize_retry_error_fingerprint,
|
stabilize_retry_error_fingerprint,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -92,6 +94,13 @@ if settings.glitchtip_dsn:
|
||||||
(suggest/lookup/reverse), которые ретраят Nominatim через tenacity; см.
|
(suggest/lookup/reverse), которые ретраят Nominatim через tenacity; см.
|
||||||
sentry_scrub.stabilize_retry_error_fingerprint."""
|
sentry_scrub.stabilize_retry_error_fingerprint."""
|
||||||
scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type]
|
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:
|
if scrubbed is None:
|
||||||
return None
|
return None
|
||||||
scrubbed = scrub_pii_event(scrubbed, hint) # type: ignore[arg-type]
|
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(search.router, prefix="/api/v1", tags=["search"])
|
||||||
app.include_router(me.router, prefix="/api/v1", tags=["me"])
|
app.include_router(me.router, prefix="/api/v1", tags=["me"])
|
||||||
app.include_router(team.router, prefix="/api/v1/team", tags=["team"])
|
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"])
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,61 @@ def scrub_payment_request_body(event: Event, _hint: dict[str, Any]) -> Event | N
|
||||||
return event
|
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:
|
def _redact_strings(obj: Any) -> Any:
|
||||||
"""Рекурсивно проходит dict/list/tuple и прогоняет обе токен-регулярки по КАЖДОЙ
|
"""Рекурсивно проходит dict/list/tuple и прогоняет обе токен-регулярки по КАЖДОЙ
|
||||||
строке (не только по конкретным ключам) — токен может оказаться в locals
|
строке (не только по конкретным ключам) — токен может оказаться в locals
|
||||||
|
|
|
||||||
352
tradein-mvp/backend/tests/test_public_mera_api.py
Normal file
352
tradein-mvp/backend/tests/test_public_mera_api.py
Normal 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 лимит сам по себе не может исчерпать суточный бюджет, "
|
||||||
|
"потолок бессмысленен — проверь, что тест сторожит реальный сценарий"
|
||||||
|
)
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -29,10 +29,14 @@
|
||||||
* (useMe/useQuota/useHistory и соседей) запрещено — публичный экран не должен
|
* (useMe/useQuota/useHistory и соседей) запрещено — публичный экран не должен
|
||||||
* иметь к ним доступа даже теоретически.
|
* иметь к ним доступа даже теоретически.
|
||||||
*
|
*
|
||||||
* Когда бэкенд откроет анонимные ручки: переключить `PUBLIC_ESTIMATE_ENABLED`
|
* КУДА ВЕДЁТ КНОПКА (15.08.2026). Раньше сабмит упирался в сообщение «расчёт
|
||||||
* в content.ts и заменить ветку `notLaunched` в `handleSubmit` на реальный
|
* ещё не открыли» — анонимных ручек не существовало. Теперь они есть
|
||||||
* переход/запрос (комбобокс подсказок — по образцу ParamsPanel.tsx, вместе с
|
* (`/api/public/mera/*`), а сама проверка живёт на отдельном экране
|
||||||
* его клавиатурной моделью и sr-live-регионом).
|
* `/estimate`: там у автокомплита есть место под список подсказок, а у
|
||||||
|
* результата — место рядом с полями. Эта форма осталась входной точкой
|
||||||
|
* лэндинга и делает ровно две полезные вещи — не пускает дальше без адреса и
|
||||||
|
* требует явно назвать город (#2576: «Ленина, 1» без города давало уверенную
|
||||||
|
* цену по одноимённой улице в ЕКБ жителю Нижнего Тагила).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useId, useRef, useState } from "react";
|
import { useId, useRef, useState } from "react";
|
||||||
|
|
@ -43,10 +47,11 @@ import { detectCityInText } from "@/lib/city-registry";
|
||||||
import {
|
import {
|
||||||
COVERED_CITIES,
|
COVERED_CITIES,
|
||||||
PRIMARY_CITY,
|
PRIMARY_CITY,
|
||||||
PUBLIC_ESTIMATE_ENABLED,
|
PUBLIC_ROUTES,
|
||||||
REGION_NAME,
|
REGION_NAME,
|
||||||
SECONDARY_CITIES,
|
SECONDARY_CITIES,
|
||||||
} from "../content";
|
} from "../content";
|
||||||
|
import { saveDraft } from "../estimate-draft";
|
||||||
import styles from "../landing.module.css";
|
import styles from "../landing.module.css";
|
||||||
|
|
||||||
/** Значение <option> «моего города нет в списке». */
|
/** Значение <option> «моего города нет в списке». */
|
||||||
|
|
@ -117,24 +122,12 @@ export function AddressForm() {
|
||||||
setCity(detected);
|
setCity(detected);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (PUBLIC_ESTIMATE_ENABLED) {
|
// Уходим на отдельный экран проверки. Черновик кладём в sessionStorage, а
|
||||||
// TODO(backend-периметр): здесь появится реальный расчёт. Отдельный PR.
|
// НЕ в query-параметры: адрес в URL попал бы в access-логи Caddy рядом с
|
||||||
return;
|
// IP посетителя — то есть мы бы хранили персональные данные там, где
|
||||||
}
|
// публично обещали ничего не хранить (см. estimate-draft.ts).
|
||||||
|
saveDraft({ address: trimmed, city: resolvedCity });
|
||||||
// Если город уже назван в самом тексте адреса — не дублируем его в эхо
|
window.location.assign(PUBLIC_ROUTES.estimate);
|
||||||
// («Серов, Серов, Ленина 3»).
|
|
||||||
const echo =
|
|
||||||
detected === resolvedCity ? trimmed : `${resolvedCity}, ${trimmed}`;
|
|
||||||
|
|
||||||
setFeedback({
|
|
||||||
kind: "info",
|
|
||||||
title: "Расчёт по адресу мы ещё не открыли для всех",
|
|
||||||
lines: [
|
|
||||||
`Адрес выглядит как наш: ${echo}. Но публичная оценка пока выключена — сервис работает по доступу для партнёров, и мы не хотим показывать заглушку вместо цены.`,
|
|
||||||
"Оценка появится на этой же странице. Если вопрос срочный — напишите нам в поддержку, ссылка в подвале.",
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const addressInvalid =
|
const addressInvalid =
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,24 +8,23 @@
|
||||||
* оператора по 152-ФЗ. Значения импортируются, а не пишутся строками: тот же
|
* оператора по 152-ФЗ. Значения импортируются, а не пишутся строками: тот же
|
||||||
* набор рендерят оферта и политика ПДн, и разъехаться они не должны.
|
* набор рендерят оферта и политика ПДн, и разъехаться они не должны.
|
||||||
*
|
*
|
||||||
* Ссылки на документы ведут по ВНУТРЕННИМ путям (`OFFER_PATH` и соседи), а не
|
* ССЫЛКИ НА ДОКУМЕНТЫ — КОРОТКИЕ (изменено 15.08.2026 по решению владельца).
|
||||||
* по коротким meraocenka.ru/oferta: короткие адреса существуют только на
|
* Раньше здесь стояли внутренние пути `/mera-public/oferta` и соседи, чтобы
|
||||||
* публичном хосте (rewrite в Caddy), а это же поддерево открывается и с
|
* подвал работал и при открытии поддерева с gendsgn.ru. Теперь наоборот:
|
||||||
* gendsgn.ru/trade-in/mera-public — там короткая ссылка ушла бы в 404.
|
* человек на публичном сервисе не должен видеть `/trade-in/mera-public/...` в
|
||||||
|
* адресной строке, и именно короткие адреса напечатаны внутри самих
|
||||||
|
* документов. Цена решения — переходы в подвале работают только на
|
||||||
|
* meraocenka.ru; разбор и что с этим делать — в шапке `PUBLIC_ROUTES`
|
||||||
|
* (content.ts).
|
||||||
*
|
*
|
||||||
* Внешняя ссылка проверяется `safeUrl` (правило frontend.md: ничего в href без
|
* Внешняя ссылка проверяется `safeUrl` (правило frontend.md: ничего в href без
|
||||||
* валидации схемы) и открывается в новой вкладке с rel="noreferrer".
|
* валидации схемы) и открывается в новой вкладке с rel="noreferrer".
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import { safeUrl } from "@/lib/safeUrl";
|
import { safeUrl } from "@/lib/safeUrl";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
LEGAL_ENTITY,
|
LEGAL_ENTITY,
|
||||||
OFFER_PATH,
|
|
||||||
PRIVACY_PATH,
|
|
||||||
REFUND_PATH,
|
|
||||||
REGION_NAME,
|
REGION_NAME,
|
||||||
SUPPORT_EMAIL,
|
SUPPORT_EMAIL,
|
||||||
SUPPORT_TELEGRAM_LABEL,
|
SUPPORT_TELEGRAM_LABEL,
|
||||||
|
|
@ -33,6 +32,8 @@ import {
|
||||||
} from "../content";
|
} from "../content";
|
||||||
import styles from "../landing.module.css";
|
import styles from "../landing.module.css";
|
||||||
|
|
||||||
|
import { PublicLink } from "./PublicLink";
|
||||||
|
|
||||||
export function SiteFooter() {
|
export function SiteFooter() {
|
||||||
const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL);
|
const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL);
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
|
|
@ -79,19 +80,19 @@ export function SiteFooter() {
|
||||||
<p className={styles.footerTitle}>Документы</p>
|
<p className={styles.footerTitle}>Документы</p>
|
||||||
<ul className={styles.footerLinks} role="list">
|
<ul className={styles.footerLinks} role="list">
|
||||||
<li>
|
<li>
|
||||||
<Link className={styles.link} href={OFFER_PATH}>
|
<PublicLink className={styles.link} to="oferta">
|
||||||
Публичная оферта
|
Публичная оферта
|
||||||
</Link>
|
</PublicLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link className={styles.link} href={REFUND_PATH}>
|
<PublicLink className={styles.link} to="refund">
|
||||||
Политика возврата
|
Политика возврата
|
||||||
</Link>
|
</PublicLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link className={styles.link} href={PRIVACY_PATH}>
|
<PublicLink className={styles.link} to="privacy">
|
||||||
Обработка персональных данных
|
Обработка персональных данных
|
||||||
</Link>
|
</PublicLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 ₽: диапазон цены, цена за квадратный
|
||||||
|
метр и список квартир, на которых он построен. Приём оплаты сейчас
|
||||||
|
подключается — как только заработает, кнопка появится здесь.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
*
|
*
|
||||||
* РЕКВИЗИТЫ И ДОКУМЕНТЫ — НЕ из макета (там маска «ООО «МЕРА» · ИНН
|
* РЕКВИЗИТЫ И ДОКУМЕНТЫ — НЕ из макета (там маска «ООО «МЕРА» · ИНН
|
||||||
* 66700XXXXX», это не настоящие данные), а из content.ts: `LEGAL_ENTITY`,
|
* 66700XXXXX», это не настоящие данные), а из content.ts: `LEGAL_ENTITY`,
|
||||||
* `SUPPORT_EMAIL`, `OFFER_PATH`, `REFUND_PATH`, `PRIVACY_PATH` — тот же набор,
|
* `SUPPORT_EMAIL`, короткие адреса документов (`PUBLIC_ROUTES`) — тот же набор,
|
||||||
* что уже рендерит `_components/SiteFooter.tsx` (подвал v1/v2), только в
|
* что уже рендерит `_components/SiteFooter.tsx` (подвал v1/v2), только в
|
||||||
* другой сетке. Документы включают Политику возврата и почту поддержки:
|
* другой сетке. Документы включают Политику возврата и почту поддержки:
|
||||||
* макет их не показывал, а это обязательные элементы платного контура
|
* макет их не показывал, а это обязательные элементы платного контура
|
||||||
|
|
@ -15,27 +15,27 @@
|
||||||
* оплаты, которых нет, подпадает под ст. 5 ФЗ «О рекламе».
|
* оплаты, которых нет, подпадает под ст. 5 ФЗ «О рекламе».
|
||||||
*
|
*
|
||||||
* «Статьи» в «Разделах» неактивны тем же образом, что в шапке (HeaderV3):
|
* «Статьи» в «Разделах» неактивны тем же образом, что в шапке (HeaderV3):
|
||||||
* раздела нет в коде. «Проверить квартиру» ведёт на #check (форма в Hero на
|
* раздела нет в коде.
|
||||||
* этой же странице) — отдельного маршрута отчёта не существует. Оба
|
*
|
||||||
* внутренних якоря — через `/mera-public#...`: подвал рендерится и на
|
* ССЫЛКИ — КОРОТКИЕ (15.08.2026). «Проверить квартиру» ведёт на `/estimate` —
|
||||||
* оферте/возврате/ПДн, где голый `#check` никуда не долистал бы.
|
* отдельный экран проверки, а не на якорь формы в герое. «МЕРА для бизнеса»
|
||||||
|
* стала абсолютной: относительный `/` на публичном домене вёл на сам лэндинг,
|
||||||
|
* то есть пункт не работал. Все переходы — обычным `<a>` через `PublicLink`,
|
||||||
|
* потому что `next/link` подставляет basePath (см. его шапку).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import { safeUrl } from "@/lib/safeUrl";
|
import { safeUrl } from "@/lib/safeUrl";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
B2B_URL,
|
||||||
LEGAL_ENTITY,
|
LEGAL_ENTITY,
|
||||||
OFFER_PATH,
|
|
||||||
PRIVACY_PATH,
|
|
||||||
REFUND_PATH,
|
|
||||||
REGION_NAME,
|
REGION_NAME,
|
||||||
SUPPORT_EMAIL,
|
SUPPORT_EMAIL,
|
||||||
SUPPORT_TELEGRAM_LABEL,
|
SUPPORT_TELEGRAM_LABEL,
|
||||||
SUPPORT_TELEGRAM_URL,
|
SUPPORT_TELEGRAM_URL,
|
||||||
} from "../../content";
|
} from "../../content";
|
||||||
import styles from "../../landing-v3.module.css";
|
import styles from "../../landing-v3.module.css";
|
||||||
|
import { PublicLink } from "../PublicLink";
|
||||||
|
|
||||||
export function FooterV3() {
|
export function FooterV3() {
|
||||||
const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL);
|
const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL);
|
||||||
|
|
@ -58,12 +58,12 @@ export function FooterV3() {
|
||||||
<div>
|
<div>
|
||||||
<p className={styles.ftrColTitle}>Разделы</p>
|
<p className={styles.ftrColTitle}>Разделы</p>
|
||||||
<div className={styles.ftrLinks}>
|
<div className={styles.ftrLinks}>
|
||||||
<Link href="/mera-public" className={styles.ftrLink}>
|
<PublicLink to="home" className={styles.ftrLink}>
|
||||||
Главная
|
Главная
|
||||||
</Link>
|
</PublicLink>
|
||||||
<Link href="/mera-public#check" className={styles.ftrLink}>
|
<PublicLink to="estimate" className={styles.ftrLink}>
|
||||||
Проверить квартиру
|
Проверить квартиру
|
||||||
</Link>
|
</PublicLink>
|
||||||
<span
|
<span
|
||||||
className={styles.ftrLinkDisabled}
|
className={styles.ftrLinkDisabled}
|
||||||
aria-disabled="true"
|
aria-disabled="true"
|
||||||
|
|
@ -71,9 +71,9 @@ export function FooterV3() {
|
||||||
>
|
>
|
||||||
Статьи
|
Статьи
|
||||||
</span>
|
</span>
|
||||||
<Link href="/" className={styles.ftrLink}>
|
<a href={B2B_URL} className={styles.ftrLink}>
|
||||||
МЕРА для бизнеса
|
МЕРА для бизнеса
|
||||||
</Link>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -106,15 +106,15 @@ export function FooterV3() {
|
||||||
<div>
|
<div>
|
||||||
<p className={styles.ftrColTitle}>Документы</p>
|
<p className={styles.ftrColTitle}>Документы</p>
|
||||||
<div className={styles.ftrLinks}>
|
<div className={styles.ftrLinks}>
|
||||||
<Link href={OFFER_PATH} className={styles.ftrLink}>
|
<PublicLink to="oferta" className={styles.ftrLink}>
|
||||||
Публичная оферта
|
Публичная оферта
|
||||||
</Link>
|
</PublicLink>
|
||||||
<Link href={REFUND_PATH} className={styles.ftrLink}>
|
<PublicLink to="refund" className={styles.ftrLink}>
|
||||||
Политика возврата
|
Политика возврата
|
||||||
</Link>
|
</PublicLink>
|
||||||
<Link href={PRIVACY_PATH} className={styles.ftrLink}>
|
<PublicLink to="privacy" className={styles.ftrLink}>
|
||||||
Обработка персональных данных
|
Обработка персональных данных
|
||||||
</Link>
|
</PublicLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,13 @@
|
||||||
* FreeCheckCard — карточка «Бесплатная проверка» на первом экране (макет v3,
|
* FreeCheckCard — карточка «Бесплатная проверка» на первом экране (макет v3,
|
||||||
* `МЕРА для себя v3.dc.html` строки ~129-170).
|
* `МЕРА для себя v3.dc.html` строки ~129-170).
|
||||||
*
|
*
|
||||||
* ЧТО ОНА ДЕЛАЕТ СЕГОДНЯ И ПОЧЕМУ. Ровно то же, чем ограничена
|
* ЧТО ОНА ДЕЛАЕТ СЕГОДНЯ. Проверяет пять полей и уводит на `/estimate`, где
|
||||||
* `../AddressForm.tsx` в закрытом v2-контуре, и по той же причине:
|
* живёт сама проверка. Макетный сценарий «спиннер 1100 мс → готовый
|
||||||
* `rbac_guard` (backend/app/core/rbac.py) не пускает анонима на
|
* результат прямо в карточке» не воспроизводится и не будет: результату нужно
|
||||||
* `/api/v1/geocode/suggest` и `/api/v1/trade-in/estimate` — их нет в
|
* место рядом с полями, а автокомплиту — место под список; в карточке первого
|
||||||
* `_PUBLIC_PATHS`. Открытие анонимного периметра — отдельный backend-PR, вне
|
* экрана нет ни того, ни другого. Раньше здесь стояло сообщение «проверка ещё
|
||||||
* границ этой задачи. Поэтому здесь: честная валидация 5 обязательных полей
|
* не открыта» — анонимных ручек не существовало; теперь они есть
|
||||||
* на клиенте + прямое сообщение «проверка ещё не открыта» вместо макетного
|
* (`/api/public/mera/*`).
|
||||||
* сценария «спиннер 1100 мс → готовый результат». Тот сценарий на живом
|
|
||||||
* сайте отдавал бы одинаковые «14 объявлений / 47 дней» на любой введённый
|
|
||||||
* адрес — не продающая формулировка, а обман конкретного человека (см. шапку
|
|
||||||
* `../../marketing-v3.ts`).
|
|
||||||
*
|
*
|
||||||
* Раздел «шаг 1 из 2» / полоска прогресса / ярлык рынка (`{{ cityMarket }}`,
|
* Раздел «шаг 1 из 2» / полоска прогресса / ярлык рынка (`{{ cityMarket }}`,
|
||||||
* `{{ stepLabel }}`) из макета сюда сознательно НЕ перенесены: они требуют
|
* `{{ stepLabel }}`) из макета сюда сознательно НЕ перенесены: они требуют
|
||||||
|
|
@ -29,7 +25,8 @@
|
||||||
import { useId, useRef, useState } from "react";
|
import { useId, useRef, useState } from "react";
|
||||||
import type { FormEvent } 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";
|
import styles from "../../landing-v3.module.css";
|
||||||
|
|
||||||
/** Три состояния квартиры — дословно из макета (строка 734 исходника). */
|
/** Три состояния квартиры — дословно из макета (строка 734 исходника). */
|
||||||
|
|
@ -38,10 +35,7 @@ type Condition = (typeof CONDITIONS)[number];
|
||||||
|
|
||||||
type FieldName = "address" | "area" | "rooms" | "floor" | "condition";
|
type FieldName = "address" | "area" | "rooms" | "floor" | "condition";
|
||||||
|
|
||||||
type Feedback =
|
type Feedback = { kind: "none" } | { kind: "error"; field: FieldName; text: string };
|
||||||
| { kind: "none" }
|
|
||||||
| { kind: "error"; field: FieldName; text: string }
|
|
||||||
| { kind: "info"; title: string; lines: readonly string[] };
|
|
||||||
|
|
||||||
const NONE: Feedback = { kind: "none" };
|
const NONE: Feedback = { kind: "none" };
|
||||||
|
|
||||||
|
|
@ -128,22 +122,17 @@ export function FreeCheckCard() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (PUBLIC_ESTIMATE_ENABLED) {
|
// Уходим на отдельный экран проверки — там живёт автокомплит и результат.
|
||||||
// TODO(backend-периметр): здесь появится реальный запрос к
|
// Черновиком отдаём только то, что использует бесплатная проба покрытия:
|
||||||
// POST /trade-in/coverage. По успешному ответу — переключить карточку в
|
// адрес, комнаты, площадь. Этаж и состояние она не использует вовсе (см.
|
||||||
// ветку результата и отрендерить `FreeResultV3` с полученными
|
// CoverageProbeInput на бэке) — их спросит платный шаг, когда появится,
|
||||||
// analogs/days (НЕ константами из marketing-v3.ts). Отдельный PR.
|
// и спросит на том же экране, а не тащит через переход.
|
||||||
return;
|
saveDraft({
|
||||||
}
|
address: trimmedAddress,
|
||||||
|
rooms: trimmedRooms,
|
||||||
setFeedback({
|
area: trimmedArea,
|
||||||
kind: "info",
|
|
||||||
title: "Бесплатную проверку по адресу мы пока не открыли",
|
|
||||||
lines: [
|
|
||||||
`Дом принят: ${trimmedArea} м², ${trimmedRooms}-комн., этаж ${trimmedFloor}, состояние «${condition}». Но онлайн-расчёт ещё не запущен — сервис работает по доступу для партнёров.`,
|
|
||||||
"Проверка появится на этой же странице. Если вопрос срочный — напишите нам в поддержку, ссылка в подвале.",
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
window.location.assign(PUBLIC_ROUTES.estimate);
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorField = feedback.kind === "error" ? feedback.field : null;
|
const errorField = feedback.kind === "error" ? feedback.field : null;
|
||||||
|
|
@ -285,16 +274,6 @@ export function FreeCheckCard() {
|
||||||
<p className={styles.heroFormFeedbackText}>{feedback.text}</p>
|
<p className={styles.heroFormFeedbackText}>{feedback.text}</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,28 +2,29 @@
|
||||||
* HeaderV3 — шапка лэндинга v3 (макет ~ строки 32-78): sticky, blur, лого +
|
* HeaderV3 — шапка лэндинга v3 (макет ~ строки 32-78): sticky, blur, лого +
|
||||||
* навигация + селектор города + CTA.
|
* навигация + селектор города + CTA.
|
||||||
*
|
*
|
||||||
* Логотип НЕ ссылка на "/": next.config.ts редиректит "/" → "/v2" (закрытый
|
* Логотип НЕ ссылка: на публичном домене корень — это сам лэндинг, то есть
|
||||||
* B2B-контур за RouteGuard) — клик по вордмарку выкинул бы анонимного
|
* ссылка вела бы на текущую же страницу.
|
||||||
* посетителя лэндинга в приложение, требующее логина (тот же комментарий, что
|
|
||||||
* в старом `_components/SiteHeader.tsx`). «Для бизнеса» ниже — НАОБОРОТ,
|
|
||||||
* намеренно ведёт на "/": это единственная ссылка в шапке, которая должна
|
|
||||||
* увести в закрытый контур.
|
|
||||||
*
|
*
|
||||||
* «Статьи» — раздела ещё нет в коде (ни маршрута, ни контента): пункт
|
* НЕАКТИВНЫЕ ПУНКТЫ. «Статьи», «Проверьте себя» и «Продажа под ключ» —
|
||||||
* навигации нарочно неактивен (aria-disabled, без href), чтобы не заводить
|
* разделов нет в коде: ни маршрута, ни секции. Раньше три якоря вели на
|
||||||
* ссылку в 404 на первой же публичной странице.
|
* `/mera-public#game` и `#turnkey`, которых не существует НИГДЕ, — то есть
|
||||||
|
* клик уводил на другую страницу и там ничего не находил. Пункты приведены к
|
||||||
|
* тому же виду, что «Статьи»: видны, но не кликабельны, пока раздела нет.
|
||||||
*
|
*
|
||||||
* Внутренние якоря идут через `/mera-public#...`, а не голый `#...`: шапка
|
* ЯКОРЯ — голые (`#accuracy`, `#check`), потому что эта шапка рендерится
|
||||||
* рендерится на каждой странице поддерева (оферта/возврат/ПДн через
|
* только на странице, где эти секции и лежат. На юридических страницах стоит
|
||||||
* layout.tsx), и там `#accuracy` без пути к самому лэндингу никуда бы не
|
* другая шапка (`_components/SiteHeader.tsx`), на `/estimate` — своя,
|
||||||
* долистал.
|
* укороченная (`_components/estimate/EstimateHeader.tsx`): туда переносить
|
||||||
|
* навигацию по секциям лэндинга незачем, там человек уже пришёл считать.
|
||||||
|
*
|
||||||
|
* «Для бизнеса» ведёт на АБСОЛЮТНЫЙ адрес B2B-контура: относительный «/» на
|
||||||
|
* публичном домене вёл на сам лэндинг, то есть пункт не работал вовсе.
|
||||||
*
|
*
|
||||||
* Единственный клиентский остров — `CityPicker` (открытие/закрытие, поиск).
|
* Единственный клиентский остров — `CityPicker` (открытие/закрытие, поиск).
|
||||||
* Сама шапка серверная.
|
* Сама шапка серверная.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Link from "next/link";
|
import { B2B_URL } from "../../content";
|
||||||
|
|
||||||
import styles from "../../landing-v3.module.css";
|
import styles from "../../landing-v3.module.css";
|
||||||
|
|
||||||
import { CityPicker } from "./CityPicker";
|
import { CityPicker } from "./CityPicker";
|
||||||
|
|
@ -43,32 +44,29 @@ export function HeaderV3() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className={styles.hdrNav} aria-label="Основная навигация">
|
<nav className={styles.hdrNav} aria-label="Основная навигация">
|
||||||
<Link href="/mera-public#accuracy" className={styles.hdrNavLink}>
|
<a href="#accuracy" className={styles.hdrNavLink}>
|
||||||
Точность
|
Точность
|
||||||
</Link>
|
</a>
|
||||||
<Link href="/mera-public#game" className={styles.hdrNavLink}>
|
{["Проверьте себя", "Продажа под ключ", "Статьи"].map((name) => (
|
||||||
Проверьте себя
|
<span
|
||||||
</Link>
|
key={name}
|
||||||
<Link href="/mera-public#turnkey" className={styles.hdrNavLink}>
|
className={styles.hdrNavDisabled}
|
||||||
Продажа под ключ
|
aria-disabled="true"
|
||||||
</Link>
|
title="Раздел скоро откроется"
|
||||||
<span
|
>
|
||||||
className={styles.hdrNavDisabled}
|
{name}
|
||||||
aria-disabled="true"
|
</span>
|
||||||
title="Раздел скоро откроется"
|
))}
|
||||||
>
|
|
||||||
Статьи
|
|
||||||
</span>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className={styles.hdrRight}>
|
<div className={styles.hdrRight}>
|
||||||
<CityPicker />
|
<CityPicker />
|
||||||
<Link href="/" className={styles.hdrBizLink}>
|
<a href={B2B_URL} className={styles.hdrBizLink}>
|
||||||
Для бизнеса
|
Для бизнеса
|
||||||
</Link>
|
</a>
|
||||||
<Link href="/mera-public#check" className={styles.hdrCta}>
|
<a href="#check" className={styles.hdrCta}>
|
||||||
Проверить за 30 секунд
|
Проверить за 30 секунд
|
||||||
</Link>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
/**
|
/**
|
||||||
* StickyCtaV3 — липкая нижняя CTA-панель (макет ~ строки 518-521).
|
* StickyCtaV3 — липкая нижняя CTA-панель (макет ~ строки 518-521).
|
||||||
*
|
*
|
||||||
* Кнопка — обычная ссылка на `/mera-public#check` (форма расчёта в Hero), а
|
* Кнопка ведёт на `/estimate` — отдельный экран проверки, а не на якорь формы
|
||||||
* не модалка: в этом заходе модального окна нет вовсе (макет вызывал
|
* в герое и не в модалку (макет вызывал `openModal`, которого в коде нет).
|
||||||
* `openModal`, которого в коде не существует). Путь с префиксом, а не голый
|
* Панель липкая и видна в том числе внизу страницы, где формы уже не видно, —
|
||||||
* `#check`: панель рендерится на каждой странице поддерева (оферта/возврат/
|
* якорь заставлял бы человека прокручивать обратно наверх ради полей, которые
|
||||||
* ПДн через layout.tsx), и там `#check` без пути к лэндингу никуда бы не
|
* на отдельном экране и так первое, что он увидит.
|
||||||
* долистал.
|
|
||||||
*
|
*
|
||||||
* `.barSpacer` — не декоративный отступ, а компенсация fixed-позиционирования:
|
* `.barSpacer` — не декоративный отступ, а компенсация fixed-позиционирования:
|
||||||
* `.barRoot` вырезан из потока документа, поэтому без спейсера он бы лёг
|
* `.barRoot` вырезан из потока документа, поэтому без спейсера он бы лёг
|
||||||
|
|
@ -17,10 +16,10 @@
|
||||||
* после подвала, как Header/Footer в текущем `layout.tsx`).
|
* после подвала, как Header/Footer в текущем `layout.tsx`).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import styles from "../../landing-v3.module.css";
|
import styles from "../../landing-v3.module.css";
|
||||||
|
|
||||||
|
import { PublicLink } from "../PublicLink";
|
||||||
|
|
||||||
export function StickyCtaV3() {
|
export function StickyCtaV3() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -29,9 +28,9 @@ export function StickyCtaV3() {
|
||||||
<p className={styles.barText}>
|
<p className={styles.barText}>
|
||||||
Узнайте цену и срок продажи своей квартиры
|
Узнайте цену и срок продажи своей квартиры
|
||||||
</p>
|
</p>
|
||||||
<Link href="/mera-public#check" className={styles.barBtn}>
|
<PublicLink to="estimate" className={styles.barBtn}>
|
||||||
Проверить за 30 секунд
|
Проверить за 30 секунд
|
||||||
</Link>
|
</PublicLink>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -34,30 +34,29 @@ import {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Включён ли публичный расчёт по адресу.
|
* Включён ли ПЛАТНЫЙ публичный расчёт по адресу.
|
||||||
*
|
*
|
||||||
* Сегодня `false` и это не «недоделка фронта»: анонимный запрос к
|
* ⚠️ ЗНАЧЕНИЕ ФЛАГА СУЗИЛОСЬ 15.08.2026. Раньше он означал «публичная форма
|
||||||
* `/api/v1/geocode/suggest` и `/api/v1/trade-in/estimate` отбивается
|
* вообще что-нибудь делает»: анонимных ручек не существовало, `rbac_guard`
|
||||||
* `rbac_guard` (backend/app/core/rbac.py::_PUBLIC_PATHS — эти пути в белом
|
* отбивал любой запрос с лэндинга, и форма честно писала «расчёт не открыт».
|
||||||
* списке отсутствуют), т.е. без отдельного backend-PR любая «живая» форма на
|
* Теперь бесплатная часть ОТКРЫТА — `/estimate` ходит в `/api/public/mera/*`
|
||||||
* лэндинге отдавала бы 401. Форма поэтому честно сообщает, что расчёт ещё не
|
* (см. `backend/app/api/public/mera.py`) и показывает живую картину рынка
|
||||||
* открыт, вместо имитации загрузки.
|
* вокруг дома. Флаг остался только на платном шаге, и это правильное место
|
||||||
*
|
* для него: бесплатная проба ничего не хранит, а платный расчёт хранит.
|
||||||
* Когда бэкенд откроет анонимный периметр — переключить в `true` и подключить
|
|
||||||
* реальный сабмит в `_components/AddressForm.tsx` (там помечено TODO-местом).
|
|
||||||
*
|
*
|
||||||
* ⚠️ ГЕЙТ: это НЕ однострочник. Переключение в `true` делает ложными публичные
|
* ⚠️ ГЕЙТ: это НЕ однострочник. Переключение в `true` делает ложными публичные
|
||||||
* утверждения, которые сегодня правдивы, поэтому вместе с флагом обязаны быть
|
* утверждения, которые сегодня правдивы, поэтому вместе с флагом обязаны быть
|
||||||
* сделаны:
|
* сделаны:
|
||||||
* 1. `privacy/page.tsx`, раздел «Что делает эта страница» — он УЖЕ условный по
|
* 1. `privacy/page.tsx`: п. 5.5 условен по этому флагу и утверждает, что
|
||||||
* этому флагу (ветка `true` описывает отправку и сохранение адреса);
|
* платный расчёт не открыт. Перечитать раздел 5 целиком — п. 5.4 уже
|
||||||
* перечитать текст обеих веток перед включением.
|
* описывает бесплатную часть и переписан под неё.
|
||||||
* 2. Согласие на обработку ПДн должно фиксироваться ДО первого INSERT в
|
* 2. Согласие на обработку ПДн должно фиксироваться ДО первого INSERT в
|
||||||
* `trade_in_estimates`: сегодня адрес физлица попадает в БД раньше любого
|
* `trade_in_estimates`: сегодня адрес физлица попадает в БД раньше любого
|
||||||
* согласия (`address` NOT NULL, `expires_at` применяется только на чтении).
|
* согласия (`address` NOT NULL, `expires_at` применяется только на чтении).
|
||||||
* 3. Должен существовать реальный путь удаления данных — в бэкенде нет ни
|
* К бесплатной пробе это не относится — она не пишет вовсе.
|
||||||
* DELETE-джоба в `app/tasks/**`, ни ручки erasure (проверено grep'ом);
|
* 3. Автоматический возврат средств обязан появиться ВМЕСТЕ с кнопкой
|
||||||
* privacy-страница поэтому и не обещает удаление.
|
* оплаты, а не после: он уже обещан в опубликованной политике возврата
|
||||||
|
* (пп. 2.1 и 4), то есть является условием договора с момента публикации.
|
||||||
*/
|
*/
|
||||||
export const PUBLIC_ESTIMATE_ENABLED: boolean = false;
|
export const PUBLIC_ESTIMATE_ENABLED: boolean = false;
|
||||||
|
|
||||||
|
|
@ -151,11 +150,12 @@ export const LEGAL_ENTITY: {
|
||||||
* (rewrite на это же поддерево), и ИМЕННО короткие напечатаны в самих
|
* (rewrite на это же поддерево), и ИМЕННО короткие напечатаны в самих
|
||||||
* документах и уйдут в заявку эквайеру.
|
* документах и уйдут в заявку эквайеру.
|
||||||
*
|
*
|
||||||
* Навигация внутри сайта ходит по внутренним путям, а не по коротким: короткий
|
* ⚠️ НАВИГАЦИЯ ПО НИМ БОЛЬШЕ НЕ ХОДИТ (решение владельца, 15.08.2026):
|
||||||
* `/oferta` существует только на хосте meraocenka.ru, а то же поддерево
|
* человек не должен видеть `/trade-in/mera-public/...` в адресной строке
|
||||||
* открывается и с gendsgn.ru/trade-in/mera-public — там ссылка на `/oferta`
|
* публичного сервиса. Ссылки внутри публичных страниц ведут на короткие
|
||||||
* ушла бы в 404. Канонический адрес для внешнего мира проставлен на каждой
|
* адреса — см. `PUBLIC_ROUTES` ниже. Эти константы остались нужны для двух
|
||||||
* странице через `alternates.canonical` (см. PUBLIC_ORIGIN ниже).
|
* вещей: цель Caddy-rewrite'ов описывается ими же, и на них ссылается B2B
|
||||||
|
* (чекбокс согласия в v2 LeadForm.tsx), живущий на другом домене.
|
||||||
*/
|
*/
|
||||||
// PRIVACY_PATH ре-экспортируется из "@/lib/legal-copy" (не литерал здесь) — тот же
|
// PRIVACY_PATH ре-экспортируется из "@/lib/legal-copy" (не литерал здесь) — тот же
|
||||||
// путь нужен B2B trade-in виджетам (чекбокс согласия в v2 LeadForm.tsx), которым
|
// путь нужен 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 OFFER_PATH = "/mera-public/oferta";
|
||||||
export const REFUND_PATH = "/mera-public/refund";
|
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-ссылок. */
|
/** Канонический публичный origin сервиса — база для canonical-ссылок. */
|
||||||
export const PUBLIC_ORIGIN = "https://meraocenka.ru";
|
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_PRIVACY_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.privacy}`;
|
||||||
export const PUBLIC_OFFER_URL = `${PUBLIC_ORIGIN}/oferta`;
|
export const PUBLIC_OFFER_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.oferta}`;
|
||||||
export const PUBLIC_REFUND_URL = `${PUBLIC_ORIGIN}/refund`;
|
export const PUBLIC_REFUND_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.refund}`;
|
||||||
|
export const PUBLIC_ESTIMATE_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.estimate}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Цена одного расчёта, ₽. Названа в оферте (п. 4.1) и в политике возврата —
|
* Цена одного расчёта, ₽. Названа в оферте (п. 4.1) и в политике возврата —
|
||||||
|
|
|
||||||
130
tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts
Normal file
130
tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
88
tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts
Normal file
88
tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts
Normal 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;
|
||||||
|
}
|
||||||
61
tradein-mvp/frontend/src/app/mera-public/estimate/page.tsx
Normal file
61
tradein-mvp/frontend/src/app/mera-public/estimate/page.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1355,3 +1355,130 @@
|
||||||
opacity: 0.72;
|
opacity: 0.72;
|
||||||
margin-top: 8px;
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { IBM_Plex_Mono, Manrope } from "next/font/google";
|
||||||
|
|
||||||
import { REGION_NAME } from "./content";
|
import { REGION_NAME } from "./content";
|
||||||
import styles from "./landing.module.css";
|
import styles from "./landing.module.css";
|
||||||
import { landingVars } from "./theme";
|
import { b2cVars, landingVars } from "./theme";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ШАПКА И ПОДВАЛ ЖИВУТ В СТРАНИЦАХ, А НЕ ЗДЕСЬ (15.08.2026). Раньше layout
|
* ШАПКА И ПОДВАЛ ЖИВУТ В СТРАНИЦАХ, А НЕ ЗДЕСЬ (15.08.2026). Раньше layout
|
||||||
|
|
@ -76,7 +76,7 @@ export default function MeraPublicLayout({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${manrope.variable} ${plexMono.variable} ${styles.page}`}
|
className={`${manrope.variable} ${plexMono.variable} ${styles.page}`}
|
||||||
style={landingVars}
|
style={{ ...landingVars, ...b2cVars }}
|
||||||
>
|
>
|
||||||
{/* Цель ссылки — <main id="content"> с tabIndex={-1} (см. page.tsx и
|
{/* Цель ссылки — <main id="content"> с tabIndex={-1} (см. page.tsx и
|
||||||
privacy/page.tsx). Без tabIndex Safari/VoiceOver не переносит фокус на
|
privacy/page.tsx). Без tabIndex Safari/VoiceOver не переносит фокус на
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,17 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
LEGAL_DOCS_REVISION,
|
LEGAL_DOCS_REVISION,
|
||||||
LEGAL_ENTITY,
|
LEGAL_ENTITY,
|
||||||
PRIVACY_PATH,
|
|
||||||
PUBLIC_OFFER_URL,
|
PUBLIC_OFFER_URL,
|
||||||
PUBLIC_REFUND_URL,
|
PUBLIC_REFUND_URL,
|
||||||
REFUND_PATH,
|
|
||||||
SERVICE_PRICE_RUB,
|
SERVICE_PRICE_RUB,
|
||||||
SUPPORT_EMAIL,
|
SUPPORT_EMAIL,
|
||||||
} from "../content";
|
} from "../content";
|
||||||
import styles from "../landing.module.css";
|
import styles from "../landing.module.css";
|
||||||
|
|
||||||
|
import { PublicLink } from "../_components/PublicLink";
|
||||||
|
|
||||||
import { SiteFooter } from "../_components/SiteFooter";
|
import { SiteFooter } from "../_components/SiteFooter";
|
||||||
import { SiteHeader } from "../_components/SiteHeader";
|
import { SiteHeader } from "../_components/SiteHeader";
|
||||||
|
|
||||||
|
|
@ -62,12 +61,12 @@ export default function MeraPublicOfferPage() {
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<main id="content" className={styles.main} tabIndex={-1}>
|
<main id="content" className={styles.main} tabIndex={-1}>
|
||||||
<div className={`${styles.container} ${styles.doc}`}>
|
<div className={`${styles.container} ${styles.doc}`}>
|
||||||
<Link
|
<PublicLink
|
||||||
className={`${styles.link} ${styles.docBack}`}
|
className={`${styles.link} ${styles.docBack}`}
|
||||||
href="/mera-public"
|
to="home"
|
||||||
>
|
>
|
||||||
← На главную
|
← На главную
|
||||||
</Link>
|
</PublicLink>
|
||||||
|
|
||||||
<h1 className={styles.h2}>Публичная оферта</h1>
|
<h1 className={styles.h2}>Публичная оферта</h1>
|
||||||
<p className={styles.docMeta}>
|
<p className={styles.docMeta}>
|
||||||
|
|
@ -315,9 +314,9 @@ export default function MeraPublicOfferPage() {
|
||||||
<p>
|
<p>
|
||||||
8.0. Условия настоящего раздела дублируются и подробно раскрыты в
|
8.0. Условия настоящего раздела дублируются и подробно раскрыты в
|
||||||
отдельном документе —{" "}
|
отдельном документе —{" "}
|
||||||
<Link className={styles.link} href={REFUND_PATH}>
|
<PublicLink className={styles.link} to="refund">
|
||||||
Политике возврата денежных средств
|
Политике возврата денежных средств
|
||||||
</Link>
|
</PublicLink>
|
||||||
, размещённой по адресу {PUBLIC_REFUND_URL}. В случае расхождений
|
, размещённой по адресу {PUBLIC_REFUND_URL}. В случае расхождений
|
||||||
между настоящим разделом и указанным документом применяется
|
между настоящим разделом и указанным документом применяется
|
||||||
настоящая Оферта.
|
настоящая Оферта.
|
||||||
|
|
@ -380,9 +379,9 @@ export default function MeraPublicOfferPage() {
|
||||||
<p>
|
<p>
|
||||||
9.1. Обработка персональных данных Заказчика осуществляется
|
9.1. Обработка персональных данных Заказчика осуществляется
|
||||||
Исполнителем в соответствии с{" "}
|
Исполнителем в соответствии с{" "}
|
||||||
<Link className={styles.link} href={PRIVACY_PATH}>
|
<PublicLink className={styles.link} to="privacy">
|
||||||
Политикой обработки персональных данных
|
Политикой обработки персональных данных
|
||||||
</Link>
|
</PublicLink>
|
||||||
, размещённой на Сайте и являющейся неотъемлемой частью настоящей
|
, размещённой на Сайте и являющейся неотъемлемой частью настоящей
|
||||||
Оферты.
|
Оферты.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
LEGAL_ENTITY,
|
LEGAL_ENTITY,
|
||||||
|
|
@ -12,6 +11,8 @@ import {
|
||||||
SUPPORT_TELEGRAM_URL,
|
SUPPORT_TELEGRAM_URL,
|
||||||
} from "../content";
|
} from "../content";
|
||||||
import styles from "../landing.module.css";
|
import styles from "../landing.module.css";
|
||||||
|
|
||||||
|
import { PublicLink } from "../_components/PublicLink";
|
||||||
import { safeUrl } from "@/lib/safeUrl";
|
import { safeUrl } from "@/lib/safeUrl";
|
||||||
|
|
||||||
import { SiteFooter } from "../_components/SiteFooter";
|
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`, а НЕ числом, вписанным в текст, — это
|
* `PAID_REPORT_RETENTION_MONTHS`, а НЕ числом, вписанным в текст, — это
|
||||||
* гейтится тестом `test_paid_retention_text_consistency.py` (бэкендовая
|
* гейтится тестом `test_paid_retention_text_consistency.py` (бэкендовая
|
||||||
|
|
@ -65,12 +86,12 @@ export default function MeraPublicPrivacyPage() {
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<main id="content" className={styles.main} tabIndex={-1}>
|
<main id="content" className={styles.main} tabIndex={-1}>
|
||||||
<div className={`${styles.container} ${styles.doc}`}>
|
<div className={`${styles.container} ${styles.doc}`}>
|
||||||
<Link
|
<PublicLink
|
||||||
className={`${styles.link} ${styles.docBack}`}
|
className={`${styles.link} ${styles.docBack}`}
|
||||||
href="/mera-public"
|
to="home"
|
||||||
>
|
>
|
||||||
← На главную
|
← На главную
|
||||||
</Link>
|
</PublicLink>
|
||||||
|
|
||||||
<h1 className={styles.h2}>
|
<h1 className={styles.h2}>
|
||||||
Политика обработки персональных данных
|
Политика обработки персональных данных
|
||||||
|
|
@ -197,13 +218,23 @@ export default function MeraPublicPrivacyPage() {
|
||||||
сервисов; статические ресурсы страницы (шрифты и т. п.) подключаются с
|
сервисов; статические ресурсы страницы (шрифты и т. п.) подключаются с
|
||||||
домена Оператора.
|
домена Оператора.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
5.4. Бесплатная проверка по адресу выполняется без регистрации и без
|
||||||
|
оплаты. Введённый адрес передаётся на сервер Оператора — без этого
|
||||||
|
подобрать сопоставимые объекты невозможно, — используется однократно
|
||||||
|
для формирования подсказок адреса и поиска сопоставимых объявлений и
|
||||||
|
не сохраняется: указанные операции выполняются в режиме чтения и не
|
||||||
|
создают записей об адресе в базах данных Оператора. Адрес передаётся
|
||||||
|
телом запроса и не записывается ни в журналы веб-сервера, ни в
|
||||||
|
технические журналы сервиса, ни в систему мониторинга ошибок. Для
|
||||||
|
формирования подсказок адрес может передаваться внешним сервисам
|
||||||
|
геокодирования.
|
||||||
|
</p>
|
||||||
{PUBLIC_ESTIMATE_ENABLED ? null : (
|
{PUBLIC_ESTIMATE_ENABLED ? null : (
|
||||||
<p>
|
<p>
|
||||||
5.4. На сегодняшний день публичный расчёт по адресу на Сайте ещё не
|
5.5. Платный расчёт по адресу на Сайте на сегодняшний день ещё не
|
||||||
открыт: форма адреса на главной странице работает только в браузере
|
открыт. Пункты 5.1–5.2 в части хранения результата расчёта
|
||||||
пользователя — введённый адрес не передаётся на сервер Оператора и
|
применяются с момента его открытия.
|
||||||
нигде не сохраняется. Пункты 5.1–5.2 применяются с момента открытия
|
|
||||||
публичного расчёта.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
150
tradein-mvp/frontend/src/app/mera-public/public-api.ts
Normal file
150
tradein-mvp/frontend/src/app/mera-public/public-api.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
LEGAL_DOCS_REVISION,
|
LEGAL_DOCS_REVISION,
|
||||||
LEGAL_ENTITY,
|
LEGAL_ENTITY,
|
||||||
OFFER_PATH,
|
|
||||||
PUBLIC_OFFER_URL,
|
PUBLIC_OFFER_URL,
|
||||||
PUBLIC_REFUND_URL,
|
PUBLIC_REFUND_URL,
|
||||||
SERVICE_PRICE_RUB,
|
SERVICE_PRICE_RUB,
|
||||||
|
|
@ -12,6 +10,8 @@ import {
|
||||||
} from "../content";
|
} from "../content";
|
||||||
import styles from "../landing.module.css";
|
import styles from "../landing.module.css";
|
||||||
|
|
||||||
|
import { PublicLink } from "../_components/PublicLink";
|
||||||
|
|
||||||
import { SiteFooter } from "../_components/SiteFooter";
|
import { SiteFooter } from "../_components/SiteFooter";
|
||||||
import { SiteHeader } from "../_components/SiteHeader";
|
import { SiteHeader } from "../_components/SiteHeader";
|
||||||
|
|
||||||
|
|
@ -53,12 +53,12 @@ export default function MeraPublicRefundPage() {
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<main id="content" className={styles.main} tabIndex={-1}>
|
<main id="content" className={styles.main} tabIndex={-1}>
|
||||||
<div className={`${styles.container} ${styles.doc}`}>
|
<div className={`${styles.container} ${styles.doc}`}>
|
||||||
<Link
|
<PublicLink
|
||||||
className={`${styles.link} ${styles.docBack}`}
|
className={`${styles.link} ${styles.docBack}`}
|
||||||
href="/mera-public"
|
to="home"
|
||||||
>
|
>
|
||||||
← На главную
|
← На главную
|
||||||
</Link>
|
</PublicLink>
|
||||||
|
|
||||||
<h1 className={styles.h2}>Политика возврата денежных средств</h1>
|
<h1 className={styles.h2}>Политика возврата денежных средств</h1>
|
||||||
<p className={styles.docMeta}>
|
<p className={styles.docMeta}>
|
||||||
|
|
@ -75,9 +75,9 @@ export default function MeraPublicRefundPage() {
|
||||||
<p>
|
<p>
|
||||||
1.2. Настоящая Политика размещена в свободном доступе в сети Интернет
|
1.2. Настоящая Политика размещена в свободном доступе в сети Интернет
|
||||||
по адресу {PUBLIC_REFUND_URL} и является неотъемлемой частью{" "}
|
по адресу {PUBLIC_REFUND_URL} и является неотъемлемой частью{" "}
|
||||||
<Link className={styles.link} href={OFFER_PATH}>
|
<PublicLink className={styles.link} to="oferta">
|
||||||
Публичной оферты
|
Публичной оферты
|
||||||
</Link>
|
</PublicLink>
|
||||||
, размещённой по адресу {PUBLIC_OFFER_URL}. Оферта и настоящая
|
, размещённой по адресу {PUBLIC_OFFER_URL}. Оферта и настоящая
|
||||||
Политика применяются совместно; в случае противоречий между ними
|
Политика применяются совместно; в случае противоречий между ними
|
||||||
приоритет имеет Оферта.
|
приоритет имеет Оферта.
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import type { CSSProperties } from "react";
|
||||||
|
|
||||||
import { tokens } from "@/components/trade-in/v2/tokens";
|
import { tokens } from "@/components/trade-in/v2/tokens";
|
||||||
|
|
||||||
|
import { b2c } from "./b2c-tokens";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Набор CSS-переменных лэндинга. Приводится к CSSProperties: TS не знает про
|
* Набор CSS-переменных лэндинга. Приводится к CSSProperties: TS не знает про
|
||||||
* произвольные `--*` ключи, но React их корректно проставляет в style.
|
* произвольные `--*` ключи, но React их корректно проставляет в style.
|
||||||
|
|
@ -63,3 +65,49 @@ export const landingVars = {
|
||||||
"--m-font-sans": tokens.font.sans,
|
"--m-font-sans": tokens.font.sans,
|
||||||
"--m-font-mono": tokens.font.mono,
|
"--m-font-mono": tokens.font.mono,
|
||||||
} as CSSProperties;
|
} 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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue