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

View file

@ -251,45 +251,68 @@ meraocenka.ru {
}
}
# Короткие адреса юридических документов. Именно они напечатаны ВНУТРИ
# самих документов (оферта ссылается на meraocenka.ru/refund, политика
# возврата — на meraocenka.ru/oferta) и уходят в заявку эквайеру, поэтому
# обязаны резолвиться сами по себе, а не только длинным
# /trade-in/mera-public/<doc>. Обратное направление тоже рабочее: длинный
# путь ловит handle ниже — навигация внутри сайта ходит по нему, потому что
# то же поддерево открывается и с gendsgn.ru/trade-in/mera-public, где
# короткого /oferta нет.
# Короткие адреса страниц публичного сайта. Именно они напечатаны ВНУТРИ
# юридических документов (оферта ссылается на meraocenka.ru/refund,
# политика возврата — на meraocenka.ru/oferta) и уходят в заявку эквайеру,
# поэтому обязаны резолвиться сами по себе.
#
# ЭТО ЕДИНСТВЕННЫЙ ВИД АДРЕСА, КОТОРЫЙ ВИДИТ ЧЕЛОВЕК (решение владельца,
# 15.08.2026). Раньше навигация внутри сайта ходила по длинным
# /trade-in/mera-public/... — так короткие адреса и длинные существовали
# параллельно. Теперь длинные отдают 301 на короткие (см. handle ниже), а
# ссылки на страницах эмитятся сразу короткими (см. `PublicLink` во
# фронте — обычный <a>, потому что next/link подставляет basePath).
#
# `rewrite`, а не `redir`: адрес в строке браузера должен остаться коротким
# — модератор эквайера открывает ссылку из заявки и видит ровно тот URL,
# который в ней указан. Каноничность для поисковиков задана отдельно, через
# `alternates.canonical` на каждой из трёх страниц.
# `alternates.canonical` на каждой странице.
#
# Пути перечислены поимённо, а не шаблоном: allowlist-by-default этого
# site-блока — часть периметра (#2545), и превращать его в «любой корневой
# путь проксируется» ради трёх страниц нельзя.
@meraLegalDocs path /oferta /refund /privacy
handle @meraLegalDocs {
# путь проксируется» нельзя. Новая публичная страница = новая строка здесь
# (и проверка в scripts/smoke-mera-perimeter.sh).
#
# NB: корень «/» СЮДА НЕ ВХОДИТ — он выше, отдельным handle. Причина
# техническая: здесь цель собирается как `/trade-in/mera-public{path}`, а
# для «/» это дало бы `/trade-in/mera-public/` со слэшем на конце. Next при
# `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант
# без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на
# «/», и запрос закольцуется.
@meraPages path /estimate /oferta /refund /privacy /v3
handle @meraPages {
rewrite * /trade-in/mera-public{path}
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
}
}
# Подстраницы САМОГО лэндинга. Нужны с момента мержа #2615: футер ссылается
# на политику обработки ПДн через next/link (`PRIVACY_PATH`), а Next с
# basePath эмитит её как /trade-in/mera-public/privacy. Без этого handle
# ссылка уходила бы в catch-all 404 ниже — то есть обязательный по 152-ФЗ
# документ был бы недоступен с публичной страницы.
# Длинные адреса поддерева → 301 на короткие. Один канонический адрес у
# страницы, а не два работающих.
#
# Matcher намеренно узкий — ровно поддерево лэндинга, НЕ /trade-in/*.
# B2B-дерево (/trade-in/v2, /trade-in/api/*, /trade-in/admin/*, /history)
# под него не подпадает и по-прежнему отдаёт 404. Регресс-тест на это —
# в scripts/smoke-mera-perimeter.sh.
handle /trade-in/mera-public/* {
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
}
# Зачем вообще оставлять длинные: они уже разошлись — ими ссылались подвал
# и шапка до 15.08.2026, они могли попасть в закладки и в переписку. 301
# (а не 404) сохраняет эти ссылки живыми и заодно передаёт поисковикам, что
# канонический адрес один.
#
# ЗДЕСЬ ЖЕ ЧИНИТСЯ БАГ: прежний матчер был `/trade-in/mera-public/*` — со
# слэшем и звёздочкой, поэтому ГОЛЫЙ `/trade-in/mera-public` (без хвоста)
# под него не подпадал и падал в catch-all 404. Ровно на этот адрес вела
# ссылка «Главная» в подвале v3, то есть она была мёртвой (замер на проде
# 15.08.2026). Первый матчер ниже ловит обе формы — со слэшем и без.
#
# 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-префиксом (тот же

View file

@ -3,10 +3,16 @@
#
# Проверяет инварианты периметра (см. корневой Caddyfile):
# 1. meraocenka.ru отдаёт 200 анонимно (публичный лэндинг).
# 1b. Подстраница лэндинга /trade-in/mera-public/privacy отдаёт 200 —
# политика ПДн, на которую ссылается футер.
# 1b. Длинный адрес /trade-in/mera-public/privacy отдаёт 301 на короткий
# (у страницы один канонический адрес, старые ссылки не ломаются).
# 1c. Короткие адреса /oferta, /refund, /privacy отдают 200 — эти URL
# напечатаны внутри самих юридических документов и уходят эквайеру.
# 1d. /estimate отдаёт 200 — экран проверки, куда ведут все кнопки лэндинга.
# 1e. Длинные адреса поддерева отдают 301 на короткие (включая ГОЛЫЙ
# /trade-in/mera-public — прежний матчер его не ловил, «Главная» в подвале
# вела в 404).
# 2d. Публичный API /api/public/mera/* доступен анонимно, а /api/v1/* на
# публичном домене по-прежнему 404.
# 2. meraocenka.ru/v2 и /trade-in/v2, /trade-in/api/* (B2B-пути) отдают 404 —
# allowlist-by-default, НЕ были случайно проброшены на B2B-дерево
# tradein-frontend. Проверяются обе формы — с basePath-префиксом и без.
@ -61,11 +67,13 @@ echo "== МЕРА B2C perimeter smoke (ЭТАП 1) =="
# 1. Публичный домен отдаёт 200 анонимно.
check "meraocenka.ru root — public 200" "$BASE_MERA/" 200
# 1b. Подстраница лэндинга (политика ПДн) доступна — на неё ссылается футер.
# Путь приезжает с basePath: next/link + basePath=/trade-in эмитит именно
# /trade-in/mera-public/privacy. Если этот handle выпадет из Caddyfile,
# обязательный по 152-ФЗ документ станет недоступен с публичной страницы.
check "meraocenka.ru privacy — public 200" "$BASE_MERA/trade-in/mera-public/privacy" 200
# 1b. Подстраница лэндинга по ДЛИННОМУ адресу теперь отдаёт 301 на короткий, а
# не 200: с 15.08.2026 у публичной страницы один канонический адрес.
# Проверка осталась именно здесь, потому что раньше она сторожила
# доступность обязательного по 152-ФЗ документа — теперь сторожит, что при
# переходе на короткие адреса длинные не превратились в 404 (тогда бы
# сломались уже разосланные ссылки).
check "meraocenka.ru длинная privacy — 301 на короткую" "$BASE_MERA/trade-in/mera-public/privacy" 301
# 1c. Короткие адреса юридических документов. Это НЕ дубль проверки 1b: именно
# эти три URL напечатаны внутри самих документов и уходят в заявку
@ -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/privacy — public 200" "$BASE_MERA/privacy" 200
# 1d. Экран проверки квартиры — короткий адрес, на который ведут все кнопки
# лэндинга. Отвалится handle — кнопки «Проверить» станут ссылками в 404.
check "meraocenka.ru/estimate — public 200" "$BASE_MERA/estimate" 200
# 1e. Длинные адреса поддерева отдают 301 на короткие: у страницы один
# канонический адрес, а старые ссылки и закладки продолжают работать.
# ГОЛЫЙ /trade-in/mera-public — регресс на баг 15.08.2026: прежний матчер
# `/trade-in/mera-public/*` эту форму не ловил, и ссылка «Главная» в
# подвале v3 вела в 404.
check "meraocenka.ru длинный корень — 301 на /" "$BASE_MERA/trade-in/mera-public" 301
check "meraocenka.ru длинная оферта — 301 на /oferta" "$BASE_MERA/trade-in/mera-public/oferta" 301
# 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401.
check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404
@ -101,16 +121,16 @@ check "meraocenka.ru/_next/image — must 404 (не открываем опти
# `/trade-in/api/*`. Зелёная только первая = API открыт целиком и тест это
# пропустил (ровно та ошибка, ради которой в Caddyfile выбран отдельный
# префикс, а не поимённый проброс 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 "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
# Тот же публичный путь на gendsgn.ru: страницу лэндинга открывают и оттуда
# (QA за basic_auth), поэтому URL у формы один на оба домена. Здесь он
# проходит через уже существующий `handle /trade-in/api/*` — проверка ловит
# регресс в 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 по-прежнему закрыты анониму.
#

View file

@ -60,7 +60,8 @@ from __future__ import annotations
import logging
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 app.api.v1.geocode import SuggestResponse, suggest_addresses
@ -106,16 +107,37 @@ def _enforce(limiter: SlidingWindowLimiter, request: Request, what: str) -> None
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(
request: Request,
q: Annotated[str, Query(min_length=2, max_length=200)],
payload: PublicSuggestInput,
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:
"""Автокомплит адреса для публичной формы (Свердловская область).
ПОЧЕМУ POST У ЧИТАЮЩЕЙ РУЧКИ. Каноничнее был бы GET с `?q=`. Но на
публичном домене включён access-лог (`/var/log/caddy/meraocenka.ru.log`), а
он пишет URI целиком то есть адрес квартиры лёг бы в файл рядом с IP
посетителя. Мы публично обещаем на этой же странице, что введённый адрес
нигде не сохраняем; лог это сохранение. Тело запроса в лог не попадает,
поэтому обещание остаётся правдой без правки конфигурации логирования (её
легко потерять при следующем рефакторинге Caddyfile а тип запроса
потерять нельзя, сломается сразу и заметно).
Тот же довод, что у черновика с лэндинга: он едет через sessionStorage, а
не через query-параметры (frontend `estimate-draft.ts`).
Делегирует В ТУ ЖЕ функцию, что обслуживает B2B-экран
(`app.api.v1.geocode.suggest_addresses`), а не повторяет её логику:
публичная форма обязана резолвить адрес ровно так же, как платный расчёт,
@ -126,7 +148,9 @@ async def public_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)

View file

@ -150,11 +150,24 @@ def test_anonymous_gets_suggest(client: TestClient) -> None:
"suggest_addresses",
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.json() == {"items": []}
def test_suggest_is_post_so_address_never_lands_in_access_log() -> None:
"""Адрес едет ТЕЛОМ, а не в query.
На публичном домене включён access-лог Caddy, и он пишет URI целиком
`GET /suggest?q=Малышева+51` сохранил бы адрес квартиры в файл рядом с IP
посетителя. На той же странице мы обещаем, что введённый адрес нигде не
сохраняем. Метод часть этого обещания, поэтому запинен: вернуть GET
«для каноничности» нельзя, не сломав обещание.
"""
route = next(r for r in public_mera.router.routes if r.path == "/suggest")
assert route.methods == {"POST"}
def test_closed_twin_still_401_for_anonymous(client: TestClient) -> None:
"""Исключение точечное, а не «auth-гейт снят».
@ -198,7 +211,7 @@ def test_suggest_and_coverage_budgets_are_independent(client: TestClient) -> Non
AsyncMock(return_value=SuggestResponse(items=[])),
):
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):
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",
AsyncMock(return_value=SuggestResponse(items=[])),
):
at_ceiling = client.get(f"{PREFIX}/suggest", params={"q": "Малышева", "limit": 10})
above = client.get(f"{PREFIX}/suggest", params={"q": "Малышева", "limit": 15})
at_ceiling = client.post(f"{PREFIX}/suggest", json={"q": "Малышева", "limit": 10})
above = client.post(f"{PREFIX}/suggest", json={"q": "Малышева", "limit": 15})
assert at_ceiling.status_code == 200, at_ceiling.text
assert above.status_code == 422, above.text

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,4 @@
import type { Metadata } from "next";
import Link from "next/link";
import {
LEGAL_ENTITY,
@ -12,6 +11,8 @@ import {
SUPPORT_TELEGRAM_URL,
} from "../content";
import styles from "../landing.module.css";
import { PublicLink } from "../_components/PublicLink";
import { safeUrl } from "@/lib/safeUrl";
import { SiteFooter } from "../_components/SiteFooter";
@ -29,9 +30,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`, а НЕ числом, вписанным в текст, это
* гейтится тестом `test_paid_retention_text_consistency.py` (бэкендовая
@ -65,12 +76,12 @@ export default function MeraPublicPrivacyPage() {
<SiteHeader />
<main id="content" className={styles.main} tabIndex={-1}>
<div className={`${styles.container} ${styles.doc}`}>
<Link
<PublicLink
className={`${styles.link} ${styles.docBack}`}
href="/mera-public"
to="home"
>
На главную
</Link>
</PublicLink>
<h1 className={styles.h2}>
Политика обработки персональных данных
@ -197,13 +208,22 @@ export default function MeraPublicPrivacyPage() {
сервисов; статические ресурсы страницы (шрифты и т. п.) подключаются с
домена Оператора.
</p>
<p>
5.4. Бесплатная проверка по адресу выполняется без регистрации и без
оплаты. Введённый адрес передаётся на сервер Оператора без этого
подобрать сопоставимые объекты невозможно, используется однократно
для формирования подсказок адреса и поиска сопоставимых объявлений и
не сохраняется: указанные операции выполняются в режиме чтения и не
создают записей об адресе в базах данных Оператора. Адрес передаётся
телом запроса и не записывается в журналы веб-сервера. Для
формирования подсказок адрес может передаваться внешним сервисам
геокодирования.
</p>
{PUBLIC_ESTIMATE_ENABLED ? null : (
<p>
5.4. На сегодняшний день публичный расчёт по адресу на Сайте ещё не
открыт: форма адреса на главной странице работает только в браузере
пользователя введённый адрес не передаётся на сервер Оператора и
нигде не сохраняется. Пункты 5.15.2 применяются с момента открытия
публичного расчёта.
5.5. Платный расчёт по адресу на Сайте на сегодняшний день ещё не
открыт. Пункты 5.15.2 в части хранения результата расчёта
применяются с момента его открытия.
</p>
)}

View file

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

View file

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

View file

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