feat(mera/b2c): отдельный экран оценки на meraocenka.ru и короткие адреса
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 44s
CI Trade-In / frontend-checks (pull_request) Successful in 1m2s
CI Trade-In / backend-tests (pull_request) Successful in 4m41s

## Экран проверки — /estimate

Проверка квартиры вынесена на собственный адрес: там у автокомплита есть
место под список подсказок, а у результата — место рядом с полями. Форма в
герое лэндинга осталась входной точкой и уводит сюда, донося набранное через
sessionStorage (НЕ через query — адрес в URL попал бы в access-лог Caddy рядом
с IP посетителя, а мы на той же странице обещаем ничего не хранить).

Показывает живую пробу покрытия: сколько похожих квартир продаётся рядом и
сколько в среднем висят их объявления. Ни одной рублёвой цифры — цену продаёт
платный шаг. Тексты вердикта вынесены чистой функцией (coverage-copy.ts) и
покрыты тестами: подпись под возрастом обязана говорить «объявление», а не
«продаётся» (выборка цензурирована), при неизвестном возрасте плитки нет
вообще, а пустая когорта объясняется как факт о рынке с подсказкой, что
поменять, — директива «никогда не блокировать вывод».

## Короткие адреса

Человек больше не видит /trade-in/mera-public/... — только /, /estimate,
/oferta, /refund, /privacy. Длинные адреса отдают 301 на короткие: у страницы
один канонический адрес, старые ссылки живы.

Цена решения: ссылки работают только на meraocenka.ru (короткие пути раздаёт
этот хост). Открывать лэндинг для проверки нужно там же, а не с gendsgn.ru.

Ссылки эмитятся обычным <a> (PublicLink) — next/link подставляет basePath, и
href="/estimate" уехал бы на несуществующий /trade-in/estimate.

## Три дефекта, найденных на живом сайте

1. Палитра v3 никуда не доезжала. b2c-tokens.ts не импортировал НИКТО, ни одна
   --b2c-* переменная не объявлялась, каскад молча пропускал такие декларации —
   лэндинг отдавал 200 бесцветным. Добавлен мост b2cVars, гейтом стал тест:
   каждая использованная в CSS переменная обязана быть объявлена.
2. Голый /trade-in/mera-public падал в 404 — матчер был со слэшем и звёздочкой.
   Ровно туда вела «Главная» в подвале.
3. «Для бизнеса» вела на «/» — то есть на сам лэндинг. Теперь абсолютный адрес
   B2B-контура. Пункты «Проверьте себя» и «Продажа под ключ» вели на якоря,
   которых нет нигде: приведены к виду «Статьи» — видны, но не кликабельны.

## Периметр и приватность

Подсказки переведены на POST: access-лог публичного домена пишет URI целиком,
то есть GET с ?q= сохранял бы адрес квартиры в файл. Тело в лог не попадает.
Метод запинен тестом — это часть обещания, а не стиль.

П. 5.4 политики ПДн переписан ВМЕСТЕ с кодом: прежний текст утверждал, что
адрес не покидает браузер, и это перестало быть правдой. Новый говорит точно —
передаётся, используется однократно, в базах не сохраняется. Последнее
проверено по коду: suggest() работает без кэша, проба — один SELECT, аудит
пишет строку только при наличии username. PUBLIC_ESTIMATE_ENABLED сузился до
платного шага (бесплатная проба не хранит ничего, платный расчёт хранит).

## Проверено

vitest 47 passed (9 файлов), tsc, next lint, next build, isolation guard
40 файлов, backend 75 passed, caddy validate = Valid configuration.

Мутации структурных гейтов:
  убрать --b2c-accent-text из b2cVars   → падает тест палитры
  убрать /estimate из @meraPages        → падает тест маршрутов
  добавить импорт next/link             → падает тест basePath
  откат                                 → 14 passed

Caddyfile добавлен в paths-filter фронтового лэйна: его читает тест маршрутов,
и без этой строки правка одного лишь Caddyfile не запускала бы ни один гейт.

Refs #2894, #2895
This commit is contained in:
bot-backend 2026-08-16 09:27:04 +03:00
parent d046eaada2
commit 7424c283d5
27 changed files with 1630 additions and 245 deletions

View file

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

View file

