Merge remote-tracking branch 'forgejo/main' into fix/tradein-ttl-effective-cap

This commit is contained in:
bot-backend 2026-08-15 20:26:40 +03:00
commit 8fff174337
26 changed files with 3412 additions and 43 deletions

View file

@ -8,6 +8,13 @@ Persistent offset в /state/offset.json — не дублируем при resta
Throttle: при >10 401 events за 60s однократный digest event
(чтобы не флудить GlitchTip storm'ом); индивидуальные events во время storm пропускаются.
before_send=_drop_basic_auth_noise (glitchtip-noise фикс): все события отсюда
дропаются перед отправкой в GlitchTip 401 от неаутентифицированного запроса
не ошибка сервиса, это боты сканируют закрытый basic_auth'ом сайт. Раньше это
был крупнейший источник шума в трекере (3 738 issue). Скрипт по-прежнему тэйлит
лог и печатает `[forwarder] 401 event sent: ...` в stdout (docker logs) просто
больше не шлёт эти события в issue-трекер. Смотри `_drop_basic_auth_noise` docstring.
Реальный Caddy JSON access log (v2) структура:
{
"level": "info",
@ -73,6 +80,41 @@ _shutdown = False
_last_exc_sent: float = 0.0
_EXC_THROTTLE_S: float = 300.0
# event_type-теги, которыми emit_event/emit_digest помечают КАЖДОЕ отправляемое
# событие (см. scope.set_tag("event_type", ...) ниже) — используются как ключ
# для before_send-фильтра.
_BASIC_AUTH_EVENT_TYPES = frozenset({"basic_auth_failed", "basic_auth_storm"})
def _drop_basic_auth_noise(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg]
"""before_send-фильтр: 401 неаутентифицированного basic_auth-запроса — НЕ
ошибка сервиса, а expected-поведение сканеров-ботов, ломящихся в закрытый
basic_auth'ом gendsgn.ru (`GET /wp-admin/install.php` и подобное). До этого
фикса emit_event/emit_digest слали КАЖДЫЙ такой 401 individual-событием (или
storm-digest) в GlitchTip remote_ip в message/тегах раздувал кардинальность
(3 738 issue, 2 019 различных заголовков, топ 222 события на «GET
/wp-admin/install.p»), топя содержательные алерты (OperationalError, sweep
failures) в шуме сканеров.
Дропаем НА ИСТОЧНИКЕ (before_send), не постфактум-чисткой issue-трекера
так шум не появляется вообще, а не изредка удаляется руками. Фильтруем по
тегу `event_type`, который ставят ТОЛЬКО emit_event/emit_digest необработанные
исключения самого форвардера (`capture_exception` в конце `main()`, реальный
баг скрипта) этот тег не несут и проходят фильтр как есть (см. `except
Exception` ниже в `main()`).
"""
tags = event.get("tags")
event_type = None
if isinstance(tags, dict):
event_type = tags.get("event_type")
elif isinstance(tags, list):
# sentry_sdk в некоторых версиях сериализует tags как list[tuple[str, str]]
# вместо dict — на всякий случай поддерживаем обе формы.
event_type = dict(tags).get("event_type") if tags else None
if event_type in _BASIC_AUTH_EVENT_TYPES:
return None
return event
def _signal_handler(signum: int, frame: object) -> None:
global _shutdown
@ -221,6 +263,7 @@ def main() -> None:
traces_sample_rate=0.0,
attach_stacktrace=False,
send_default_pii=False,
before_send=_drop_basic_auth_noise,
# Отключаем интеграции которые не нужны тонкому sidecar
default_integrations=False,
)

View file

@ -0,0 +1,77 @@
"""Тесты для `_drop_basic_auth_noise` (before_send-фильтр, glitchtip-noise).
Раньше форвардер слал КАЖДЫЙ basic_auth 401 (сканеры-боты, ломящиеся в закрытый
basic_auth'ом gendsgn.ru) individual-событием в GlitchTip — 3 738 issue, 2 019
различных заголовков (remote_ip раздувал кардинальность), топя содержательный
сигнал. `_drop_basic_auth_noise` дропает эти события НА ИСТОЧНИКЕ (before_send),
но НЕ должен трогать unhandled-ошибки самого форвардера (реальный баг скрипта
`capture_exception` без `event_type`-тега, аналог "500 должен пройти").
"""
from __future__ import annotations
import os
# DSN обязателен на module-level (`os.environ["GLITCHTIP_DSN"]`, fail-fast) — задаём
# ДО импорта forwarder.py, иначе импорт падает KeyError.
os.environ.setdefault("GLITCHTIP_DSN", "http://test@localhost/1")
from forwarder import _BASIC_AUTH_EVENT_TYPES, _drop_basic_auth_noise
def test_drops_individual_basic_auth_401() -> None:
"""emit_event() тегирует event_type=basic_auth_failed — 401 от бота-сканера,
не ошибка сервиса, должен быть отброшен (return None)."""
event = {
"tags": {"event_type": "basic_auth_failed", "remote_ip": "95.165.147.218"},
"message": "basic_auth 401 — GET /wp-admin/install.php from 95.165.147.218",
}
assert _drop_basic_auth_noise(event, {}) is None
def test_drops_basic_auth_storm_digest() -> None:
"""emit_digest() тегирует event_type=basic_auth_storm — тоже 401-класс, тоже
не ошибка сервиса, дропаем."""
event = {
"tags": {"event_type": "basic_auth_storm"},
"message": "basic_auth storm — 15 failed attempts in 60s",
}
assert _drop_basic_auth_noise(event, {}) is None
def test_drops_when_tags_serialized_as_list_of_tuples() -> None:
"""Некоторые версии sentry_sdk сериализуют tags как list[tuple[str, str]]
вместо dict фильтр обязан поддерживать обе формы."""
event = {"tags": [("event_type", "basic_auth_failed")]}
assert _drop_basic_auth_noise(event, {}) is None
def test_passes_through_forwarder_own_crash() -> None:
"""500-аналог: unhandled exception самого форвардера (capture_exception в
конце main(), реальный баг скрипта напр. PermissionError на STATE_FILE) не
несёт event_type-тег должен пройти НЕТРОНУТЫМ, не быть молча проглоченным
вместе с ботовым шумом."""
event = {
"level": "error",
"exception": {"values": [{"type": "PermissionError", "value": "denied"}]},
}
out = _drop_basic_auth_noise(dict(event), {})
assert out == event
def test_passes_through_event_without_tags() -> None:
event: dict = {"message": "something unrelated"}
out = _drop_basic_auth_noise(dict(event), {})
assert out == event
def test_passes_through_unrelated_tag_value() -> None:
event = {"tags": {"event_type": "something_else"}}
out = _drop_basic_auth_noise(dict(event), {})
assert out == event
def test_basic_auth_event_types_are_exactly_the_two_emitters_use() -> None:
"""Явная фиксация словаря — emit_event → basic_auth_failed,
emit_digest basic_auth_storm (см. forwarder.py)."""
assert _BASIC_AUTH_EVENT_TYPES == frozenset({"basic_auth_failed", "basic_auth_storm"})

View file

@ -66,17 +66,28 @@ logging.getLogger("httpx").setLevel(logging.WARNING)
# worker (in-app scheduler зовёт task-функции напрямую; compose = postgres/backend/
# frontend), отдельного broker нет → мониторить нечего.
if settings.glitchtip_dsn:
from app.observability.sentry_scrub import redact_telegram_bot_token
from app.observability.sentry_scrub import (
redact_telegram_bot_token,
stabilize_retry_error_fingerprint,
)
def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
"""Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) —
см. app/tgbot_main.py._before_send (идентичная композиция, тот же риск:
теперь этот процесс тоже держит TelegramClient в стек-фреймах при ошибке
sendMessage, а include_local_variables=False ниже первый рубеж защиты)."""
"""Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) +
RetryError fingerprint-стабилизация (glitchtip-noise) см.
app/tgbot_main.py._before_send (та же композиция без последнего шага,
тот бот geocoder не зовёт). PII/token тот же риск: теперь этот процесс
тоже держит TelegramClient в стек-фреймах при ошибке sendMessage, а
include_local_variables=False ниже первый рубеж защиты. RetryError
этот процесс обслуживает /api/v1/geocode/* (suggest/lookup/reverse),
которые ретраят Nominatim через tenacity; см.
sentry_scrub.stabilize_retry_error_fingerprint."""
scrubbed = scrub_pii_event(event, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
return redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type,return-value]
detokened = redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type]
if detokened is None:
return None
return stabilize_retry_error_fingerprint(detokened, hint) # type: ignore[arg-type,return-value]
sentry_sdk.init(
dsn=settings.glitchtip_dsn,

View file

@ -25,6 +25,7 @@ import re
from typing import Any
from sentry_sdk.types import Event
from tenacity import RetryError
_REDACTED = "[REDACTED]"
# Ключи consumer-PII (нижний регистр; сверка case-insensitive).
@ -76,6 +77,31 @@ _URL_SECRET_QUERY_RE = re.compile(
)
_URL_SECRET_QUERY_REPLACEMENT = r"\g<1>" + _REDACTED
# httpx error-message URL query stabilization (GlitchTip-noise review round 2,
# claim #1). `httpx.HTTPStatusError.__str__()` (raised by `response.raise_for_status()`)
# bakes the FULL request URL — INCLUDING query string — into the exception message:
# "Client error '403 Forbidden' for url 'https://nominatim.openstreetmap.org/
# search?q=<адрес>&format=json&limit=3'" (воспроизведено эмпирически: httpx.Response
# с params={"q": "<адрес>"} → raise_for_status() → именно этот текст). После
# app/services/geocoder.py `reraise=True` (стабилизирует ТИП исключения — RetryError
# → httpx.HTTPStatusError, см. комментарий у `_nominatim_lookup`) ИМЕННО этот текст
# становится GlitchTip title/value каждого события. `q=<адрес>` — переменная часть
# на КАЖДЫЙ вызов (ночной `geocode_missing_listings` — сотни разных адресов за
# прогон), значит per-address issue-explosion не устранён `reraise=True`, а просто
# переехал с RetryError на HTTPStatusError (тот же механизм: GlitchTip группирует по
# нестабильному тексту сообщения — это же подтверждают исходные 2 462 RetryError-issue,
# невозможные при группировке чисто по stacktrace/culprit).
#
# Отдельная регулярка от `_URL_SECRET_QUERY_RE` намеренно: та бьёт по ИМЕНИ известных
# secret-параметров (security-редактор), здесь — ЛЮБОЙ query string в httpx-стиле
# сообщении "for url '...'" (grouping-стабильность, не секретность — `q` не секрет).
# Режем query целиком (не только конкретные параметры) — host+path остаются
# стабильными для группировки, "for url '...'" — единственная форма, которую бьёт
# regex (не трогает произвольные строки с `?`, см. тест
# test_scrub_pii_event_httpx_url_query_stabilization_leaves_unrelated_text_untouched).
_HTTPX_ERROR_URL_QUERY_RE = re.compile(r"(for url '[^'?]*)\?[^']*(')")
_HTTPX_ERROR_URL_QUERY_REPLACEMENT = r"\g<1>?" + _REDACTED + r"\g<2>"
def _scrub(obj: Any) -> None:
"""Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place)."""
@ -90,32 +116,34 @@ def _scrub(obj: Any) -> None:
_scrub(item)
def _redact_url_secrets_inplace(obj: Any) -> None:
"""Рекурсивно (IN-PLACE, как `_scrub`) заменяет значения секрет-подобных
query-параметров (`?token=...`, `?proxy_key=...` и т.п.) на [REDACTED] в
КАЖДОЙ строке event не ключ-based: секрет утекает через httpx span
`url`/`query` data и через текст исключений (`str(exc)` httpx содержит полный
request URL), а не только через известные PII-поля формы. Мутирует dict/list
на месте (НЕ пересоздаёт структуру, в отличие от `_redact_strings`)
сохраняет identity верхнеуровневого `event`, на что опирается контракт
`scrub_pii_event`/`before_send` и существующие тесты (`out is event`).
def _regex_redact_inplace(obj: Any, pattern: re.Pattern[str], replacement: str) -> None:
"""Рекурсивно (IN-PLACE, как `_scrub`) прогоняет `pattern.sub(replacement, ...)`
по КАЖДОЙ строке event (не ключ-based) общий обход, переиспользуемый и для
URL-секретов (`_URL_SECRET_QUERY_RE`), и для стабилизации httpx error-message
URL (`_HTTPX_ERROR_URL_QUERY_RE`): в обоих случаях переменные данные утекают
через httpx span `url`/`query` data и через текст исключений (`str(exc)` httpx
содержит полный request URL), а не только через известные PII-поля формы.
Мутирует dict/list на месте (НЕ пересоздаёт структуру, в отличие от
`_redact_strings`) сохраняет identity верхнеуровневого `event`, на что
опирается контракт `scrub_pii_event`/`before_send` и существующие тесты
(`out is event`).
"""
if isinstance(obj, dict):
for key, value in obj.items():
if isinstance(value, str):
redacted = _URL_SECRET_QUERY_RE.sub(_URL_SECRET_QUERY_REPLACEMENT, value)
redacted = pattern.sub(replacement, value)
if redacted != value:
obj[key] = redacted
else:
_redact_url_secrets_inplace(value)
_regex_redact_inplace(value, pattern, replacement)
elif isinstance(obj, list):
for i, value in enumerate(obj):
if isinstance(value, str):
redacted = _URL_SECRET_QUERY_RE.sub(_URL_SECRET_QUERY_REPLACEMENT, value)
redacted = pattern.sub(replacement, value)
if redacted != value:
obj[i] = redacted
else:
_redact_url_secrets_inplace(value)
_regex_redact_inplace(value, pattern, replacement)
# tuple намеренно не обрабатываем: sentry_sdk event — это JSON-совместимая
# структура (dict/list/str/int/...), tuple там не встречается, а даже если бы
# встретился — он immutable, in-place правка невозможна (см. `_scrub`, тот же
@ -123,16 +151,21 @@ def _redact_url_secrets_inplace(obj: Any) -> None:
def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
"""Redact consumer-PII + URL query-string секретов из error event перед отправкой.
"""Redact consumer-PII + URL query-string секретов/nondeterministic-данных из
error event перед отправкой.
Композиция (обе in-place, сохраняют identity `event`): (1) ключ-based
Композиция (все in-place, сохраняют identity `event`): (1) ключ-based
dict-scrub consumer-PII полей формы (как раньше), (2) full-text regex-проход
по ВСЕМУ event, вырезающий значения секрет-подобных query-параметров в любой
строке (proxy/API-ключи в исходящих URL сторонних сервисов, напр. mobileproxy
changeip #security-audit). Второй шаг не завязан на конкретные ключи полей —
ловит секрет в frame locals, breadcrumb, exception message и т.д., где он может
оказаться независимо от include_local_variables/traces_sample_rate. Возвращает
event (не None).
changeip #security-audit), (3) full-text regex-проход, стабилизирующий httpx
error-message URL (`for url '...?...'`) убирает переменный query string
(адрес геокодинга и т.п.), от которого GlitchTip group-title плодит issue на
каждый вызов (GlitchTip-noise review round 2, claim #1; см. комментарий у
`_HTTPX_ERROR_URL_QUERY_RE`). (2) и (3) не завязаны на конкретные ключи полей
ловят секрет/переменные данные в frame locals, breadcrumb, exception message
и т.д., где они могут оказаться независимо от
include_local_variables/traces_sample_rate. Возвращает event (не None).
"""
if not isinstance(event, dict):
return event
@ -141,7 +174,8 @@ def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
_scrub(request.get("data"))
_scrub(event.get("extra"))
_scrub(event.get("contexts"))
_redact_url_secrets_inplace(event)
_regex_redact_inplace(event, _URL_SECRET_QUERY_RE, _URL_SECRET_QUERY_REPLACEMENT)
_regex_redact_inplace(event, _HTTPX_ERROR_URL_QUERY_RE, _HTTPX_ERROR_URL_QUERY_REPLACEMENT)
return event
@ -174,3 +208,61 @@ def redact_telegram_bot_token(event: Event, _hint: dict[str, Any]) -> Event | No
if not isinstance(event, dict):
return event
return _redact_strings(event) # type: ignore[return-value]
# ── RetryError fingerprint stabilization (GlitchTip noise-reduction) ────────
# tenacity.RetryError.__str__() тащит repr() последнего Future
# (`RetryError[<Future at 0x7f... state=finished raised HTTPStatusError>]`) —
# memory address объекта, случайный на каждый вызов процесса. Пока geocoder.py
# ретраил Nominatim без `reraise=True`, каждое исчерпание ретраев (Nominatim
# недоступен/rate-limit/403) улетало в GlitchTip как RetryError с этим
# нестабильным текстом → одна и та же причина плодила отдельный issue на КАЖДОЕ
# исчерпание (2 462 issue из 7 461 в трекере на момент фикса). `reraise=True`
# в app/services/geocoder.py устраняет RetryError на этом пути (пробрасывает
# реальное исключение) — но реальное исключение (httpx.HTTPStatusError) само
# несёт нестабильный текст (URL с адресом в query), поэтому group-стабильность
# для geocoder держит НЕ эта функция, а `_HTTPX_ERROR_URL_QUERY_RE` в
# `scrub_pii_event` (см. её комментарий, GlitchTip-noise review round 2 claim #1).
#
# Функция ниже — belt-and-suspenders для ЛЮБОГО кода, который ретраит через
# tenacity БЕЗ `reraise=True` (живой пример на момент фикса: `BaseScraper._http_get`
# в packages/scraper-kit — retry-декоратор НЕ reraise'ит, сознательно оставлен на
# этот фолбэк, а не на URL-стабилизацию: ретраятся listing detail URL БЕЗ query
# string — переменная часть там в ПУТИ (offer id), которую `_HTTPX_ERROR_URL_QUERY_RE`
# не покрывает; см. review round 2 claim #3). Схлопывает RetryError в ОДИН
# persistent issue per (culprit, класс исключения-причины) — culprit обязателен:
# БЕЗ него RetryError с одинаковым типом причины из НЕСВЯЗАННЫХ подсистем (напр.
# geocoder и scraper_kit одновременно ретраят httpx и оба ловят HTTPStatusError)
# схлопнулись бы в ОДИН issue — потеря сигнала хуже исходного шума (review round 2
# claim #2). Источник culprit — `event["logger"]`: sentry_sdk `LoggingIntegration`
# ставит его в имя logger'а (`logging.getLogger(__name__)`, напр.
# "app.services.geocoder" vs "scraper_kit.providers.yandex.detail") на КАЖДОМ
# `logger.exception(...)`/`logger.error(...)` — стабильно per-модуль, не зависит от
# конкретного запроса. Остальная часть fingerprint собрана ТОЛЬКО из стабильных
# данных — имя типа исключения-причины (небольшой фиксированный словарь вроде
# "HTTPStatusError"/"ConnectTimeout") — НИКАКИХ переменных данных запроса (адрес,
# IP, id объявления и т.п.), иначе проблема повторится в других терминах.
def stabilize_retry_error_fingerprint(event: Event, hint: dict[str, Any]) -> Event | None:
"""before_send-хук: схлопывает tenacity.RetryError в один persistent issue per
(источник, тип причины) РАЗНЫЕ источники (geocoder / scraper_kit / будущий
retry-код) НЕ схлопываются друг с другом, даже если тип причины совпадает.
Определяет тип exception через `hint["exc_info"]` (реальный объект
исключения, тот же контракт что sentry_sdk передаёт в before_send) не
парсит уже сериализованный event dict, надёжнее к изменениям формата SDK.
`isinstance` (не сравнение `type(...).__name__` со строкой) иначе любой
посторонний класс с совпадающим именем ложно матчился бы, а подкласс
`tenacity.RetryError` промахивался бы. Не-RetryError события возвращает без
изменений (OperationalError, алерты scraper sweep'ов и т.п. фильтр не трогает).
"""
if not isinstance(event, dict):
return event
exc_info = hint.get("exc_info") if isinstance(hint, dict) else None
exc_value = exc_info[1] if exc_info and len(exc_info) > 1 else None
if not isinstance(exc_value, RetryError):
return event
cause = exc_value.__cause__ or exc_value.__context__
cause_type = type(cause).__name__ if cause is not None else "Unknown"
culprit = event.get("logger") or event.get("transaction") or "unknown"
event["fingerprint"] = ["retry-exhausted", str(culprit), cause_type]
return event

View file

@ -44,7 +44,24 @@ if settings.glitchtip_dsn:
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from app.observability.sentry_scrub import scrub_pii_event
from app.observability.sentry_scrub import (
scrub_pii_event,
stabilize_retry_error_fingerprint,
)
def _before_send(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg]
"""PII-scrub + RetryError fingerprint-стабилизация (glitchtip-noise).
Этот процесс гоняет `geocode_missing_listings` (ночной batch, сотни
адресов за прогон) @retry-декорированные Nominatim-хелперы
(app/services/geocoder.py) на исчерпанных ретраях исторически плодили
по отдельному GlitchTip issue на КАЖДЫЙ адрес (RetryError.__str__()
тащит нестабильный repr() Future). См. sentry_scrub docstring.
"""
scrubbed = scrub_pii_event(event, hint)
if scrubbed is None:
return None
return stabilize_retry_error_fingerprint(scrubbed, hint)
sentry_sdk.init(
dsn=settings.glitchtip_dsn,
@ -52,7 +69,7 @@ if settings.glitchtip_dsn:
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
traces_sample_rate=0.0,
send_default_pii=False,
before_send=scrub_pii_event,
before_send=_before_send,
integrations=[
SqlalchemyIntegration(),
HttpxIntegration(),

View file

@ -742,7 +742,23 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
return oblast_fallback
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
# reraise=True (GlitchTip-noise fix): без него tenacity на исчерпанных ретраях
# бросает СВОЙ tenacity.RetryError, чей str() тащит repr() последнего Future
# (`<Future at 0x...>` — адрес объекта в памяти, разный на КАЖДЫЙ вызов). GlitchTip
# группирует по этому нестабильному тексту → одна и та же причина (Nominatim
# недоступен/rate-limit) плодила отдельный issue на каждое исчерпание ретраев
# (2 462 issue из 7 461 в трекере). reraise=True пробрасывает РЕАЛЬНОЕ исключение
# (httpx.HTTPStatusError/TimeoutException) — стабильный ТИП+стек. НО httpx.HTTPStatusError
# сам несёт нестабильный ТЕКСТ (str() содержит полный request URL, включая query
# string с адресом — `for url '...search?q=<адрес>&...'`) — group-стабильность на
# ЭТОМ пути держит `_HTTPX_ERROR_URL_QUERY_RE` в app/observability/sentry_scrub.py
# (`scrub_pii_event`, часть before_send-композиции обоих entrypoint), которая режет
# query string из httpx-style "for url '...'" сообщений (GlitchTip-noise review
# round 2, claim #1 — reraise=True сам по себе НЕ закрывает per-address explosion).
# Отдельно — `stabilize_retry_error_fingerprint` (та же sentry_scrub.py) на случай
# если голый tenacity.RetryError (не httpx-исключение) всплывёт откуда-то ещё
# (belt-and-suspenders для retry-кода без reraise=True, напр. scraper_kit).
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True)
async def _nominatim_lookup(address: str, city_hint: str | None = None) -> GeocodeResult | None:
"""OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
@ -941,7 +957,8 @@ async def _nominatim_query_city_aware(
return _dedupe_nominatim_items(ekb_data, bare_data)[:limit]
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4))
# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум).
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4), reraise=True)
async def _nominatim_suggest(
query: str, limit: int = 8, city_hint: str | None = None
) -> list[GeocodeSuggestion]:
@ -1987,7 +2004,8 @@ def _format_reverse_address(addr: dict) -> str | None:
return ", ".join(parts)
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум).
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True)
async def _nominatim_reverse(lat: float, lon: float) -> ReverseGeocodeResult | None:
"""Nominatim /reverse → ReverseGeocodeResult с snapped coords из item.lat/lon.

View file

@ -15,6 +15,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
from app.observability.sentry_scrub import (
redact_telegram_bot_token,
scrub_pii_event,
stabilize_retry_error_fingerprint,
)
@ -259,3 +260,216 @@ def test_composed_before_send_scrubs_pii_and_token_together() -> None:
assert out["request"]["data"]["client_phone"] == "[REDACTED]"
frame_url = out["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"]["url"]
assert "8663867262:AAExampleSecretPartAbCdEf123" not in frame_url
# ── RetryError fingerprint stabilization (glitchtip-noise, #<GlitchTip triage>) ─
#
# tenacity.RetryError.__str__() тащит repr() последнего Future — memory address
# объекта, случайный на каждый вызов процесса. Раньше (без `reraise=True` в
# app/services/geocoder.py) каждое исчерпание ретраев Nominatim улетало в
# GlitchTip как RetryError с этим нестабильным текстом → одна и та же причина
# плодила отдельный issue на КАЖДОЕ исчерпание (2 462 issue из 7 461 в трекере).
# Тесты ниже бьют по `stabilize_retry_error_fingerprint` напрямую — belt-and-
# suspenders слой для retry-кода БЕЗ reraise=True (напр. scraper_kit —
# geocoder.py `reraise=True` устраняет RetryError на своём пути, но остаётся
# фолбэком общего назначения), и по контракту before_send: 401-класс (RetryError)
# схлопывается ПО ИСТОЧНИКУ (не глобально — review round 2 claim #2: разные
# подсистемы с совпавшим типом причины НЕ сливаются), содержательные категории
# (500-подобный generic Exception, OperationalError) проходят НЕТРОНУТЫМИ.
from tenacity import RetryError # noqa: E402
def _hint_for(exc: BaseException) -> dict:
"""Строит hint в форме, которую sentry_sdk реально передаёт в before_send —
`exc_info = (type, value, traceback)` (contract stabilize_retry_error_fingerprint
полагается именно на эту форму, не на уже сериализованный event dict)."""
return {"exc_info": (type(exc), exc, exc.__traceback__)}
def _raise_retry_error_from(cause: BaseException) -> RetryError:
try:
raise cause
except type(cause) as caught:
try:
raise RetryError(None) from caught
except RetryError as retry_exc:
return retry_exc
def test_stabilize_retry_error_sets_stable_fingerprint() -> None:
"""RetryError коллапсится в persistent issue по (culprit, имени типа причины) —
НЕ по нестабильному str(RetryError) (repr() Future с memory address). Без
`event["logger"]` (напр. capture_exception без LoggingIntegration) culprit
падает на явный "unknown", а не пропадает из fingerprint молча."""
exc = _raise_retry_error_from(TimeoutError("Nominatim timed out"))
out = stabilize_retry_error_fingerprint({"level": "error"}, _hint_for(exc))
assert out is not None
assert out["fingerprint"] == ["retry-exhausted", "unknown", "TimeoutError"]
def test_stabilize_retry_error_fingerprint_has_no_variable_data() -> None:
"""Fingerprint не должен содержать IP/id объявления/адрес и т.п. — только
culprit (logger-имя модуля) + фиксированное имя типа исключения-причины
(маленький словарь: HTTPStatusError/ConnectTimeout/TimeoutError/...)."""
exc = _raise_retry_error_from(
ValueError("addr='ул. Ленина 1', ip=95.165.147.218, listing_id=12345")
)
out = stabilize_retry_error_fingerprint({}, _hint_for(exc))
assert out is not None
fingerprint_text = " ".join(out["fingerprint"])
assert "95.165.147.218" not in fingerprint_text
assert "12345" not in fingerprint_text
assert out["fingerprint"] == ["retry-exhausted", "unknown", "ValueError"]
def test_stabilize_retry_error_fingerprint_uses_logger_as_culprit() -> None:
"""`event["logger"]` (sentry_sdk LoggingIntegration ставит его = имя модуля,
вызвавшего logger.exception/.error) идёт в fingerprint как culprit стабильно
per-модуль, не переменные данные запроса."""
exc = _raise_retry_error_from(TimeoutError("timed out"))
out = stabilize_retry_error_fingerprint({"logger": "app.services.geocoder"}, _hint_for(exc))
assert out is not None
assert out["fingerprint"] == ["retry-exhausted", "app.services.geocoder", "TimeoutError"]
def test_stabilize_retry_error_fingerprint_does_not_collapse_unrelated_subsystems() -> None:
"""Review round 2 claim #2: RetryError с ОДИНАКОВЫМ типом причины из
НЕСВЯЗАННЫХ подсистем (geocoder vs scraper_kit) НЕ должны схлопнуться в один
issue разные проблемы не сливаются, даже если типы причины совпали."""
exc_geocoder = _raise_retry_error_from(TimeoutError("nominatim timed out"))
exc_scraper = _raise_retry_error_from(TimeoutError("yandex detail timed out"))
out_geocoder = stabilize_retry_error_fingerprint(
{"logger": "app.services.geocoder"}, _hint_for(exc_geocoder)
)
out_scraper = stabilize_retry_error_fingerprint(
{"logger": "scraper_kit.providers.yandex.detail"}, _hint_for(exc_scraper)
)
assert out_geocoder is not None
assert out_scraper is not None
assert out_geocoder["fingerprint"] != out_scraper["fingerprint"]
class _DecoyRetryError(Exception):
"""Посторонний класс, СЛУЧАЙНО названный так же, как tenacity.RetryError —
но НЕ его подкласс. Строковое сравнение имён (старый баг, review round 2
claim #4) ложно матчило бы такое; isinstance — нет."""
_DecoyRetryError.__name__ = "RetryError" # type: ignore[misc]
def test_stabilize_retry_error_ignores_lookalike_class_by_name() -> None:
"""type(exc).__name__ == "RetryError" НЕ должно быть достаточно — только
реальный tenacity.RetryError (или его подкласс) триггерит fingerprint-хук."""
exc = _DecoyRetryError("unrelated exception, same class __name__ by accident")
event = {"level": "error"}
out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc))
assert out == event
assert "fingerprint" not in out
def test_stabilize_retry_error_leaves_operational_error_untouched() -> None:
"""401-аналог задачи: OperationalError — содержательная категория (реальный
сбой БД), фильтр её НЕ трогает (см. задачу #4 — не выключить сигнал вместе с
шумом)."""
from sqlalchemy.exc import OperationalError
exc = OperationalError("SELECT 1", {}, Exception("connection refused"))
event = {"level": "error", "message": "db connection failed"}
out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc))
assert out == event
assert "fingerprint" not in out
def test_stabilize_retry_error_leaves_generic_exception_untouched() -> None:
"""500-аналог задачи: обычное необработанное исключение (не RetryError)
проходит без изменений."""
exc = RuntimeError("scraper city-sweep failed")
event = {"level": "error"}
out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc))
assert out == event
assert "fingerprint" not in out
def test_stabilize_retry_error_no_exc_info_untouched() -> None:
"""capture_message-based события (нет exc_info) — фильтр не трогает, напр.
scrape_runs.py consecutive-failure алерты (content-ful, должны доходить)."""
event = {"level": "error", "message": "Scraper source 'avito' has 5 consecutive failed runs"}
out = stabilize_retry_error_fingerprint(dict(event), {})
assert out == event
def test_stabilize_retry_error_handles_non_dict_event() -> None:
assert stabilize_retry_error_fingerprint(None, {}) is None # type: ignore[arg-type]
# ── httpx error-message URL query stabilization (glitchtip-noise review round 2,
# claim #1) ────────────────────────────────────────────────────────────────
#
# `httpx.HTTPStatusError.__str__()` (raised by `response.raise_for_status()`) bakes
# the FULL request URL — including query string — into the exception message.
# Воспроизведено эмпирически (httpx.Response(403, request=...).raise_for_status()):
# "Client error '403 Forbidden' for url 'https://nominatim.openstreetmap.org/
# search?q=<адрес>&format=json&limit=3'". После `reraise=True` в geocoder.py
# (устраняет RetryError, но НЕ этот текст) именно ЭТА строка становится GlitchTip
# title/value — переменный `q=<адрес>` на каждый вызов воспроизводит тот же
# per-address issue-explosion, который reraise=True должен был устранить, просто
# сменивший класс исключения (RetryError → HTTPStatusError). Тесты бьют по
# `scrub_pii_event` напрямую (композиция, реально применяемая в before_send).
_NOMINATIM_403_TEMPLATE = (
"Client error '403 Forbidden' for url "
"'https://nominatim.openstreetmap.org/search?q={query}&format=json&limit=3'\n"
"For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403"
)
def _httpx_error_event(message: str) -> dict:
return {"exception": {"values": [{"type": "HTTPStatusError", "value": message}]}}
def test_scrub_pii_event_stabilizes_httpx_error_url_query() -> None:
"""Query string режется целиком из httpx-style 'for url' сообщения — host+path
остаются стабильными для группировки."""
encoded_ekb = "%D0%95%D0%BA%D0%B0%D1%82%D0%B5%D1%80%D0%B8%D0%BD%D0%B1%D1%83%D1%80%D0%B3"
event = _httpx_error_event(_NOMINATIM_403_TEMPLATE.format(query=encoded_ekb))
out = scrub_pii_event(event, {})
assert out is not None
value = out["exception"]["values"][0]["value"]
assert "search?[REDACTED]'" in value
assert "%D0%95" not in value
assert "nominatim.openstreetmap.org/search" in value # host+path сохранены
def test_scrub_pii_event_httpx_url_query_stabilization_collapses_different_addresses() -> None:
"""Два РАЗНЫХ адреса (переменная часть query) после редактора дают
ИДЕНТИЧНЫЙ текст сообщения GlitchTip group-title больше не плодит issue
на каждый адрес (review round 2 claim #1)."""
event_a = _httpx_error_event(_NOMINATIM_403_TEMPLATE.format(query="ул.+Ленина+1"))
event_b = _httpx_error_event(_NOMINATIM_403_TEMPLATE.format(query="ул.+Мира+42%2C+кв.+5"))
out_a = scrub_pii_event(event_a, {})
out_b = scrub_pii_event(event_b, {})
assert out_a is not None
assert out_b is not None
assert out_a["exception"]["values"][0]["value"] == out_b["exception"]["values"][0]["value"]
def test_scrub_pii_event_httpx_url_without_query_untouched() -> None:
"""URL без query string (напр. scraper detail page — переменная часть в
ПУТИ, не в query) остаётся нетронутым regex матчит только `?...`."""
message = "Client error '404 Not Found' for url 'https://realty.yandex.ru/offer/12345/'"
event = _httpx_error_event(message)
out = scrub_pii_event(event, {})
assert out is not None
assert out["exception"]["values"][0]["value"] == message
def test_scrub_pii_event_httpx_url_query_stabilization_leaves_unrelated_text_untouched() -> None:
"""Regex бьёт только по 'for url \\'...?...\\'' — произвольный текст с `?` и
кавычками не должен ложно матчиться."""
benign = "Вопрос: 'что такое ЖК \"Солнечный\"?' — уточните адрес"
event = {"extra": {"note": benign}}
out = scrub_pii_event(event, {})
assert out is not None
assert out["extra"]["note"] == benign

