From 8fcec9f12ef7764ec78cbab81033a7701c3e52e8 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 18:08:04 +0300 Subject: [PATCH 1/3] fix(tradein/observability): stop basic_auth 401 and RetryError GlitchTip noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 83% of tracker issues (7460 total) were pure noise drowning real signal: - basic_auth 401 (3738 issues, 2019 distinct titles) — ops/glitchtip-auth- forwarder sent EVERY 401 from bots scanning gendsgn.ru (GET /wp-admin/ install.php etc.) as an individual GlitchTip event, remote_ip baked into message/tags inflated cardinality. Not an application error — expected bot-scan traffic against a basic_auth-protected site. - RetryError (2462 issues) — geocoder.py's three tenacity @retry-wrapped Nominatim helpers (lookup/suggest/reverse) raised tenacity.RetryError on exhaustion without reraise=True; RetryError.__str__() embeds a Future repr() with a memory address that differs every call, so GlitchTip grouped each exhausted retry as a distinct issue instead of one. Fix at the source, not post-hoc issue cleanup: - forwarder.py: before_send drops events tagged event_type in {basic_auth_failed, basic_auth_storm}; forwarder's own capture_exception (real script bugs) carries no such tag and passes through untouched. - geocoder.py: reraise=True on all three @retry decorators — propagates the real underlying exception (stable type + stacktrace) instead of the unstable RetryError wrapper. - sentry_scrub.stabilize_retry_error_fingerprint: belt-and-suspenders before_send hook, composed into both app/main.py and scheduler_main.py (geocoder runs in both processes — FastAPI request path and the overnight geocode_missing_listings batch). Collapses any RetryError that still slips through into one persistent issue per cause-exception type name only — never IP/address/listing-id. Content-ful categories (OperationalError, city-sweep, harvest_quarter, cian/avito/yandex sweep failures, scrape_freshness_check — ~700 issues) are untouched: filters key off event_type tag / exception type name only. --- ops/glitchtip-auth-forwarder/forwarder.py | 43 +++++++++ .../test_forwarder.py | 77 ++++++++++++++++ tradein-mvp/backend/app/main.py | 23 +++-- .../backend/app/observability/sentry_scrub.py | 37 ++++++++ tradein-mvp/backend/app/scheduler_main.py | 21 ++++- tradein-mvp/backend/app/services/geocoder.py | 17 +++- .../backend/tests/test_sentry_scrub.py | 92 +++++++++++++++++++ 7 files changed, 299 insertions(+), 11 deletions(-) create mode 100644 ops/glitchtip-auth-forwarder/test_forwarder.py diff --git a/ops/glitchtip-auth-forwarder/forwarder.py b/ops/glitchtip-auth-forwarder/forwarder.py index 8fc3c5b4..79c782f3 100644 --- a/ops/glitchtip-auth-forwarder/forwarder.py +++ b/ops/glitchtip-auth-forwarder/forwarder.py @@ -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, ) diff --git a/ops/glitchtip-auth-forwarder/test_forwarder.py b/ops/glitchtip-auth-forwarder/test_forwarder.py new file mode 100644 index 00000000..a9c089b3 --- /dev/null +++ b/ops/glitchtip-auth-forwarder/test_forwarder.py @@ -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"}) diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 347cad8c..29f99749 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -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, diff --git a/tradein-mvp/backend/app/observability/sentry_scrub.py b/tradein-mvp/backend/app/observability/sentry_scrub.py index 51c01e94..932042d5 100644 --- a/tradein-mvp/backend/app/observability/sentry_scrub.py +++ b/tradein-mvp/backend/app/observability/sentry_scrub.py @@ -174,3 +174,40 @@ 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[]`) — +# memory address объекта, случайный на каждый вызов процесса. Пока geocoder.py +# ретраил Nominatim без `reraise=True`, каждое исчерпание ретраев (Nominatim +# недоступен/rate-limit/403) улетало в GlitchTip как RetryError с этим +# нестабильным текстом → одна и та же причина плодила отдельный issue на КАЖДОЕ +# исчерпание (2 462 issue из 7 461 в трекере на момент фикса). `reraise=True` +# теперь пробрасывает реальное исключение вместо RetryError — это ГЛАВНЫЙ фикс. +# Функция ниже — belt-and-suspenders: ловит RetryError, если он всё же всплывёт +# (ретрай-код, который забыли/не смогли пометить reraise=True), и схлопывает его +# в ОДИН persistent issue 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. + + Определяет тип exception через `hint["exc_info"]` (реальный объект + исключения, тот же контракт что sentry_sdk передаёт в before_send) — не + парсит уже сериализованный event dict, надёжнее к изменениям формата SDK. + Не-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 exc_value is None or type(exc_value).__name__ != "RetryError": + return event + cause = exc_value.__cause__ or exc_value.__context__ + cause_type = type(cause).__name__ if cause is not None else "Unknown" + event["fingerprint"] = ["retry-exhausted", cause_type] + return event diff --git a/tradein-mvp/backend/app/scheduler_main.py b/tradein-mvp/backend/app/scheduler_main.py index 3bb8af4a..e5f93ed0 100644 --- a/tradein-mvp/backend/app/scheduler_main.py +++ b/tradein-mvp/backend/app/scheduler_main.py @@ -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(), diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 7785116a..6b3d9cc7 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -726,7 +726,16 @@ 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 +# (`` — адрес объекта в памяти, разный на КАЖДЫЙ вызов). GlitchTip +# группирует по этому нестабильному тексту → одна и та же причина (Nominatim +# недоступен/rate-limit) плодила отдельный issue на каждое исчерпание ретраев +# (2 462 issue из 7 461 в трекере). reraise=True пробрасывает РЕАЛЬНОЕ исключение +# (httpx.HTTPStatusError/TimeoutException) — стабильный тип+стек, группируется +# нормально. Дополнительная страховка — stabilize_retry_error_fingerprint +# (app/observability/sentry_scrub.py) на случай если RetryError всплывёт откуда-то ещё. +@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. @@ -925,7 +934,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]: @@ -1654,7 +1664,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. diff --git a/tradein-mvp/backend/tests/test_sentry_scrub.py b/tradein-mvp/backend/tests/test_sentry_scrub.py index 1e8d8ba5..242c03d4 100644 --- a/tradein-mvp/backend/tests/test_sentry_scrub.py +++ b/tradein-mvp/backend/tests/test_sentry_scrub.py @@ -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,94 @@ 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, #) ─ +# +# 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 слой (основной фикс — reraise=True), и по контракту before_send: +# 401-класс (RetryError) схлопывается, содержательные категории (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 по имени типа причины — НЕ по + нестабильному str(RetryError) (repr() Future с memory address).""" + 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", "TimeoutError"] + + +def test_stabilize_retry_error_fingerprint_has_no_variable_data() -> None: + """Fingerprint не должен содержать IP/id объявления/адрес и т.п. — только + фиксированное имя типа исключения-причины (маленький словарь: + 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", "ValueError"] + + +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] From 8f954fb98c19bd7c33784f75a840f61e9d014a31 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 15 Aug 2026 19:24:32 +0300 Subject: [PATCH 2/3] =?UTF-8?q?feat(mera/b2c):=20=D0=BB=D1=8D=D0=BD=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=20v3=20=D0=BF=D0=BE=20=D0=BC=D0=B0=D0=BA?= =?UTF-8?q?=D0=B5=D1=82=D1=83=20=E2=80=94=20=D0=BF=D1=80=D0=B5=D0=B2=D1=8C?= =?UTF-8?q?=D1=8E=20=D0=BD=D0=B0=20/mera-public/v3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дизайн-макет владельца (15.08.2026). Собрано рядом с живым лэндингом, а не вместо него: /mera-public открыт на meraocenka.ru, менять его посреди согласования дизайна нельзя. - b2c-tokens.ts — палитра макета отдельно от v2/tokens.ts. Красить теми же токенами B2B-экран нельзя, это другой продукт; hex-литералы остаются в одном файле. - marketing-v3.ts — числа из макета в ОДНОМ месте, с таблицей расхождений с нашими замерами и гейтом: заменить вычисляемыми до снятия noindex. - _components/v3/ — шапка с селектором города, подвал, липкая CTA, герой с карточкой проверки, три шага, «Точность», «Цена ошибки». - layout.tsx больше не рендерит шапку/подвал: вложенный layout в Next ДОБАВЛЯЕТСЯ к родительскому, поэтому v3 со своим хромом получал бы две шапки. Теперь хром выбирает страница — v1 берёт SiteHeader/SiteFooter, v3 свой. Отступления от макета (все задокументированы в коде): - реквизиты и документы берутся из LEGAL_ENTITY, а не из маски «ООО «МЕРА» · ИНН 66700XXXXX» в макете; - в подвал добавлены Политика возврата и почта поддержки, которых в макете нет, — обязательные элементы платного контура; - бейджи МИР/СБП/VISA не рендерятся: приём платежей не подключён; - форма не выдаёт результат — анонимного расчёта нет, а одинаковые «14 объявлений» на любой адрес это обман конкретного человека, а не продающая формулировка. Ветка результата свёрстана и ждёт /coverage; - замыленная рублёвая цифра не перенесена: в макете она лежит в разметке текстом под filter:blur и достаётся из исходника; - акцент #16A9BC не используется как цвет текста (2,6:1 на фоне страницы) — для текста затемнённый #0B6B79, 5,8:1; - города и объёмы в селекторе — из нашего реестра, а не 42 города РФ. Секции макета, требующие отсутствующих данных и контуров (лента прогноз→факт, игра, продажа под ключ, статьи, модалка, оплата), не верстались — заведены задачами #2894-#2904. Проверено: tsc --noEmit, next lint, isolation guard (31 файл), next build — /mera-public/v3 пререндерится статикой. --- .../mera-public/_components/v3/AccuracyV3.tsx | 154 ++ .../mera-public/_components/v3/CityPicker.tsx | 140 ++ .../_components/v3/CostOfErrorV3.tsx | 52 + .../mera-public/_components/v3/FooterV3.tsx | 145 ++ .../_components/v3/FreeCheckCard.tsx | 303 ++++ .../_components/v3/FreeResultV3.tsx | 100 ++ .../mera-public/_components/v3/HeaderV3.tsx | 76 + .../app/mera-public/_components/v3/HeroV3.tsx | 107 ++ .../mera-public/_components/v3/StepsV3.tsx | 100 ++ .../_components/v3/StickyCtaV3.tsx | 38 + .../src/app/mera-public/b2c-tokens.ts | 69 + .../src/app/mera-public/landing-v3.module.css | 1357 +++++++++++++++++ .../frontend/src/app/mera-public/layout.tsx | 14 +- .../src/app/mera-public/marketing-v3.ts | 166 ++ .../src/app/mera-public/oferta/page.tsx | 11 +- .../frontend/src/app/mera-public/page.tsx | 11 +- .../src/app/mera-public/privacy/page.tsx | 11 +- .../src/app/mera-public/refund/page.tsx | 11 +- .../frontend/src/app/mera-public/v3/page.tsx | 56 + 19 files changed, 2909 insertions(+), 12 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/CityPicker.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/FooterV3.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeResultV3.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/HeaderV3.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/HeroV3.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/StepsV3.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/StickyCtaV3.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/b2c-tokens.ts create mode 100644 tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css create mode 100644 tradein-mvp/frontend/src/app/mera-public/marketing-v3.ts create mode 100644 tradein-mvp/frontend/src/app/mera-public/v3/page.tsx diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx new file mode 100644 index 00000000..a240140b --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx @@ -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" (а не ): + * на мобильном брейке (см. landing-v3.module.css) она превращается в карточки + * без горизонтального скролла, а implicit table-роли всё равно снимаются + * Safari при смене display у настоящей
— тот же квирк, что уже описан + * в 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 ( +
+
+
+ +

+ Мы сверяем каждый прогноз с реальной сделкой +

+

+ {`Когда квартира продана, мы возвращаемся к своему прогнозу и считаем расхождение. Вот сводка за ${ACCURACY_PERIOD_LABEL}.`} +

+
+ +
    + {KPI_TILES.map((tile) => ( +
  • +
    {tile.value}
    +
    {tile.label}
    +
    {tile.note}
    +
  • + ))} +
  • +
    + {ACCURACY_PLACEHOLDER.checks} +
    +
    проверок за год
    +
    + {ACCURACY_PLACEHOLDER.checksNote} +
    +
  • +
+ +
+
+ Объект + Мера сказала + Факт сделки + Расхождение + + Выше средней цены по району + +
+ {PROOF_ROWS_PLACEHOLDER.map((row) => ( +
+
+ {row.addr} + {row.meta} +
+
+ {row.said} +
+
+ {row.fact} +
+
+ {row.err} +
+
+
+
{row.gain}
+
{row.gainPct}
+
+
+
+ ))} +
+

+ Таблица иллюстрирует формат отчёта, это не список конкретных сделок. + «Выше средней цены» — разница со средней ценой сделки по похожим + квартирам в районе. +

+
+
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CityPicker.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CityPicker.tsx new file mode 100644 index 00000000..7ef9ee8d --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CityPicker.tsx @@ -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(DEFAULT_CITY.label); + const wrapRef = useRef(null); + const buttonRef = useRef(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 ( +
+ + + {open ? ( +
+
+ setQuery(e.target.value)} + placeholder="Поиск города" + aria-label="Поиск города" + /> +
+
+ {filtered.length > 0 ? ( + filtered.map((c) => ( + + )) + ) : ( +

+ Города нет в списке. Мы работаем там, где хватает данных по + сделкам — напишите нам, и добавим. +

+ )} +
+
+ ДАННЫЕ ПО {OBLAST_CITIES.length} ГОРОДАМ:{" "} + {REGION_NAME.toUpperCase()} +
+
+ ) : null} +
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx new file mode 100644 index 00000000..b7281015 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx @@ -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 ( +
+
+
+ +

+ {`${SERVICE_PRICE_RUB} ₽ против двух месяцев вашей жизни`} +

+
+ +
    + {COST_OF_ERROR_PLACEHOLDER.map((tile, index) => { + const dark = index === COST_OF_ERROR_PLACEHOLDER.length - 1; + return ( +
  • +
    {tile.value}
    +
    {tile.title}
    +

    + {tile.text} +

    +
  • + ); + })} +
+
+
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FooterV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FooterV3.tsx new file mode 100644 index 00000000..1ff10d5d --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FooterV3.tsx @@ -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 ( +
+
+
+
+
+

+ Оценка квартир на вторичном рынке по сделкам и объявлениям.{" "} + {REGION_NAME}. +

+
+ +
+

Разделы

+
+ + Главная + + + Проверить квартиру + + + Статьи + + + МЕРА для бизнеса + +
+
+ +
+

Связаться

+

+ Почта:{" "} + + {SUPPORT_EMAIL} + +

+ {telegramHref ? ( +

+ Поддержка в Telegram:{" "} + + {SUPPORT_TELEGRAM_LABEL} + +

+ ) : null} +
+ +
+

Документы

+
+ + Публичная оферта + + + Политика возврата + + + Обработка персональных данных + +
+
+
+ +
+
+ © {year} МЕРА + {LEGAL_ENTITY ? ( + + {LEGAL_ENTITY.name}, ИНН {LEGAL_ENTITY.inn}, ОГРН{" "} + {LEGAL_ENTITY.ogrn}, {LEGAL_ENTITY.address} + + ) : null} + + Оценка носит информационный характер и не является офертой или + отчётом об оценке. + +
+ {/* TODO(платёжный контур): бейджи способов оплаты (МИР / СБП / VISA / + MASTERCARD, как в макете) — рендерить только когда приём оплаты + будет подключён backend'ом. Сегодня `PUBLIC_ESTIMATE_ENABLED = + false`, оплаты нет вовсе, а заявлять способы оплаты, которых нет, + — нарушение ст. 5 ФЗ «О рекламе». Гейт G6, см. + mera-b2c-paid-flow-decision.md. */} +
+
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx new file mode 100644 index 00000000..2adf9381 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx @@ -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 мс → готовый результат». Тот сценарий на живом + * сайте отдавал бы одинаковые «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(""); + const [feedback, setFeedback] = useState(NONE); + + const addressRef = useRef(null); + const areaRef = useRef(null); + const roomsRef = useRef(null); + const floorRef = useRef(null); + const conditionGroupRef = useRef(null); + + function clearFeedback() { + setFeedback(NONE); + } + + function handleSubmit(event: FormEvent) { + 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 ( +
+
+ Бесплатная проверка +
+ +
+
+ + +
+ + + + + +
+ +
+ + Состояние + +
+ {CONDITIONS.map((name) => ( + + ))} +
+
+ + +

+ БЕЗ РЕГИСТРАЦИИ · БЕЗ ЗВОНКОВ · 30 СЕКУНД +

+ + {/* Живая область держим в DOM постоянно — регион, добавленный в + момент ошибки, часть скринридеров не озвучивает (тот же приём, + что в ../AddressForm.tsx). */} +
+ {feedback.kind === "error" && ( +
+

{feedback.text}

+
+ )} + {feedback.kind === "info" && ( +
+

{feedback.title}

+ {feedback.lines.map((line) => ( +

+ {line} +

+ ))} +
+ )} +
+ +
+
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeResultV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeResultV3.tsx new file mode 100644 index 00000000..5cbd9c78 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeResultV3.tsx @@ -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 ( +
+

ГОТОВО · БЕСПЛАТНАЯ ЧАСТЬ

+ +
+
+
{analogs}
+
{analogsLabel}
+
+
+
{days}
+
{daysLabel}
+
+
+ +
+ {/* Скелет без единой цифры: замыленную сумму из макета намеренно не + переносим, см. JSDoc выше. */} +