@ -251,45 +251,68 @@ 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 на
# «/», и запрос закольцуется.
@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
# ссылка уходила бы в catch-all 404 ниже — то есть обязательный по 152-ФЗ
# документ был бы недоступен с публичной страницы.
# #
# 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). Первый матчер ниже ловит обе формы — со слэшем и без.
#
# Query-строка при редиректе не переносится: ни одна из этих страниц
# параметров не принимает (форма проверки шлёт данные телом POST, а
# черновик с лэндинга едет через sessionStorage — специально чтобы адрес
# квартиры не попал в access-лог).
@meraLongRoot path /trade-in/mera-public /trade-in/mera-public/
handle @meraLongRoot {
redir / permanent
} }
@meraLongSub path_regexp meralong ^/trade-in/mera-public/(.+)$
handle @meraLongSub {
redir /{re.meralong.1} permanent
} }
# Next.js уже эмитит ссылки на статику с /trade-in-префиксом (тот же # Next.js уже эмитит ссылки на статику с /trade-in-префиксом (тот же

View file

@ -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-префиксом и без.
@ -61,11 +67,13 @@ 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 напечатаны внутри самих документов и уходят в заявку
@ -76,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
@ -101,16 +121,16 @@ check "meraocenka.ru/_next/image — must 404 (не открываем опти
# `/trade-in/api/*`. Зелёная только первая = API открыт целиком и тест это # `/trade-in/api/*`. Зелёная только первая = API открыт целиком и тест это
# пропустил (ровно та ошибка, ради которой в Caddyfile выбран отдельный # пропустил (ровно та ошибка, ради которой в Caddyfile выбран отдельный
# префикс, а не поимённый проброс v1-путей). # префикс, а не поимённый проброс v1-путей).
check "meraocenka.ru public suggest — 200 anonymous" "$BASE_MERA/trade-in/api/public/mera/suggest?q=%D0%9C%D0%B0%D0%BB%D1%8B%D1%88%D0%B5%D0%B2%D0%B0" 200 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_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=%D0%9C%D0%B0%D0%BB%D1%8B%D1%88%D0%B5%D0%B2%D0%B0" 404 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 check "meraocenka.ru v1 coverage — must stay 404" "$BASE_MERA/trade-in/api/v1/trade-in/coverage" 404
# Тот же публичный путь на gendsgn.ru: страницу лэндинга открывают и оттуда # Тот же публичный путь на gendsgn.ru: страницу лэндинга открывают и оттуда
# (QA за basic_auth), поэтому URL у формы один на оба домена. Здесь он # (QA за basic_auth), поэтому URL у формы один на оба домена. Здесь он
# проходит через уже существующий `handle /trade-in/api/*` — проверка ловит # проходит через уже существующий `handle /trade-in/api/*` — проверка ловит
# регресс в rbac._PUBLIC_PATHS (стало бы 401), а не в Caddyfile. # регресс в rbac._PUBLIC_PATHS (стало бы 401), а не в Caddyfile.
check "gendsgn.ru public suggest — 200 anonymous" "$BASE_MAIN/trade-in/api/public/mera/suggest?q=%D0%9C%D0%B0%D0%BB%D1%8B%D1%88%D0%B5%D0%B2%D0%B0" 200 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 по-прежнему закрыты анониму.
# #

View file