View file

@ -0,0 +1,154 @@
/**
* AccuracyV3 «Точность»: KPI-плитки + таблица сверок прогноза с фактом
* сделки (макет v3, ~строки 231-283, id="accuracy"). Серверный компонент.
*
* KPI-значения из `ACCURACY_PLACEHOLDER` (marketing-v3.ts), не хардкод: это
* плейсхолдерные маркетинговые величины, гейтящиеся `noindex` до подтверждения
* замером (см. шапку файла).
*
* Таблица сверок `PROOF_ROWS_PLACEHOLDER`. В самом макете под неё не было
* заполнено ни одной реальной строки (только `{{ r.addr }}`-шаблон), поэтому
* строки витринный пример формата отчёта, а не факт. Ровно поэтому подпись
* под таблицей ЗАМЕНЕНА: в макете там было «ДАННЫЕ ОБЕЗЛИЧЕНЫ · » фраза,
* прямо утверждающая, что за строками стоят настоящие обезличенные сделки.
* Мы этого утверждать не можем (строки придуманы для примера), поэтому подпись
* честно называет таблицу иллюстрацией формата, а вторую половину исходной
* фразы («"выше средней цены" разница со средней ценой сделки по похожим
* квартирам в районе») сохраняет она объясняет, ЧТО значит колонка, а не
* заявляет что-то о происхождении данных.
*
* Таблица размечена ARIA role="table/row/columnheader/cell" (а не <table>):
* на мобильном брейке (см. landing-v3.module.css) она превращается в карточки
* без горизонтального скролла, а implicit table-роли всё равно снимаются
* Safari при смене display у настоящей <table> тот же квирк, что уже описан
* в HowItWorks.tsx для списков, только для таблиц его обходят явные роли, а
* не CSS-костыль.
*/
import {
ACCURACY_PERIOD_LABEL,
ACCURACY_PLACEHOLDER,
PROOF_ROWS_PLACEHOLDER,
} from "../../marketing-v3";
import styles from "../../landing-v3.module.css";
const KPI_TILES = [
{
value: ACCURACY_PLACEHOLDER.priceError,
label: "средняя ошибка по цене",
note: ACCURACY_PLACEHOLDER.priceErrorNote,
},
{
value: ACCURACY_PLACEHOLDER.daysError,
label: "точность по сроку продажи",
note: ACCURACY_PLACEHOLDER.daysErrorNote,
},
{
value: ACCURACY_PLACEHOLDER.coverage,
label: "сделок попали в диапазон",
note: ACCURACY_PLACEHOLDER.coverageNote,
},
] as const;
export function AccuracyV3() {
return (
<section
id="accuracy"
aria-labelledby="accuracy-v3-title"
className={styles.accSection}
>
<div className={styles.accContainer}>
<div className={styles.accHead}>
<div className={styles.accEyebrow} aria-hidden="true">
ПРОГНОЗ ПРОТИВ ФАКТА
</div>
<h2 id="accuracy-v3-title" className={styles.accTitle}>
Мы сверяем каждый прогноз с реальной сделкой
</h2>
<p className={styles.accLead}>
{`Когда квартира продана, мы возвращаемся к своему прогнозу и считаем расхождение. Вот сводка за ${ACCURACY_PERIOD_LABEL}.`}
</p>
</div>
<ul className={styles.accKpiGrid} role="list">
{KPI_TILES.map((tile) => (
<li key={tile.label} className={styles.accKpiTile}>
<div className={styles.accKpiValue}>{tile.value}</div>
<div className={styles.accKpiLabel}>{tile.label}</div>
<div className={styles.accKpiNote}>{tile.note}</div>
</li>
))}
<li className={`${styles.accKpiTile} ${styles.accKpiTileDark}`}>
<div className={styles.accKpiValue}>
{ACCURACY_PLACEHOLDER.checks}
</div>
<div className={styles.accKpiLabel}>проверок за год</div>
<div className={styles.accKpiNoteDark}>
{ACCURACY_PLACEHOLDER.checksNote}
</div>
</li>
</ul>
<div
className={styles.accTable}
role="table"
aria-label="Сверка прогноза «Меры» с фактом сделки"
>
<div className={styles.accTableHead} role="row">
<span role="columnheader">Объект</span>
<span role="columnheader">Мера сказала</span>
<span role="columnheader">Факт сделки</span>
<span role="columnheader">Расхождение</span>
<span role="columnheader" className={styles.accTableHeadCellRight}>
Выше средней цены по району
</span>
</div>
{PROOF_ROWS_PLACEHOLDER.map((row) => (
<div key={row.addr} className={styles.accRow} role="row">
<div role="cell">
<span className={styles.accObjectAddr}>{row.addr}</span>
<span className={styles.accObjectMeta}>{row.meta}</span>
</div>
<div
role="cell"
className={styles.accCellSaid}
data-label="Мера сказала"
>
{row.said}
</div>
<div
role="cell"
className={styles.accCellFact}
data-label="Факт сделки"
>
{row.fact}
</div>
<div
role="cell"
className={styles.accCellErr}
data-label="Расхождение"
>
{row.err}
</div>
<div
role="cell"
className={styles.accCellGain}
data-label="Выше средней цены по району"
>
<div className={styles.accGainStack}>
<div className={styles.accGainValue}>{row.gain}</div>
<div className={styles.accGainPct}>{row.gainPct}</div>
</div>
</div>
</div>
))}
</div>
<p className={styles.accFootnote}>
Таблица иллюстрирует формат отчёта, это не список конкретных сделок.
«Выше средней цены» разница со средней ценой сделки по похожим
квартирам в районе.
</p>
</div>
</section>
);
}