@ -60,7 +60,8 @@ from __future__ import annotations
import logging import logging
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.v1.geocode import SuggestResponse, suggest_addresses from app.api.v1.geocode import SuggestResponse, suggest_addresses
@ -106,16 +107,37 @@ def _enforce(limiter: SlidingWindowLimiter, request: Request, what: str) -> None
limiter.record(ip) limiter.record(ip)
@router.get("/suggest", response_model=SuggestResponse) 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( async def public_suggest(
request: Request, request: Request,
q: Annotated[str, Query(min_length=2, max_length=200)], payload: PublicSuggestInput,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
limit: Annotated[int, Query(ge=1, le=10)] = 8,
city_hint: Annotated[str | None, Query(max_length=100)] = None,
) -> SuggestResponse: ) -> SuggestResponse:
"""Автокомплит адреса для публичной формы (Свердловская область). """Автокомплит адреса для публичной формы (Свердловская область).
ПОЧЕМУ POST У ЧИТАЮЩЕЙ РУЧКИ. Каноничнее был бы GET с `?q=`. Но на
публичном домене включён access-лог (`/var/log/caddy/meraocenka.ru.log`), а
он пишет URI целиком то есть адрес квартиры лёг бы в файл рядом с IP
посетителя. Мы публично обещаем на этой же странице, что введённый адрес
нигде не сохраняем; лог это сохранение. Тело запроса в лог не попадает,
поэтому обещание остаётся правдой без правки конфигурации логирования (её
легко потерять при следующем рефакторинге Caddyfile а тип запроса
потерять нельзя, сломается сразу и заметно).
Тот же довод, что у черновика с лэндинга: он едет через sessionStorage, а
не через query-параметры (frontend `estimate-draft.ts`).
Делегирует В ТУ ЖЕ функцию, что обслуживает B2B-экран Делегирует В ТУ ЖЕ функцию, что обслуживает B2B-экран
(`app.api.v1.geocode.suggest_addresses`), а не повторяет её логику: (`app.api.v1.geocode.suggest_addresses`), а не повторяет её логику:
публичная форма обязана резолвить адрес ровно так же, как платный расчёт, публичная форма обязана резолвить адрес ровно так же, как платный расчёт,
@ -126,7 +148,9 @@ async def public_suggest(
стоить внешнего вызова. стоить внешнего вызова.
""" """
_enforce(_suggest_limiter, request, "suggest") _enforce(_suggest_limiter, request, "suggest")
return await suggest_addresses(q=q, limit=limit, db=db, city_hint=city_hint) return await suggest_addresses(
q=payload.q, limit=payload.limit, db=db, city_hint=payload.city_hint
)
@router.post("/coverage", response_model=CoverageProbeResponse) @router.post("/coverage", response_model=CoverageProbeResponse)

View file

@ -150,11 +150,24 @@ def test_anonymous_gets_suggest(client: TestClient) -> None:
"suggest_addresses", "suggest_addresses",
AsyncMock(return_value=SuggestResponse(items=[])), AsyncMock(return_value=SuggestResponse(items=[])),
): ):
resp = client.get(f"{PREFIX}/suggest", params={"q": "Малышева"}) resp = client.post(f"{PREFIX}/suggest", json={"q": "Малышева"})
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert resp.json() == {"items": []} 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: def test_closed_twin_still_401_for_anonymous(client: TestClient) -> None:
"""Исключение точечное, а не «auth-гейт снят». """Исключение точечное, а не «auth-гейт снят».
@ -198,7 +211,7 @@ def test_suggest_and_coverage_budgets_are_independent(client: TestClient) -> Non
AsyncMock(return_value=SuggestResponse(items=[])), AsyncMock(return_value=SuggestResponse(items=[])),
): ):
for _ in range(public_mera._SUGGEST_LIMIT): for _ in range(public_mera._SUGGEST_LIMIT):
client.get(f"{PREFIX}/suggest", params={"q": "Малышева"}) client.post(f"{PREFIX}/suggest", json={"q": "Малышева"})
with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE): with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE):
resp = client.post(f"{PREFIX}/coverage", json=_COVERAGE_BODY) resp = client.post(f"{PREFIX}/coverage", json=_COVERAGE_BODY)
@ -229,7 +242,7 @@ def test_suggest_limit_ceiling_is_lower_than_v1(client: TestClient) -> None:
"suggest_addresses", "suggest_addresses",
AsyncMock(return_value=SuggestResponse(items=[])), AsyncMock(return_value=SuggestResponse(items=[])),
): ):
at_ceiling = client.get(f"{PREFIX}/suggest", params={"q": "Малышева", "limit": 10}) at_ceiling = client.post(f"{PREFIX}/suggest", json={"q": "Малышева", "limit": 10})
above = client.get(f"{PREFIX}/suggest", params={"q": "Малышева", "limit": 15}) above = client.post(f"{PREFIX}/suggest", json={"q": "Малышева", "limit": 15})
assert at_ceiling.status_code == 200, at_ceiling.text assert at_ceiling.status_code == 200, at_ceiling.text
assert above.status_code == 422, above.text assert above.status_code == 422, above.text

View file

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

View file

@ -0,0 +1,109 @@
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);
});
it("каждый маршрут из PUBLIC_ROUTES раздаётся публичным доменом", () => {
for (const route of Object.values(PUBLIC_ROUTES)) {
// Корень — отдельным `handle /`, остальные перечислены в матчере @meraPages.
const served =
route === "/"
? /handle\s+\/\s*\{/.test(meraBlock)
: new RegExp(`@meraPages path[^\\n]*\\s${route}(\\s|$)`, "m").test(meraBlock);
expect(served, `${route} не раздаётся на meraocenka.ru — ссылка на него будет 404`).toBe(
true,
);
}
});
});
describe("basePath не протекает в публичные ссылки", () => {
/**
* `next/link` подставляет `basePath=/trade-in` в каждый href, поэтому
* `<Link href="/estimate">` уехал бы на несуществующий `/trade-in/estimate`.
* Публичное дерево ходит обычным `<a>` (компонент `PublicLink`).
*
* Проверка на импорт, а не на строку `/trade-in` в разметке: именно импорт
* `next/link` источник подстановки, и именно он вернётся, если кто-то
* добавит ссылку «как везде в проекте».
*/
it("ни один файл под mera-public не импортирует next/link", () => {
const offenders = collectFiles(MERA_PUBLIC, [".ts", ".tsx"])
.filter((file) => /from\s+["']next\/link["']/.test(readFileSync(file, "utf8")))
.map((file) => path.relative(MERA_PUBLIC, file));
expect(
offenders,
`next/link подставит basePath и ссылка уедет на /trade-in/...: ${offenders}`,
).toEqual([]);
});
});

View file

@ -29,10 +29,14 @@
* (useMe/useQuota/useHistory и соседей) запрещено публичный экран не должен * (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 =

View file

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

View file

@ -8,24 +8,23 @@
* оператора по 152-ФЗ. Значения импортируются, а не пишутся строками: тот же * оператора по 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>

View file

@ -0,0 +1,403 @@
"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 { 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;
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 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 addressRef = useRef<HTMLInputElement>(null);
const areaRef = useRef<HTMLInputElement>(null);
const suggestAbort = useRef<AbortController | null>(null);
// Черновик с лэндинга — то, что человек уже набрал там. Забираем ОДИН раз
// на монтировании; координат в нём нет (и быть не может — на лэндинге нет
// автокомплита), поэтому дом всё равно придётся выбрать из подсказок.
useEffect(() => {
const draft = takeDraft();
if (!draft) return;
setQuery(draft.address);
if (draft.rooms) setRooms(draft.rooms);
if (draft.area) setArea(draft.area);
if (draft.city && COVERED_CITIES.includes(draft.city)) setCity(draft.city);
}, []);
// Подсказки: debounce + отмена предыдущего запроса. Без отмены ответы
// приходят в произвольном порядке, и в списке может осесть выдача по
// ПРЕДЫДУЩЕЙ строке — человек видит подсказки не к тому, что набрал.
useEffect(() => {
if (picked || query.trim().length < SUGGEST_MIN_CHARS) {
setSuggestions([]);
return;
}
const timer = setTimeout(() => {
suggestAbort.current?.abort();
const controller = new AbortController();
suggestAbort.current = controller;
fetchAddressSuggestions(query.trim(), {
cityHint: city,
signal: controller.signal,
})
.then((items) => {
setSuggestions(items);
setHighlighted(-1);
})
.catch(() => {
// Молча: подсказки — удобство. Отвалились (в т.ч. по лимиту) —
// человек всё ещё может выбрать дом, набрав адрес точнее. Ронять
// на этом весь экран было бы хуже, чем остаться без списка.
setSuggestions([]);
});
}, SUGGEST_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [query, city, picked]);
useEffect(() => () => suggestAbort.current?.abort(), []);
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") {
setSuggestions([]);
}
}
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" });
fetchCoverage({
lat: picked.lat,
lon: picked.lon,
rooms: Number.parseInt(rooms, 10),
area_m2: areaValue,
})
.then((probe) => setPhase({ kind: "result", verdict: describeCoverage(probe) }))
.catch((error) => setPhase({ kind: "failed", ...failureCopy(error) }));
}
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>
<div className={styles.estAutocomplete}>
<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"
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>
)}
{phase.kind === "result" && <CoverageResult verdict={phase.verdict} />}
</div>
</div>
);
}
function CoverageResult({ verdict }: { verdict: CoverageVerdict }) {
return (
<div className={styles.estResult}>
<p className={styles.heroResultEyebrow}>
{verdict.tone === "none" ? "ПРОВЕРИЛИ" : "ГОТОВО · БЕСПЛАТНАЯ ЧАСТЬ"}
</p>
<p className={styles.heroFormFeedbackTitle}>{verdict.title}</p>
{verdict.tiles.length > 0 && (
<div className={styles.heroResultGrid}>
{verdict.tiles.map((tile) => (
<div key={tile.label} className={styles.heroResultTile}>
<div className={styles.heroResultTileValue}>{tile.value}</div>
<div className={styles.heroResultTileLabel}>{tile.label}</div>
</div>
))}
</div>
)}
{verdict.lines.map((line) => (
<p key={line} className={styles.heroFormFeedbackText}>
{line}
</p>
))}
{verdict.offerPaidStep && (
<div className={styles.estPaidStep}>
<p className={styles.heroResultOverlayTitle}>
Цена и срок продажи следующий шаг
</p>
<p className={styles.heroFormFeedbackText}>
Полный расчёт стоит 150&nbsp;: диапазон цены, цена за квадратный
метр и список квартир, на которых он построен. Приём оплаты сейчас
подключается как только заработает, кнопка появится здесь.
</p>
</div>
)}
</div>
);
}