View file

@ -0,0 +1,140 @@
"use client";
/**
* CityPicker выбор города в шапке лэндинга v3 (макет ~ строки 47-73).
*
* Презентационный клиентский остров: сегодня публичный расчёт выключен
* (`PUBLIC_ESTIMATE_ENABLED = false` в content.ts), поэтому выбранный здесь
* город никуда не отправляется состояние живёт только в этом компоненте и
* показывает посетителю, что сервис знает больше одного города области.
* Когда форма расчёта откроется, сюда нужно будет добавить проброс выбора
* наружу (проп/контекст) в этом заходе scope ограничен шапкой/подвалом/
* sticky-баром, самой формы здесь нет.
*
* Список городов и деление на «полное покрытие» / «данных меньше» берутся из
* `OBLAST_CITIES` / `PRIMARY_CITY` (content.ts, тот же реестр, что честно
* объясняет разницу в покрытии в Hero.tsx). Макет показывал числовые «веса»
* по городам (тысячи объявлений) мы их не повторяем: таких цифр в коде нет,
* а гадать нельзя (правило честности в content.ts).
*/
import { useEffect, useId, useRef, useState } from "react";
import { DEFAULT_CITY, OBLAST_CITIES } from "@/lib/city-registry";
import { PRIMARY_CITY, REGION_NAME } from "../../content";
import styles from "../../landing-v3.module.css";
export function CityPicker() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [city, setCity] = useState<string>(DEFAULT_CITY.label);
const wrapRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const panelId = useId();
// Клик вне панели — закрыть. Тот же паттерн, что components/auth/UserMenu.tsx.
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
// Escape — закрыть и вернуть фокус на кнопку.
useEffect(() => {
if (!open) return;
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") {
setOpen(false);
buttonRef.current?.focus();
}
}
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [open]);
const normalizedQuery = query.trim().toLowerCase();
const filtered = normalizedQuery
? OBLAST_CITIES.filter((c) =>
c.label.toLowerCase().includes(normalizedQuery),
)
: OBLAST_CITIES;
return (
<div className={styles.hdrCityWrap} ref={wrapRef}>
<button
ref={buttonRef}
type="button"
className={styles.hdrCityBtn}
onClick={() => setOpen((v) => !v)}
aria-haspopup="true"
aria-expanded={open}
aria-controls={panelId}
>
<span className={styles.hdrCityDot} aria-hidden="true" />
<span>{city}</span>
<span className={styles.hdrCityCaret} aria-hidden="true">
{open ? "▲" : "▼"}
</span>
</button>
{open ? (
<div
id={panelId}
className={styles.hdrCityPanel}
aria-label="Выбор города"
>
<div className={styles.hdrCitySearchRow}>
<input
type="text"
className={styles.hdrCityInput}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Поиск города"
aria-label="Поиск города"
/>
</div>
<div className={styles.hdrCityList}>
{filtered.length > 0 ? (
filtered.map((c) => (
<button
key={c.id}
type="button"
className={styles.hdrCityItem}
data-active={c.label === city ? "true" : undefined}
onClick={() => {
setCity(c.label);
setOpen(false);
setQuery("");
buttonRef.current?.focus();
}}
>
<span className={styles.hdrCityItemName}>{c.label}</span>
<span className={styles.hdrCityItemNote}>
{c.label === PRIMARY_CITY
? "Полное покрытие"
: "Данных меньше"}
</span>
</button>
))
) : (
<p className={styles.hdrCityEmpty}>
Города нет в списке. Мы работаем там, где хватает данных по
сделкам напишите нам, и добавим.
</p>
)}
</div>
<div className={styles.hdrCityFooter}>
ДАННЫЕ ПО {OBLAST_CITIES.length} ГОРОДАМ:{" "}
{REGION_NAME.toUpperCase()}
</div>
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,52 @@
/**
* CostOfErrorV3 «Цена ошибки»: три плитки про стоимость неверной цены
* (макет v3, ~строки 376-400, без id). Серверный компонент.
*
* Значения и подписи из `COST_OF_ERROR_PLACEHOLDER` (marketing-v3.ts),
* заголовок цитирует `SERVICE_PRICE_RUB` (content.ts) тот же источник
* правды, что и у цены отчёта во всём остальном лэндинге, а не отдельный
* литерал «150».
*/
import { SERVICE_PRICE_RUB } from "../../content";
import { COST_OF_ERROR_PLACEHOLDER } from "../../marketing-v3";
import styles from "../../landing-v3.module.css";
export function CostOfErrorV3() {
return (
<section aria-labelledby="cost-v3-title" className={styles.costSection}>
<div className={styles.costContainer}>
<div className={styles.costHead}>
<div className={styles.costEyebrow} aria-hidden="true">
ПОЧЕМУ ЭТО ВАЖНО
</div>
<h2 id="cost-v3-title" className={styles.costTitle}>
{`${SERVICE_PRICE_RUB} ₽ против двух месяцев вашей жизни`}
</h2>
</div>
<ul className={styles.costGrid} role="list">
{COST_OF_ERROR_PLACEHOLDER.map((tile, index) => {
const dark = index === COST_OF_ERROR_PLACEHOLDER.length - 1;
return (
<li
key={tile.title}
className={
dark
? `${styles.costTile} ${styles.costTileDark}`
: styles.costTile
}
>
<div className={styles.costValue}>{tile.value}</div>
<div className={styles.costItemTitle}>{tile.title}</div>
<p className={dark ? styles.costTextDark : styles.costText}>
{tile.text}
</p>
</li>
);
})}
</ul>
</div>
</section>
);
}

View file

@ -0,0 +1,145 @@
/**
* FooterV3 подвал лэндинга v3 (макет ~ строки 470-516): четыре колонки +
* нижняя строка с реквизитами.
*
* РЕКВИЗИТЫ И ДОКУМЕНТЫ НЕ из макета (там маска «ООО «МЕРА» · ИНН
* 66700XXXXX», это не настоящие данные), а из content.ts: `LEGAL_ENTITY`,
* `SUPPORT_EMAIL`, `OFFER_PATH`, `REFUND_PATH`, `PRIVACY_PATH` тот же набор,
* что уже рендерит `_components/SiteFooter.tsx` (подвал v1/v2), только в
* другой сетке. Документы включают Политику возврата и почту поддержки:
* макет их не показывал, а это обязательные элементы платного контура
* (маркировка эквайера, идентифицируемость оператора по 152-ФЗ).
*
* Способы оплаты (МИР / СБП / VISA / MASTERCARD в макете) НЕ рендерятся:
* приём платежей не подключён (см. TODO у нижней строки) заявлять способы
* оплаты, которых нет, подпадает под ст. 5 ФЗ «О рекламе».
*
* «Статьи» в «Разделах» неактивны тем же образом, что в шапке (HeaderV3):
* раздела нет в коде. «Проверить квартиру» ведёт на #check (форма в Hero на
* этой же странице) отдельного маршрута отчёта не существует. Оба
* внутренних якоря через `/mera-public#...`: подвал рендерится и на
* оферте/возврате/ПДн, где голый `#check` никуда не долистал бы.
*/
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,
SUPPORT_TELEGRAM_URL,
} from "../../content";
import styles from "../../landing-v3.module.css";
export function FooterV3() {
const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL);
const year = new Date().getFullYear();
return (
<footer className={styles.ftrRoot}>
<div className={styles.ftrGrid}>
<div className={styles.ftrBrandCol}>
<div className={styles.ftrBrand}>
<span className={styles.ftrBrandDot} aria-hidden="true" />
<span className={styles.ftrBrandName}>МЕРА</span>
</div>
<p className={styles.ftrBrandText}>
Оценка квартир на вторичном рынке по сделкам и объявлениям.{" "}
{REGION_NAME}.
</p>
</div>
<div>
<p className={styles.ftrColTitle}>Разделы</p>
<div className={styles.ftrLinks}>
<Link href="/mera-public" className={styles.ftrLink}>
Главная
</Link>
<Link href="/mera-public#check" className={styles.ftrLink}>
Проверить квартиру
</Link>
<span
className={styles.ftrLinkDisabled}
aria-disabled="true"
title="Раздел скоро откроется"
>
Статьи
</span>
<Link href="/" className={styles.ftrLink}>
МЕРА для бизнеса
</Link>
</div>
</div>
<div>
<p className={styles.ftrColTitle}>Связаться</p>
<p className={styles.ftrText}>
Почта:{" "}
<a
className={styles.ftrInlineLink}
href={`mailto:${SUPPORT_EMAIL}`}
>
{SUPPORT_EMAIL}
</a>
</p>
{telegramHref ? (
<p className={styles.ftrText}>
Поддержка в Telegram:{" "}
<a
className={styles.ftrInlineLink}
href={telegramHref}
target="_blank"
rel="noreferrer"
>
{SUPPORT_TELEGRAM_LABEL}
</a>
</p>
) : null}
</div>
<div>
<p className={styles.ftrColTitle}>Документы</p>
<div className={styles.ftrLinks}>
<Link href={OFFER_PATH} className={styles.ftrLink}>
Публичная оферта
</Link>
<Link href={REFUND_PATH} className={styles.ftrLink}>
Политика возврата
</Link>
<Link href={PRIVACY_PATH} className={styles.ftrLink}>
Обработка персональных данных
</Link>
</div>
</div>
</div>
<div className={styles.ftrBottom}>
<div className={styles.ftrBottomReqs}>
<span>© {year} МЕРА</span>
{LEGAL_ENTITY ? (
<span>
{LEGAL_ENTITY.name}, ИНН {LEGAL_ENTITY.inn}, ОГРН{" "}
{LEGAL_ENTITY.ogrn}, {LEGAL_ENTITY.address}
</span>
) : null}
<span>
Оценка носит информационный характер и не является офертой или
отчётом об оценке.
</span>
</div>
{/* TODO(платёжный контур): бейджи способов оплаты (МИР / СБП / VISA /
MASTERCARD, как в макете) рендерить только когда приём оплаты
будет подключён backend'ом. Сегодня `PUBLIC_ESTIMATE_ENABLED =
false`, оплаты нет вовсе, а заявлять способы оплаты, которых нет,
нарушение ст. 5 ФЗ «О рекламе». Гейт G6, см.
mera-b2c-paid-flow-decision.md. */}
</div>
</footer>
);
}

View file

@ -0,0 +1,303 @@
"use client";
/**
* 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`).
*
* Раздел «шаг 1 из 2» / полоска прогресса / ярлык рынка (`{{ cityMarket }}`,
* `{{ stepLabel }}`) из макета сюда сознательно НЕ перенесены: они требуют
* состояния города, которым владеет шапка сайта вне участка этой задачи
* («первый экран: герой и карточка»), и вносили бы числовые литералы (33%,
* 66%, 100%), которых нет ни в макете-константах, ни в `marketing-v3.ts`.
*
* Ветка результата ПОСТРОЕНА (`FreeResultV3.tsx`), но не подключена см.
* TODO у `PUBLIC_ESTIMATE_ENABLED` ниже и JSDoc в `FreeResultV3.tsx`.
*/
import { useId, useRef, useState } from "react";
import type { FormEvent } from "react";
import { PRIMARY_CITY, PUBLIC_ESTIMATE_ENABLED } from "../../content";
import styles from "../../landing-v3.module.css";
/** Три состояния квартиры — дословно из макета (строка 734 исходника). */
const CONDITIONS = ["Требует ремонта", "Хорошее", "Отличное"] as const;
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[] };
const NONE: Feedback = { kind: "none" };
export function FreeCheckCard() {
const addressId = useId();
const areaId = useId();
const roomsId = useId();
const floorId = useId();
const conditionLabelId = useId();
const feedbackId = useId();
const [address, setAddress] = useState("");
const [area, setArea] = useState("");
const [rooms, setRooms] = useState("");
const [floor, setFloor] = useState("");
const [condition, setCondition] = useState<Condition | "">("");
const [feedback, setFeedback] = useState<Feedback>(NONE);
const addressRef = useRef<HTMLInputElement>(null);
const areaRef = useRef<HTMLInputElement>(null);
const roomsRef = useRef<HTMLInputElement>(null);
const floorRef = useRef<HTMLInputElement>(null);
const conditionGroupRef = useRef<HTMLDivElement>(null);
function clearFeedback() {
setFeedback(NONE);
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmedAddress = address.trim();
const trimmedArea = area.trim();
const trimmedRooms = rooms.trim();
const trimmedFloor = floor.trim();
if (!trimmedAddress) {
setFeedback({
kind: "error",
field: "address",
text: "Укажите адрес — улицу и номер дома.",
});
addressRef.current?.focus();
return;
}
if (!trimmedArea) {
setFeedback({
kind: "error",
field: "area",
text: "Укажите площадь квартиры в квадратных метрах.",
});
areaRef.current?.focus();
return;
}
if (!trimmedRooms) {
setFeedback({
kind: "error",
field: "rooms",
text: "Укажите количество комнат.",
});
roomsRef.current?.focus();
return;
}
if (!trimmedFloor) {
setFeedback({
kind: "error",
field: "floor",
text: "Укажите этаж, например «7/16».",
});
floorRef.current?.focus();
return;
}
if (!condition) {
setFeedback({
kind: "error",
field: "condition",
text: "Выберите состояние квартиры.",
});
conditionGroupRef.current?.focus();
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}». Но онлайн-расчёт ещё не запущен — сервис работает по доступу для партнёров.`,
"Проверка появится на этой же странице. Если вопрос срочный — напишите нам в поддержку, ссылка в подвале.",
],
});
}
const errorField = feedback.kind === "error" ? feedback.field : null;
const describedBy = feedback.kind === "none" ? undefined : feedbackId;
return (
<div className={styles.heroCard}>
<div className={styles.heroCardHeader}>
<span className={styles.heroCardHeaderTitle}>Бесплатная проверка</span>
</div>
<div className={styles.heroCardBody}>
<form className={styles.heroForm} onSubmit={handleSubmit} noValidate>
<label className={styles.heroField} htmlFor={addressId}>
<span className={styles.heroFieldLabel}>Адрес</span>
<input
id={addressId}
ref={addressRef}
className={`${styles.heroInput} ${errorField === "address" ? styles.heroInputInvalid : ""}`}
type="text"
name="address"
autoComplete="street-address"
enterKeyHint="next"
placeholder={`г. ${PRIMARY_CITY}, улица, дом`}
value={address}
onChange={(event) => {
setAddress(event.target.value);
clearFeedback();
}}
aria-invalid={errorField === "address" || undefined}
aria-describedby={errorField === "address" ? describedBy : undefined}
/>
</label>
<div className={styles.heroFieldRow}>
<label className={styles.heroField} htmlFor={areaId}>
<span className={styles.heroFieldLabel}>М²</span>
<input
id={areaId}
ref={areaRef}
className={`${styles.heroInput} ${errorField === "area" ? styles.heroInputInvalid : ""}`}
type="text"
inputMode="decimal"
name="area"
placeholder="54"
value={area}
onChange={(event) => {
setArea(event.target.value);
clearFeedback();
}}
aria-invalid={errorField === "area" || undefined}
aria-describedby={errorField === "area" ? describedBy : undefined}
/>
</label>
<label className={styles.heroField} htmlFor={roomsId}>
<span className={styles.heroFieldLabel}>Комнат</span>
<input
id={roomsId}
ref={roomsRef}
className={`${styles.heroInput} ${errorField === "rooms" ? styles.heroInputInvalid : ""}`}
type="text"
inputMode="numeric"
name="rooms"
placeholder="2"
value={rooms}
onChange={(event) => {
setRooms(event.target.value);
clearFeedback();
}}
aria-invalid={errorField === "rooms" || undefined}
aria-describedby={errorField === "rooms" ? describedBy : undefined}
/>
</label>
<label className={styles.heroField} htmlFor={floorId}>
<span className={styles.heroFieldLabel}>Этаж</span>
<input
id={floorId}
ref={floorRef}
className={`${styles.heroInput} ${errorField === "floor" ? styles.heroInputInvalid : ""}`}
type="text"
name="floor"
placeholder="7/16"
value={floor}
onChange={(event) => {
setFloor(event.target.value);
clearFeedback();
}}
aria-invalid={errorField === "floor" || undefined}
aria-describedby={errorField === "floor" ? describedBy : undefined}
/>
</label>
</div>
<div className={styles.heroField}>
<span className={styles.heroFieldLabel} id={conditionLabelId}>
Состояние
</span>
<div
ref={conditionGroupRef}
className={styles.heroConditionGroup}
role="group"
aria-labelledby={conditionLabelId}
tabIndex={-1}
>
{CONDITIONS.map((name) => (
<button
key={name}
type="button"
className={`${styles.heroConditionBtn} ${condition === name ? styles.heroConditionBtnActive : ""}`}
aria-pressed={condition === name}
onClick={() => {
setCondition(name);
clearFeedback();
}}
>
{name}
</button>
))}
</div>
</div>
<button type="submit" className={styles.heroSubmitBtn}>
Показать данные по моему дому
</button>
<p className={styles.heroSubmitCaption}>
БЕЗ РЕГИСТРАЦИИ · БЕЗ ЗВОНКОВ · 30 СЕКУНД
</p>
{/* Живая область держим в DOM постоянно регион, добавленный в
момент ошибки, часть скринридеров не озвучивает (тот же приём,
что в ../AddressForm.tsx). */}
<div id={feedbackId} role="status" aria-live="polite">
{feedback.kind === "error" && (
<div
className={`${styles.heroFormFeedback} ${styles.heroFormFeedbackError}`}
>
<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>
</div>
);
}

View file

@ -0,0 +1,100 @@
/**
* FreeResultV3 ветка «результат» карточки «Бесплатная проверка» (макет v3,
* строки ~176-204 `МЕРА для себя v3.dc.html`).
*
* НЕ ВЫЗЫВАЕТСЯ СЕГОДНЯ НИОТКУДА. Компонент собран и типизирован, но нигде не
* рендерится в живом дереве: `FreeCheckCard.tsx` при сабмите (см. TODO рядом с
* `PUBLIC_ESTIMATE_ENABLED` там) всегда остаётся в форме и честно говорит, что
* онлайн-проверки ещё нет потому что анонимных ручек `/geocode/suggest` и
* `/trade-in/estimate` не существует (см. `../../content.ts`). Как только
* появится `POST /trade-in/coverage`, эту ветку нужно подключить: завести
* `phase`-состояние в `FreeCheckCard`, по успешному ответу переключаться в
* `"result"` и рендерить этот компонент с РЕАЛЬНЫМИ `analogs`/`days` из ответа
* ручки не константами.
*
* Данные приходят пропсами, а не читаются из `marketing-v3.ts` внутри
* компонента, вызывающий код сегодня мог бы по ошибке подставить туда
* `FREE_SAMPLE_PLACEHOLDER` для любого адреса, и тогда «14 похожих объявлений»
* увидел бы каждый посетитель независимо от того, что он ввёл. Это уже не
* продающая формулировка, а обман конкретного человека см. шапку
* `marketing-v3.ts`. Пропсовый контракт держит эту ответственность на
* вызывающей стороне и делает её явной в диффе, когда придёт время подключать
* реальные данные.
*
* Замыленная рублёвая цифра из макета («9,75 млн », `filter: blur`) сюда
* НЕ перенесена вообще вместо неё абстрактная плашка-скелет без единой
* цифры (см. `.heroResultSkeleton*` в `landing-v3.module.css`).
*/
import styles from "../../landing-v3.module.css";
export interface FreeResultV3Props {
/** Число похожих объявлений — крупная цифра первой плитки, напр. "14". */
analogs: string;
/** Подпись под числом объявлений. */
analogsLabel: string;
/** Средний срок продажи — крупная цифра второй плитки, напр. "47 дн.". */
days: string;
/** Подпись под сроком продажи. */
daysLabel: string;
/** Клик по «Проверить другую квартиру» — вернуть карточку к форме. */
onBack: () => void;
}
export function FreeResultV3({
analogs,
analogsLabel,
days,
daysLabel,
onBack,
}: FreeResultV3Props) {
return (
<div>
<p className={styles.heroResultEyebrow}>ГОТОВО · БЕСПЛАТНАЯ ЧАСТЬ</p>
<div className={styles.heroResultGrid}>
<div className={styles.heroResultTile}>
<div className={styles.heroResultTileValue}>{analogs}</div>
<div className={styles.heroResultTileLabel}>{analogsLabel}</div>
</div>
<div className={styles.heroResultTile}>
<div className={styles.heroResultTileValue}>{days}</div>
<div className={styles.heroResultTileLabel}>{daysLabel}</div>
</div>
</div>
<div className={styles.heroResultLocked}>
{/* Скелет без единой цифры: замыленную сумму из макета намеренно не
переносим, см. JSDoc выше. */}
<div className={styles.heroResultSkeleton} aria-hidden="true">
<div className={styles.heroResultSkeletonLine1} />
<div className={styles.heroResultSkeletonLine2} />
<div className={styles.heroResultSkeletonBar} />
</div>
<div className={styles.heroResultOverlay}>
<p className={styles.heroResultOverlayTitle}>
Ваша цена и срок продажи готовы
</p>
<button
type="button"
className={styles.heroResultOverlayBtn}
disabled
>
Открыть за 150&nbsp;
</button>
<p className={styles.heroResultOverlayCaption}>
Оплата подключается вместе с открытием сервиса пока недоступна.
</p>
</div>
</div>
<button
type="button"
className={styles.heroResultBackBtn}
onClick={onBack}
>
Проверить другую квартиру
</button>
</div>
);
}

View file

@ -0,0 +1,76 @@
/**
* HeaderV3 шапка лэндинга v3 (макет ~ строки 32-78): sticky, blur, лого +
* навигация + селектор города + CTA.
*
* Логотип НЕ ссылка на "/": next.config.ts редиректит "/" "/v2" (закрытый
* B2B-контур за RouteGuard) клик по вордмарку выкинул бы анонимного
* посетителя лэндинга в приложение, требующее логина (тот же комментарий, что
* в старом `_components/SiteHeader.tsx`). «Для бизнеса» ниже НАОБОРОТ,
* намеренно ведёт на "/": это единственная ссылка в шапке, которая должна
* увести в закрытый контур.
*
* «Статьи» раздела ещё нет в коде (ни маршрута, ни контента): пункт
* навигации нарочно неактивен (aria-disabled, без href), чтобы не заводить
* ссылку в 404 на первой же публичной странице.
*
* Внутренние якоря идут через `/mera-public#...`, а не голый `#...`: шапка
* рендерится на каждой странице поддерева (оферта/возврат/ПДн через
* layout.tsx), и там `#accuracy` без пути к самому лэндингу никуда бы не
* долистал.
*
* Единственный клиентский остров `CityPicker` (открытие/закрытие, поиск).
* Сама шапка серверная.
*/
import Link from "next/link";
import styles from "../../landing-v3.module.css";
import { CityPicker } from "./CityPicker";
export function HeaderV3() {
return (
<header className={styles.hdrRoot}>
<div className={styles.hdrInner}>
<div 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>
</div>
<nav className={styles.hdrNav} aria-label="Основная навигация">
<Link href="/mera-public#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>
</nav>
<div className={styles.hdrRight}>
<CityPicker />
<Link href="/" className={styles.hdrBizLink}>
Для бизнеса
</Link>
<Link href="/mera-public#check" className={styles.hdrCta}>
Проверить за 30 секунд
</Link>
</div>
</div>
</header>
);
}

View file

@ -0,0 +1,107 @@
/**
* HeroV3 первый экран лэндинга v3 (макет `МЕРА для себя v3.dc.html`,
* секция `#check`, строки ~109-208).
*
* Серверный компонент: весь текст статичен, единственное состояние живёт в
* `FreeCheckCard` (клиентский дочерний компонент).
*
* Числа в разметке только импортированные константы (`ACCURACY_PLACEHOLDER`,
* `FREE_SAMPLE_PLACEHOLDER.analogs`, цена отчёта из `COST_OF_ERROR_PLACEHOLDER`)
* ни одного нового числового литерала здесь не заведено, см. шапку
* `../../marketing-v3.ts`.
*/
import { ACCURACY_PLACEHOLDER, COST_OF_ERROR_PLACEHOLDER, FREE_SAMPLE_PLACEHOLDER } from "../../marketing-v3";
import styles from "../../landing-v3.module.css";
import { FreeCheckCard } from "./FreeCheckCard";
/** Цена отчёта — та же величина, что в третьей плитке блока «Почему это важно». */
const REPORT_PRICE = COST_OF_ERROR_PLACEHOLDER[2].value;
export function HeroV3() {
return (
<section
id="check"
className={styles.heroSection}
aria-labelledby="hero-v3-title"
>
<div className={styles.heroGrid}>
<div>
<div className={styles.heroBadge}>
<span className={styles.heroBadgeDot} aria-hidden="true" />
<span className={styles.heroBadgeText}>
СРЕДНЯЯ ОШИБКА ПРОГНОЗА {ACCURACY_PLACEHOLDER.priceError}
</span>
</div>
<h1 id="hero-v3-title" className={styles.heroTitle}>
Реальная цена вашей квартиры чтобы не продешевить
</h1>
<p className={styles.heroLead}>
Мы называем реальную цену вашей квартиры чтобы вы не продали её
дешевле и не зависли на полгода.
</p>
<div className={styles.heroChecklist}>
<div className={styles.heroListItem}>
<span className={styles.heroListIcon} aria-hidden="true">
</span>
<span className={styles.heroListText}>
<strong>Не продешевить:</strong> увидите потолок, который
рынок реально готов заплатить
</span>
</div>
<div className={styles.heroListItem}>
<span className={styles.heroListIcon} aria-hidden="true">
</span>
<span className={styles.heroListText}>
<strong>Не зависнуть:</strong> узнаете, за сколько дней уйдёт
квартира по вашей цене
</span>
</div>
<div className={styles.heroListItem}>
<span className={styles.heroListIcon} aria-hidden="true">
</span>
<span className={styles.heroListText}>
<strong>Не уступать зря:</strong> будет чем ответить на торг {" "}
{FREE_SAMPLE_PLACEHOLDER.analogs} аналогов с ценами
</span>
</div>
</div>
<div className={styles.heroPerks}>
<div className={styles.heroListItem}>
<span className={styles.heroListIcon} aria-hidden="true">
</span>
<span className={styles.heroListText}>
<strong>Бесплатно:</strong> сколько похожих квартир продаётся
рядом и как быстро они уходят
</span>
</div>
<div className={styles.heroListItem}>
<span className={styles.heroListIcon} aria-hidden="true">
</span>
<span className={styles.heroListText}>
<strong>
За{" "}
<span className={styles.heroNoBreak}>{REPORT_PRICE}</span>:
</strong>{" "}
ваша цена и срок продажи и каждый такой прогноз мы потом
сверяем с фактом сделки
</span>
</div>
</div>
</div>
<FreeCheckCard />
</div>
</section>
);
}

View file

@ -0,0 +1,100 @@
/**
* StepsV3 «что делает сервис»: три шага (макет v3, ~строки 210-228,
* без id секция идёт сразу после hero). Серверный компонент.
*
* Разметка упорядоченный список <ol role="list">, тот же приём, что и в
* `_components/HowItWorks.tsx` (см. комментарий там про Safari/VoiceOver
* quirk со снятием implicit list-ролей у list-style:none).
*
* Заголовка у секции в макете нет только три карточки. Чтобы у <section>
* всё равно был доступный aria-labelledby (а не голый регион без имени),
* заголовок сделан визуально скрытым и взят из заголовка эквивалентной
* секции v2-лэндинга (`HowItWorks.tsx` «Как это работает») то же по
* смыслу содержимое, а не придуманный текст.
*/
import { SERVICE_PRICE_RUB } from "../../content";
import { STEP1_DURATION_LABEL } from "../../marketing-v3";
import styles from "../../landing-v3.module.css";
interface StepV3 {
readonly title: string;
readonly text: string;
readonly accent?: boolean;
}
const STEPS_V3: readonly StepV3[] = [
{
title: "Вы описываете квартиру",
text: "Район, метраж, комнаты, этаж, состояние. Без регистрации и звонков.",
},
{
title: "Мы собираем рынок вокруг",
text: "Похожие квартиры рядом: сколько их продаётся сейчас и за сколько дней они уходят.",
},
{
title: "Вы получаете цену и срок",
text: "Конкретная цифра для объявления, прогноз срока продажи и все аналоги, на которых построен расчёт.",
accent: true,
},
] as const;
/**
* Бейджи «ШАГ N · » по одному на шаг. Единственные числа во второй половине
* бейджей берутся из marketing-v3.ts (`STEP1_DURATION_LABEL`) и content.ts
* (`SERVICE_PRICE_RUB`, тот же источник, что и у цены отчёта в остальном
* лэндинге) в массиве STEPS_V3 выше их нет намеренно, чтобы не разъезжались
* два места с одним и тем же числом.
*/
const STEP_BADGES: readonly string[] = [
STEP1_DURATION_LABEL.toUpperCase(),
"БЕСПЛАТНО",
`${SERVICE_PRICE_RUB}`,
];
export function StepsV3() {
return (
<section aria-labelledby="steps-v3-title" className={styles.stepsSection}>
<h2 id="steps-v3-title" className={styles.stepsSrOnly}>
Как это работает
</h2>
<ol className={styles.stepsList} role="list">
{STEPS_V3.map((step, index) => (
<li
key={step.title}
className={
step.accent
? `${styles.stepsItem} ${styles.stepsItemAccent}`
: styles.stepsItem
}
>
<div
className={
step.accent
? `${styles.stepsLabel} ${styles.stepsLabelAccent}`
: styles.stepsLabel
}
>
{/* «ШАГ N ·» скрыт от AT избыточен с порядком, который и так
объявляет role="list" (см. шапку файла и HowItWorks.tsx).
Сам бейдж (время/цена шага) реальная информация, её от
screen reader'а не прячем. */}
<span aria-hidden="true">{`ШАГ ${index + 1} · `}</span>
{STEP_BADGES[index]}
</div>
<h3 className={styles.stepsTitle}>{step.title}</h3>
<p
className={
step.accent
? `${styles.stepsText} ${styles.stepsTextAccent}`
: styles.stepsText
}
>
{step.text}
</p>
</li>
))}
</ol>
</section>
);
}

View file

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

View file

@ -0,0 +1,69 @@
/**
* b2c-tokens палитра и типографика ПУБЛИЧНОГО лэндинга «МЕРА» (макет v3,
* 15.08.2026).
*
* ПОЧЕМУ ОТДЕЛЬНО ОТ `v2/tokens.ts`. До сих пор лэндинг красился теми же
* токенами, что и B2B-экран оценки (`theme.ts` пробрасывал `tokens.*` в
* `--m-*`). Макет v3 задаёт другую палитру целиком ни один hex не совпадает
* (#F4F7F8/#16A9BC против #dde6ef/#2e8bff), и красить ими B2B-приложение
* нельзя: это другой продукт с другой аудиторией и своим утверждённым видом.
* Поэтому у публичного контура появляется собственный набор токенов, а
* `v2/tokens.ts` остаётся источником правды для закрытого контура и не
* трогается.
*
* Правило «никаких hex в CSS» сохраняется: литералы живут ЗДЕСЬ и только
* здесь, CSS ходит через `var(--m-*)`.
*
* КОНТРАСТ (WCAG AA 4.5:1 для текста) единственное расхождение с макетом,
* сделанное сознательно. В макете ссылки покрашены акцентом `#16A9BC`, а он
* на фоне страницы `#F4F7F8` даёт **2.6:1** это нечитаемо для части людей и
* провалит любой a11y-аудит. Поэтому:
* - `accent` (#16A9BC) ТОЛЬКО заливки, обводки, точки, декор (как и в
* старом theme.ts было для #2e8bff);
* - `accentText` (#0B6B79) тот же оттенок, затемнённый до **5.8:1** на
* фоне страницы; им красится текст ссылок и акцентные подписи;
* - `onAccent` (#FFFFFF) на заливке `accentDeep` (#0E8496) 4.6:1, годится
* для текста кнопок.
* Визуально разница между #16A9BC и #0B6B79 в тонкой ссылке незаметна, а
* читаемость меняется принципиально.
*/
export const b2c = {
/** Фон страницы. */
pageBg: "#F4F7F8",
/** Поверхность карточек. */
surface: "#FFFFFF",
/** Мягкая подложка секций и полей ввода. */
surfaceSoft: "#EAF5F7",
/** Тёмная плашка (последняя плитка «цены ошибки», подвал-акцент). */
surfaceDark: "#121A1D",
/** Основной текст. */
ink: "#121A1D",
/** Вторичный текст, подписи. */
muted: "#667579",
/** Границы, разделители. */
line: "#CBD7DA",
/** Тонкие внутренние разделители. */
lineSoft: "#EAF5F7",
/** Акцент — заливки/обводки/декор. НЕ для текста, см. шапку. */
accent: "#16A9BC",
/** Акцент для заливки кнопок (под белым текстом). */
accentDeep: "#0E8496",
/** Акцент для ТЕКСТА — затемнён до AA. */
accentText: "#0B6B79",
/** Текст на акцентной заливке. */
onAccent: "#FFFFFF",
/** Положительная дельта («выше средней цены»). */
success: "#1B8A5A",
/** Отрицательная дельта, предупреждения. */
danger: "#C0442E",
font: {
sans: "var(--font-manrope), 'Manrope', system-ui, sans-serif",
mono: "var(--font-plex-mono), 'IBM Plex Mono', ui-monospace, monospace",
},
} as const;

File diff suppressed because it is too large Load diff

View file

@ -5,8 +5,16 @@ import { REGION_NAME } from "./content";
import styles from "./landing.module.css";
import { landingVars } from "./theme";
import { SiteFooter } from "./_components/SiteFooter";
import { SiteHeader } from "./_components/SiteHeader";
/**
* ШАПКА И ПОДВАЛ ЖИВУТ В СТРАНИЦАХ, А НЕ ЗДЕСЬ (15.08.2026). Раньше layout
* рендерил SiteHeader/SiteFooter для всего поддерева. С появлением второй
* версии лэндинга (`v3/`, макет владельца) это стало блокером: вложенный
* layout в Next добавляется К родительскому, а не заменяет его, поэтому
* страница с собственной шапкой получала бы ДВЕ шапки сразу. Теперь layout
* отвечает только за обёртку шрифты, токены, skip-link, а каждая страница
* сама выбирает свой хром: v1 берёт SiteHeader/SiteFooter, v3 HeaderV3/
* FooterV3/StickyCtaV3.
*/
/**
* Публичный (B2C) периметр «МЕРЫ»: лэндинг для собственника квартиры.
@ -78,9 +86,7 @@ export default function MeraPublicLayout({
<a className={styles.skipLink} href="#content">
Перейти к содержимому
</a>
<SiteHeader />
{children}
<SiteFooter />
</div>
);
}

View file

@ -0,0 +1,166 @@
/**
* marketing-v3 ПЛЕЙСХОЛДЕРНЫЕ маркетинговые величины лэндинга (макет v3).
*
* ЧИТАТЬ ПЕРЕД ИСПОЛЬЗОВАНИЕМ. Ни одно число в этом файле не подтверждено
* замером продукта. Они взяты из дизайн-макета владельца (15.08.2026) как
* продающие формулировки и держатся ЗДЕСЬ, в одном месте, ровно для того,
* чтобы их можно было заменить одной правкой, когда появятся вычисляемые
* метрики.
*
* Что говорят наши собственные замеры на ту же тему (зафиксировано, чтобы
* расхождение не потерялось issue «compliance: заменить маркетинговые
* метрики лэндинга на вычисляемые до снятия noindex»):
*
* | здесь | факт по замеру |
* |------------------------|-----------------------------------------------|
* | ошибка 1,8% | 13,2% медиана |%| (бэктест n=277), held-out 16,1% |
* | 83% в диапазон | 81,95% но коридор шириной 74% от точки |
* | ±6 дн по сроку | сверки срока с фактом НЕ СУЩЕСТВУЕТ |
* | 42 700 проверок за год | 1 084 оценки всего (база живёт 2,5 месяца) |
* | 42 города РФ | 6 городов области, полное покрытие 1 (ЕКБ) |
*
* ГЕЙТ: пока `robots: { index: false }` в `layout.tsx` стоит и домен не открыт
* наружу, эти строки никому не показываются. ПЕРЕД снятием noindex каждая
* величина обязана быть либо заменена вычисляемой, либо снята со страницы
* публикация недостоверных характеристик платной услуги подпадает под ст. 5
* ФЗ «О рекламе» и ЗоЗПП.
*
* Правило для новых секций: не вписывай число в JSX руками заведи константу
* здесь. Иначе замена «одной правкой» перестанет работать ровно тогда, когда
* понадобится.
*/
/** Точность прогноза — блок «Точность» и бейдж героя. */
export const ACCURACY_PLACEHOLDER = {
/** Средняя ошибка по цене. */
priceError: "1,8%",
priceErrorNote: "медиана расхождения прогноза и цены сделки",
/** Точность по сроку продажи. */
daysError: "±6 дн.",
daysErrorNote: "отклонение факта от прогнозного окна",
/** Доля сделок внутри рекомендованного коридора. */
coverage: "83%",
coverageNote: "цена сделки внутри рекомендованного коридора",
/** Объём проверок за год. */
checks: "42 700",
checksNote: "из них 3 180 доведены до сделки и сверены",
} as const;
/** Блок «Почему это важно» — цена ошибки в цене. */
export const COST_OF_ERROR_PLACEHOLDER = [
{
value: "4,3%",
title: "теряет цена за каждый месяц простоя",
text: "Покупатели видят «зависшее» объявление и начинают торговаться сильнее.",
},
{
value: "×2,4",
title: "дольше продаётся квартира с завышенной ценой",
text: "Даже +7% к рыночной цене выбивают вас из выдачи по фильтрам покупателей.",
},
{
value: "150 ₽",
title: "стоит узнать правильную цену заранее",
text: "Один платёж. Без подписки. Отчёт остаётся у вас по ссылке.",
},
] as const;
/**
* Пример бесплатной части в макете. Это ИЛЛЮСТРАЦИЯ на статичной странице, а
* не расчёт по введённому адресу: анонимного расчёта сегодня нет вовсе
* (`PUBLIC_ESTIMATE_ENABLED = false`, ручки закрыты RBAC).
*
* Как только появится `POST /trade-in/coverage`, эти значения обязаны уехать
* показывать одинаковые «14 объявлений» любому, кто ввёл свой адрес, это уже
* не продающая формулировка, а обман конкретного человека.
*/
export const FREE_SAMPLE_PLACEHOLDER = {
analogs: "14",
analogsLabel: "похожих объявлений рядом",
days: "47 дн.",
daysLabel: "средний срок продажи",
} as const;
/** Отчётный период сводки в блоке «Точность» (лид над KPI-плитками, id="accuracy"). */
export const ACCURACY_PERIOD_LABEL = "последние 12 месяцев";
/** Заявленное время на шаг 1 («описываете квартиру») — бейдж в блоке «Как это работает». */
export const STEP1_DURATION_LABEL = "30 секунд";
/**
* Строки таблицы сверок в блоке «Точность» (id="accuracy"). Это ИЛЛЮСТРАЦИЯ
* формата отчёта из дизайн-макета (адреса, суммы и расхождения придуманы для
* примера), а не реальные сделки макет не содержал заполненных данных для
* этой таблицы (только шаблон `{{ r.addr }}` и т.д.), реальных проверок под
* ней нет. `checks`/`err`/`gain` здесь НЕ обязаны биться с
* `ACCURACY_PLACEHOLDER` арифметически это витринные примеры, а не выборка,
* из которой посчитана средняя ошибка.
*
* Как и весь файл: до снятия noindex каждая строка обязана быть либо заменена
* реальной сверенной сделкой, либо явно помечена как иллюстрация в самой
* вёрстке (см. подпись под таблицей в AccuracyV3.tsx).
*/
export interface ProofRow {
/** Адрес/район объекта. */
readonly addr: string;
/** Тип, площадь, этаж. */
readonly meta: string;
/** Прогноз «Меры». */
readonly said: string;
/** Фактическая цена сделки. */
readonly fact: string;
/** Расхождение прогноза и факта, %. */
readonly err: string;
/** Превышение над средней ценой по району, ₽. */
readonly gain: string;
/** То же превышение, %. */
readonly gainPct: string;
}
export const PROOF_ROWS_PLACEHOLDER: readonly ProofRow[] = [
{
addr: "Втузгородок, ул. Ясная, 8",
meta: "1-к, 34 м², 6/16 этаж",
said: "3 640 000 ₽",
fact: "3 705 000 ₽",
err: "1,8%",
gain: "+165 000 ₽",
gainPct: "+4,7%",
},
{
addr: "Юго-Западный, ул. Юмашева, 15",
meta: "2-к, 52 м², 3/9 этаж",
said: "5 210 000 ₽",
fact: "5 180 000 ₽",
err: "0,6%",
gain: "+96 000 ₽",
gainPct: "+1,9%",
},
{
addr: "Ботанический, ул. Академика Бардина, 23",
meta: "3-к, 71 м², 8/16 этаж",
said: "7 480 000 ₽",
fact: "7 640 000 ₽",
err: "2,1%",
gain: "+310 000 ₽",
gainPct: "+4,2%",
},
{
addr: "Уралмаш, ул. Кировградская, 42",
meta: "2-к, 46 м², 5/5 этаж",
said: "4 120 000 ₽",
fact: "4 065 000 ₽",
err: "1,3%",
gain: "+58 000 ₽",
gainPct: "+1,4%",
},
{
addr: "Центр, ул. Малышева, 51",
meta: "1-к, 38 м², 11/25 этаж",
said: "6 350 000 ₽",
fact: "6 490 000 ₽",
err: "2,2%",
gain: "+215 000 ₽",
gainPct: "+3,4%",
},
] as const;

View file

@ -13,6 +13,9 @@ import {
} from "../content";
import styles from "../landing.module.css";
import { SiteFooter } from "../_components/SiteFooter";
import { SiteHeader } from "../_components/SiteHeader";
/**
* Публичная оферта на оказание информационно-аналитических услуг «МЕРА».
*
@ -55,7 +58,9 @@ export default function MeraPublicOfferPage() {
const entity = LEGAL_ENTITY;
return (
<main id="content" className={styles.main} tabIndex={-1}>
<>
<SiteHeader />
<main id="content" className={styles.main} tabIndex={-1}>
<div className={`${styles.container} ${styles.doc}`}>
<Link
className={`${styles.link} ${styles.docBack}`}
@ -475,6 +480,8 @@ export default function MeraPublicOfferPage() {
</>
)}
</div>
</main>
</main>
<SiteFooter />
</>
);
}

View file

@ -17,14 +17,21 @@ import { HowItWorks } from "./_components/HowItWorks";
import { WhatYouGet } from "./_components/WhatYouGet";
import styles from "./landing.module.css";
import { SiteFooter } from "./_components/SiteFooter";
import { SiteHeader } from "./_components/SiteHeader";
export default function MeraPublicPage() {
return (
<main id="content" className={styles.main} tabIndex={-1}>
<>
<SiteHeader />
<main id="content" className={styles.main} tabIndex={-1}>
<Hero />
<HowItWorks />
<WhatYouGet />
<DataSources />
<Faq />
</main>
</main>
<SiteFooter />
</>
);
}

View file

@ -14,6 +14,9 @@ import {
import styles from "../landing.module.css";
import { safeUrl } from "@/lib/safeUrl";
import { SiteFooter } from "../_components/SiteFooter";
import { SiteHeader } from "../_components/SiteHeader";
/**
* Политика обработки персональных данных по ст. 18.1 152-ФЗ.
*
@ -58,7 +61,9 @@ export default function MeraPublicPrivacyPage() {
const entity = LEGAL_ENTITY;
return (
<main id="content" className={styles.main} tabIndex={-1}>
<>
<SiteHeader />
<main id="content" className={styles.main} tabIndex={-1}>
<div className={`${styles.container} ${styles.doc}`}>
<Link
className={`${styles.link} ${styles.docBack}`}
@ -279,6 +284,8 @@ export default function MeraPublicPrivacyPage() {
</p>
) : null}
</div>
</main>
</main>
<SiteFooter />
</>
);
}

View file

@ -12,6 +12,9 @@ import {
} from "../content";
import styles from "../landing.module.css";
import { SiteFooter } from "../_components/SiteFooter";
import { SiteHeader } from "../_components/SiteHeader";
/**
* Политика возврата денежных средств.
*
@ -46,7 +49,9 @@ export default function MeraPublicRefundPage() {
const entity = LEGAL_ENTITY;
return (
<main id="content" className={styles.main} tabIndex={-1}>
<>
<SiteHeader />
<main id="content" className={styles.main} tabIndex={-1}>
<div className={`${styles.container} ${styles.doc}`}>
<Link
className={`${styles.link} ${styles.docBack}`}
@ -209,6 +214,8 @@ export default function MeraPublicRefundPage() {
</p>
) : null}
</div>
</main>
</main>
<SiteFooter />
</>
);
}

View file

@ -0,0 +1,56 @@
import type { Metadata } from "next";
import { AccuracyV3 } from "../_components/v3/AccuracyV3";
import { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3";
import { FooterV3 } from "../_components/v3/FooterV3";
import { HeaderV3 } from "../_components/v3/HeaderV3";
import { HeroV3 } from "../_components/v3/HeroV3";
import { StepsV3 } from "../_components/v3/StepsV3";
import { StickyCtaV3 } from "../_components/v3/StickyCtaV3";
import styles from "../landing.module.css";
/**
* Лэндинг v3 сборка по дизайн-макету владельца (15.08.2026).
*
* ЭТО ПРЕВЬЮ, А НЕ ЗАМЕНА. Живой публичный лэндинг остаётся на
* `/mera-public` он открыт на meraocenka.ru и его нельзя менять
* посреди согласования дизайна. Новая версия лежит рядом, по
* `/mera-public/v3`, чтобы её можно было открыть и сравнить с текущей
* на одном и том же контенте. Переключение отдельным решением, когда
* версия принята.
*
* ЧЕГО ЗДЕСЬ ПОКА НЕТ (секции макета, требующие данных или контуров,
* которых нет в коде каждая заведена отдельной задачей):
* - лента «прогноз факт» и мини-игра «Проверьте себя» нужен контур
* сверки прогноза с фактом сделки;
* - «Продажа под ключ» противоречит текущему утверждению лэндинга
* «мы не берём квартиры на продажу», требует отдельного договора;
* - блок статей раздела не существует;
* - модалка проверки и рабочая оплата нет анонимного периметра и
* платёжного контура.
*
* ЧИСЛА в секции «Точность» и «Цена ошибки» плейсхолдеры из макета,
* см. `marketing-v3.ts`: они обязаны быть заменены вычисляемыми до снятия
* `robots: noindex`.
*/
export const metadata: Metadata = {
title: "Мера · расчёт стоимости квартиры по рыночным данным",
robots: { index: false, follow: false },
};
export default function MeraPublicV3Page() {
return (
<>
<HeaderV3 />
<main id="content" className={styles.main} tabIndex={-1}>
<HeroV3 />
<StepsV3 />
<AccuracyV3 />
<CostOfErrorV3 />
</main>
<FooterV3 />
<StickyCtaV3 />
</>
);
}