View file

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

View file

@ -4,7 +4,7 @@
* *
* РЕКВИЗИТЫ И ДОКУМЕНТЫ НЕ из макета (там маска «ООО «МЕРА» · ИНН * РЕКВИЗИТЫ И ДОКУМЕНТЫ НЕ из макета (там маска «ООО «МЕРА» · ИНН
* 66700XXXXX», это не настоящие данные), а из content.ts: `LEGAL_ENTITY`, * 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>

View file

@ -4,17 +4,13 @@
* FreeCheckCard карточка «Бесплатная проверка» на первом экране (макет v3, * FreeCheckCard карточка «Бесплатная проверка» на первом экране (макет v3,
* `МЕРА для себя v3.dc.html` строки ~129-170). * `МЕРА для себя v3.dc.html` строки ~129-170).
* *
* ЧТО ОНА ДЕЛАЕТ СЕГОДНЯ И ПОЧЕМУ. Ровно то же, чем ограничена * ЧТО ОНА ДЕЛАЕТ СЕГОДНЯ. Проверяет пять полей и уводит на `/estimate`, где
* `../AddressForm.tsx` в закрытом v2-контуре, и по той же причине: * живёт сама проверка. Макетный сценарий «спиннер 1100&nbsp;мс готовый
* `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&nbsp;мс готовый результат». Тот сценарий на живом
* сайте отдавал бы одинаковые «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>

View file

@ -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) => (
Проверьте себя
</Link>
<Link href="/mera-public#turnkey" className={styles.hdrNavLink}>
Продажа под ключ
</Link>
<span <span
key={name}
className={styles.hdrNavDisabled} className={styles.hdrNavDisabled}
aria-disabled="true" aria-disabled="true"
title="Раздел скоро откроется" title="Раздел скоро откроется"
> >
Статьи {name}
</span> </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>

View file

@ -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>
</> </>
); );

View file

@ -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) и в политике возврата

View file

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

View file

@ -0,0 +1,65 @@
/**
* Черновик заявки: то, что человек уже набрал на лэндинге, чтобы не набирать
* это заново на `/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;
}
}

View file

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

View file

@ -1355,3 +1355,130 @@
opacity: 0.72; 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);
}

View file

@ -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 не переносит фокус на

View file

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

View file

@ -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,19 @@ import { SiteHeader } from "../_components/SiteHeader";
* стала неудобной, а потому, что перестала быть правдой. * стала неудобной, а потому, что перестала быть правдой.
* *
* ЧТО ОСТАЛОСЬ ОТ ПРЕЖНЕЙ СТРАНИЦЫ правило честности: * ЧТО ОСТАЛОСЬ ОТ ПРЕЖНЕЙ СТРАНИЦЫ правило честности:
* - Раздел про страницу ввода адреса УСЛОВЕН по `PUBLIC_ESTIMATE_ENABLED`. * - Раздел про страницу ввода адреса переписан 15.08.2026 ВМЕСТЕ с кодом.
* Пока публичный расчёт выключен, адрес физически не покидает браузер, и * Прежний п. 5.4 утверждал, что введённый адрес не покидает браузер, и
* писать обратное нельзя; текст меняется вместе с флагом, а не потом. * это было правдой, пока форма ничего не отправляла. С открытием
* бесплатной проверки (`/estimate` `/api/public/mera/*`) адрес уходит на
* сервер, поэтому утверждение заменено на точное: передаётся, используется
* однократно, в базах не сохраняется. Последнее проверено по коду, а не
* предположено: `suggest()` работает без кэша (докстринг
* `services/geocoder.py`), проба покрытия один SELECT, а `RequestAudit`
* пишет строку только при наличии username, которого у анонима нет; в
* любом случае адрес едет телом запроса и в аудит не попадает.
* - Пункт про ПЛАТНЫЙ расчёт остался условным по `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 +76,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 +208,22 @@ 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.15.2 в части хранения результата расчёта
пользователя введённый адрес не передаётся на сервер Оператора и применяются с момента его открытия.
нигде не сохраняется. Пункты 5.15.2 применяются с момента открытия
публичного расчёта.
</p> </p>
)} )}

View file

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

View file

@ -1,10 +1,8 @@
import type { Metadata } from "next"; import 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}. Оферта и настоящая
Политика применяются совместно; в случае противоречий между ними Политика применяются совместно; в случае противоречий между ними
приоритет имеет Оферта. приоритет имеет Оферта.

View file

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