Compare commits

...

19 commits

Author SHA1 Message Date
2e82b0810f fix(mera/b2c): снять с витрины обещание времени и подписать величины измеренным
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 57s
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI / openapi-codegen-check (pull_request) Has been skipped
«30 секунд» ушло из бейджа шага 1 как незамеренное, но осталось видимым
посетителю в четырёх других местах той же страницы: подпись под кнопкой формы,
CTA шапки, CTA блока возражений и липкая нижняя панель, которая висит на экране
всегда. Гейт витринных чисел этого не видел по построению — «сек» не было среди
единиц, а трёх файлов не было в списке сканируемых (обе дыры закрыты отдельно).
Замена — по смыслу: число полей формы (STEP1_FIELDS_LABEL) проверяется глазами,
и оба места берут его из landing-facts.ts, а не пишут у себя.

Медиана аналогов стояла под общей шапкой «пример по рынку Екатеринбурга», но
городом не ограничена: _ANALOGS_SQL идёт по всей таблице trade_in_estimates,
фильтр по city стоит только у экспозиции. Плюс Hero печатал у неё лишь размер
выборки и выбрасывал note «только расчёты, где аналоги нашлись» — отбор
выживших. Теперь у каждой величины своя подпись со своей границей и своя
оговорка рядом со значением; правило зафиксировано двумя проверками рендера
(обе краснеют на возврате к одной строке и на выброшенном note).

price_cut_share_pct считает долю ОБЪЯВЛЕНИЙ и только по Домклику — подпись
«столько продавцов снижают цену» приписывала величине единицу, которой её не
мерили. Один продавец ведёт несколько объявлений.

В ReportResult оставались три литерала «150 ₽» при живой SERVICE_PRICE_RUB:
цена обязана иметь один источник с офертой и политикой возврата.

Обещание «прогноз срока продажи» приведено к тому, что считается:
_estimate_days_on_market — медиана days_on_market аналогов, то есть сколько
похожие объявления УЖЕ висят. deals.days_on_market заполнен 0 раз из 108 623,
сверять прогноз срока не с чем (по этой же причине в ветке уже снято «±6 дн»).
2026-08-29 20:40:20 +05:00
b17a2a310c fix(mera/b2c): гейт витринных чисел ловит происхождение, а не форму записи
Ревьюер нашёл три дыры и воспроизвёл каждую на живом коде.

1. Сканер проверял ФОРМУ. Четыре маркетинговых утверждения, вписанные в
   DealsTickerV3, оставляли гейт зелёным 17/17: «9 из 10» (доля словами),
   «42700 квартир» (разделитель тысяч никакой + единица вне списка),
   «в 2 раза точнее» (кратность словами), «42 городах». Последнее — строка
   из таблицы расхождений СТАРОГО гейта, то есть новый пропускал ровно тот
   класс вранья, ради которого заводился. Добавлены четыре формы; эти же
   четыре строки внесены в контроль на инструмент — прежние шесть образцов
   повторяли то, что сканер и так знал, и покрытия не расширяли.

2. noindex перестал охраняться. `toContain("robots")` остаётся зелёным при
   `index: true` — проверено переворотом флага. Вернулась проверка значения
   (не слабее прежней `noindexIsOn()`), и не только в layout, но и в
   метаданных самой страницы, которые layout перекрывают.

3. Список файлов отставал от страницы: корень рендерил 15 компонентов,
   сканировались 11 — «возражения», шапка, подвал и sticky-CTA были вне
   охраны. Список больше не пишется руками: он выводится из каталога
   компонентов, а отдельная проверка требует, чтобы всё, что импортирует
   корень, попало в этот список (ловит компонент, положенный мимо каталога).

Гейт на этой ветке КРАСНЫЙ и красный по делу: расширенный список нашёл
«Проверить за 30 секунд» в HeaderV3, StickyCtaV3, ObjectionsV3 и
FreeCheckCard. Замера времени заполнения формы нет и не было — это записано
в landing-facts.ts и в StepsV3, откуда ту же фразу уже сняли. Правка текста —
вне участка этой задачи (файлы вёрстки правит соседняя ветка).
2026-08-29 20:39:00 +05:00
2c9b4ab547 feat(mera-public): витрина лэндинга v3 на замеренных числах
Литералы из дизайн-макета заменены величинами из /stats и /showcase, а то,
чему нет входа в данных, снято вместе с блоком.

Что заменено:
- ошибка «1,8%» → 14,5% (n=327) с двумя оговорками рядом: сравнение идёт с
  объявлениями, активными на момент расчёта, а сделка прошлая; цена ДКП
  бывает занижена ради налога;
- «83% в диапазон» → 88% (n=327) неразрывно с шириной коридора ±37% в
  подписи и с третьей плиткой: уверенность «низкая» у 325 из 327;
- «±6 дн. точность по сроку» СНЯТА — deals.days_on_market пуст 0/108 623; на
  её месте медианная экспозиция активного объявления, без ± и без «точности»;
- «42 700 проверок, из них 3 180 сверены» → estimates_total; «сверено» снято
  (контура сверки прогноза со сделкой клиента нет);
- «−4,3% за месяц» → price_cut_median_pct_per_month (среди снижавших, своя
  выборка); «×2,4 дольше» СНЯТА (замер даёт ×1,04) → price_cut_share_pct;
- «14 объявлений / 47 дн.» → analogs_median и listing_age_median_days, прямо
  помеченные как пример по рынку, а не расчёт по адресу посетителя;
- 5 выдуманных строк сверок и 3 раунда игры → реальные сделки /showcase
  (район+комнаты+площадь+этаж, квартал вместо дня) с подписью прогона;
- цена во всех местах — SERVICE_PRICE_RUB из content.ts.

Отказ ручки стоит блока: пустой /stats снимает плитку, пустой /showcase —
таблицу, ленту и игру. Ни нуля, ни прочерка, ни прошлого значения.

Гейт #2904 переписан: он охранял «плейсхолдеры существуют» и покраснел бы по
построению за их снятие. Новый охраняет происхождение чисел — сканирует
компоненты на вписанные руками величины и проверяет заполненность записей
landing-facts.ts. Фальсифицирован: вписанное в AccuracyV3 «1,8% по 42 700
проверкам» роняет гейт тремя формами сразу. Сканер прикрыт контролем на
образцах — он же поймал уже существовавшую «150 ₽» в FreeResultV3.

robots/noindex не тронуты.
2026-08-29 20:37:58 +05:00
f612401202 feat(mera-public): серверный слой витринных чисел лэндинга
Загрузка /api/public/mera/stats и /showcase для серверных секций лэндинга:
типы обеих ручек, честная деградация и форматирование величины вместе с
размером выборки.

Любой сбой (сервис недоступен, 500, не-JSON, пустой ответ, нет ключа) даёт
«величины нет», а не ноль: /stats отдаёт пустой набор, /showcase — null,
и ни одна из функций не бросает, чтобы отказ ручки стоил блока, а не всей
страницы. Число выходит наружу только через formatStat — вместе с sample_n
и note, поэтому потерять размер выборки по дороге в JSX нельзя.

Адрес серверного fetch — BACKEND_URL, тот же путь, которым Next уже ходит в
backend (rewrites в next.config.ts, 'internal SSR' в prod-компоуз), поэтому
без /trade-in: префикс добавляет Caddy для браузера, не бэкенд. Кэш —
revalidate 3600: пересчёт ночной, no-store жёг бы rate-limit ручки на всех
посетителей, кэш без срока показывал бы вчерашнее до деплоя.

Тесты двусторонние и фальсифицированы: подстановка нуля вместо null роняет
5 из 17, пустая витрина вместо null — 3 из 17.
2026-08-29 20:35:27 +05:00
5d93f13030 Merge pull request 'feat(mera/b2c): новый дизайн становится корнем meraocenka.ru, функционал старого перенесён' (#3236) from feat/b2c-v3-becomes-root into main
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Infra Host / sync-infra-host (push) Successful in 7s
Deploy / changes (push) Successful in 8s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 43s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-worker (push) Successful in 44s
Deploy / build-frontend (push) Successful in 43s
Deploy / deploy (push) Successful in 1m36s
Deploy / deploy-status (push) Successful in 1s
Deploy Trade-In / build-frontend (push) Successful in 2m27s
Deploy / perimeter-smoke (push) Failing after 14s
Deploy Trade-In / test (push) Successful in 4m20s
Deploy Trade-In / build-backend (push) Has been cancelled
2026-08-29 15:32:36 +00:00
3b029cc0c7 Merge pull request 'fix(mera/b2c): выключенная ручка расчёта подтверждала своё существование и печатала схему' (#3233) from fix/disabled-endpoint-leaks-schema into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
2026-08-29 15:31:26 +00:00
ac2633011b fix(mera/v2): форма принимала этаж больше этажности дома (#3226)
Some checks failed
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m15s
Deploy Trade-In / deploy (push) Successful in 1m3s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Failing after 15s
2026-08-29 15:19:24 +00:00
6ab13649f6 fix(mera/b2c): выключенная ручка расчёта подтверждала своё существование и печатала схему
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m10s
Докстринг обещал: «Отвечаем 404, а не 403: выключенная ручка не должна
подтверждать, что она существует». Замер на проде 29.08.2026 (флаг выключен):

  POST /api/public/mera/estimate  {}          → 422 + address/area_m2/rooms/consent
  POST /api/public/mera/estimate  {валидное}  → 404
  POST /api/public/mera/nosuchthing           → 401 (rbac)

422 отличается и от 404, и от 401 — то есть подтверждает, что ручка есть, и
заодно выдаёт её схему.

Причина не в логике гейта, а в его МЕСТЕ: проверка стояла первой строкой тела
хендлера, а FastAPI валидирует тело раньше, чем доходит до кода. Гейт перенесён
в dependencies=[Depends(...)] обеих ручек — зависимости решаются до разбора тела,
и выключенная ручка неотличима от отсутствующей при любом входе.

Тест двусторонний и фальсифицирован: возврат вызова в тело красит три теста
(включая уже существовавший про 404), обратная правка — снова зелено.
2026-08-29 20:19:15 +05:00
b241e0145a Merge pull request 'feat(mera/b2c): платёжный роутер, статус-машина и доставка купленного (за флагом)' (#3231) from feat/b2c-payments-router into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m56s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 2m30s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Failing after 11s
2026-08-29 15:08:33 +00:00
195f9f3697 Merge pull request 'chore(tradein): две ручки ротации без читателей и врущий комментарий над ними' (#3232) from chore/3212-dead-rotation-knobs into main
All checks were successful
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Successful in 4m2s
Deploy Trade-In / build-backend (push) Successful in 1m40s
Deploy Trade-In / deploy (push) Successful in 1m35s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
2026-08-29 14:57:40 +00:00
7563f849db Merge pull request 'chore(tradein): четыре символа, на которые нет ни одной ссылки' (#3224) from chore/3212-dead-code-tradein into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Has been cancelled
Deploy Trade-In / perimeter-smoke (push) Has been cancelled
Deploy Trade-In / build-backend (push) Has been cancelled
Deploy Trade-In / build-frontend (push) Has been cancelled
Deploy Trade-In / build-browser (push) Has been cancelled
Deploy Trade-In / deploy (push) Has been cancelled
Deploy Trade-In / deploy-status (push) Has been cancelled
2026-08-29 14:57:28 +00:00
a48070dd89 fix(tradein): миграция платежей 277 → 279 (столкновение номеров) + lock_timeout
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m54s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
Два параллельных агента взяли ОДИН номер: 277_landing_showcase_runs.sql в ветке
витрины и 277_payments_live_checkout_uidx.sql здесь. Обе ветки по отдельности
зелёные, но на main второй файл встал бы конфликтом — ровно ловушка из шапки
tests/test_migration_numbering.py: номер сверяется с origin/main, а не с чужими
открытыми ветками.

Заняты сейчас: 275 (метрики), 276+277 (витрина), 278 (публичный токен) → этот 279.
Ссылки на номер обновлены в payments.py и test_payments_router.py, включая путь,
по которому тест читает предикат частичного UNIQUE.

Плюс CREATE UNIQUE INDEX на существующей таблице payments обёрнут в
SET LOCAL lock_timeout = '5s' — гейт #2752.
2026-08-29 19:38:38 +05:00
c5315539fa fix(payments): один живой платёж на оценку — гарантия БД, а не порядка выполнения
Идемпотентность checkout держалась на «SELECT, потом INSERT» — ровно на том,
что шапка модуля называет дефектом. Двойной клик по кнопке оплаты давал два
параллельных запроса, два INSERT, два Init и два холда на карте покупателя.

- миграция 277: частичный UNIQUE (estimate_id, product_code) по живым статусам
  + ON CONFLICT DO NOTHING в INSERT. Проигравший гонку не идёт в банк: отдаёт
  ссылку соперника, если та уже готова, иначе 409;
- граница по времени для брошенных попыток: NEW/FORM_SHOWED старше 30 минут
  переводятся в DEADLINE_EXPIRED. Без неё зависший платёж (нотификации по нему
  может не прийти вовсе) навсегда отдавал покупателю одну и ту же протухшую
  PaymentURL. Окно НЕ распространяется на AUTHORIZED и прочие карточные
  статусы — там деньги уже в игре, разгребать их — работа реконсиляции;
- IDOR: checkout читал оценку без _assert_estimate_access. По чужому
  estimate_id возвращался order_id чужого живого платежа, а order_id — право
  доступа для /payments/status/<order_id>, отдающего capability-ссылку на
  отчёт. Проверка ставится только для оценок с владельцем: у анонимной покупки
  идентичности нет, правом там работает сам estimate_id.

Тесты двусторонние, фальсификация прогнана: снятие ON CONFLICT / границы по
времени / IDOR-гварда красит ровно один тест каждый раз, два из трёх — по
значению ответа.
2026-08-29 19:38:38 +05:00
28e13d5841 feat(payments): роутер checkout/notify, статус-машина и выдача по capability-ссылке
Не хватало ровно проводки: сервисный слой Т-Банка (PR-C) и схема (PR-B, 233)
уже были, HTTP-ручек и статус-машины — нет, как и доставки купленного.

Всё за kill-switch PAYMENTS_ENABLED (дефолт false): при выключенном контуре
каждая ручка отвечает 503 и не трогает ни банк, ни платёжные таблицы, поэтому
merge на проде не меняет поведения.

Идемпотентность целиком отдана БД (UNIQUE миграции 233 + ON CONFLICT DO
NOTHING), а не паре «проверить-потом-вставить»: между проверкой и вставкой
проходит параллельный ретрай банка, и товар выдаётся дважды. Признаком
«выдача состоялась» служит payment_notifications.processed_at, а не сам факт
строки — иначе падение процесса между записью нотификации и выдачей оставило
бы клиента без отчёта при списанных деньгах.

Доставка — capability-ссылка /api/v1/trade-in/r/<token>: токен лежит в
payment_entitlements.subject (ref_id остаётся estimate_id, на нём держится
UNIQUE «выдали один раз»), режется из GlitchTip-событий и открыт в rbac
отдельным узким префиксом. Тело GET /estimate/{id} вынесено в load_estimate,
чтобы у второго права доступа был тот же загрузчик, а не третья копия
гейта читаемости.
2026-08-29 19:38:37 +05:00
49652f4413 fix(mera/b2c): вернуть потерянные вопросы FAQ, починить адрес превью и честность двух комментариев
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m0s
Правки по трём ревью ветки «v3 становится корнем».

FAQ. Из шести вопросов `content.ts` возражения v3 брали два, остальные
четыре (`how-accurate`, `vs-marketplace`, `personal-data`, `bank-report`)
не рендерились нигде: `Faq.tsx`, который выводил все шесть, удалён вместе
с лэндингом v1. Макетные возражения их тематически закрывали, но
пересказом — а оригиналы проходили юр-ревью. Содержательно пропали два
утверждения: «Имя, паспорт и документы на квартиру мы не спрашиваем» с
отсылкой к странице ПДн (152-ФЗ) и «показываем диапазон, а не одно число»
как прямой ответ про точность (рамка диапазона осталась только в
`AccuracyV3`, рядом с плейсхолдерными числами).

Теперь в секции один порядок чтения: все шесть вопросов из `FAQ` по id
плюс три макетных возражения, которым в `FAQ` пары нет («факт сделки»,
«почему так дёшево», «подписка»). Три макетных пересказа удалены в пользу
оригиналов; у «почему так дёшево» убран второй абзац — почти дословный
повтор второго абзаца `how-accurate`, который теперь стоит следующим.
Новых формулировок не добавлено. Отсылка к странице ПДн стала живой
ссылкой через `PublicLink` (обычный `<a>`, basePath не течёт); текст
ссылки — слова самого `content.ts`, найденные по вхождению.

Гейт стал двусторонним: тест перебирает `FAQ` и требует, чтобы КАЖДЫЙ id
был отрисован. Прежняя версия перечисляла два id руками и потому молчала
ровно в тот момент, когда четыре ответа исчезли со страницы. Проверено
мутацией: снятие вопроса, переименование id и переформулировка фразы про
ПДн красят тест по отдельности.

CADDY. `/trade-in/mera-public/v3` выбыл из @meraLongPages вместе со
страницей и никуда не попал — длинный адрес превью падал в catch-all 404,
хотя раньше вёл на страницу. Обе его формы добавлены в @meraV3Gone. Там же
редирект перестал терять query: было `redir * /`, стало `redir * {uri}`
со срезанием пути — как у @meraLongPages, где перенос UTM и обоснован.
Проверено на живом Caddy 2.11 по настоящему site-блоку: все четыре формы
дают 301 на `/` с сохранённой query, без параметров — чистый `/` без
хвоста `?`, соседние матчеры не задеты, лишний сегмент по-прежнему 404.

КОММЕНТАРИИ, ОПИСЫВАЮЩИЕ НЕСУЩЕСТВУЮЩЕЕ. Строка `page.tsx` в гейте
плейсхолдеров убрана: комментарий над ней утверждал, что устаревший путь
«перестал бы что-либо сканировать», но ни в корневом `page.tsx`, ни в
прежнем `v3/page.tsx` нет ни одного имени из `PLACEHOLDER_EXPORTS` —
страница только собирает секции. Замер подтверждает: множество найденных
плейсхолдеров со строкой и без неё совпадает, а сама по себе она не даёт
ничего. Держать её значило держать ложное ощущение охвата.

Дескриптор «Оценка вторичного жилья по рыночным данным · <регион>» на
внутренние страницы не возвращается — причина записана в докстринге
`InnerHeader`: эту шапку носят и `/estimate`, `/articles`, `/docs`, где
дескриптора не было никогда; требование юриста было про первый экран
лэндинга и там выполняется `HeroV3`; на самих юр-страницах то же сказано
сильнее — в тексте оферты (п. 1.3, 6.2) и в `FooterV3` на каждой странице.
2026-08-29 19:35:57 +05:00
bot-backend
1141035899 chore(tradein): две ручки ротации, которых не осталось читателей, и врущий комментарий над ними
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m8s
proxy_rotate_attempts / proxy_rotate_attempt_timeout_s тюнили ретраи changeip-GET.
Сам changeip снят в #2616 шаг 2 (аккаунт mobileproxy закрыт, ссылки нет), и с тех
пор ручки живут пламбингом: Settings -> property адаптера -> поле протокола
ScraperConfig -> и всё. Ни одного потребителя, только четыре теста, которые
заполняют их при сборке конфига.

Комментарий над ними в contracts.py оправдывал их сохранение так:
  «оставлены как budget-верхняя-граница для app.tasks.avito_detail_backfill wait_for»
— но wait_for там берёт СОСЕДНЕЕ поле, avito_proxy_rotate_settle_s
(avito_detail_backfill.py:249). То есть комментарий приписывал этим двум полям
работу третьего и тем самым прикрывал их мёртвость.

Соседние ручки проверены и ОСТАВЛЕНЫ, они действительно читаются:
  * avito/cian/yandex_proxy_max_rotations — pipeline._max_rotations;
  * avito_proxy_rotate_settle_s — asyncio.wait_for в avito_detail_backfill.

Заодно сжат комментарий в config.py: перечисление истории changeip заменено на
то, что нужно знать сейчас — кто читает оставшиеся две ручки и где живая ротация
(ASOCKS_API_TOKEN / proxy_rotation, #2611).

ruff clean, 4974 passed / 37 skipped.
2026-08-29 17:07:04 +03:00
6976ca8915 feat(mera/b2c): лэндинг v3 становится корнем публичного домена
Вариант дизайна выбран — превью с /mera-public/v3 переехало в корень
целиком, вместе с блоками, перенесёнными из v1 предыдущим шагом. Каталог
v3/ и компоненты v1 (Hero / HowItWorks / WhatYouGet / DataSources / Faq /
AddressForm / SiteHeader / SiteFooter) удалены: их данные живут в
content.ts и уже читаются секциями v3, поэтому ушла только вёрстка.

Юридические страницы (оферта, возврат, ПДн) держались на SiteHeader/
SiteFooter — единственные, кто их ещё импортировал. Переведены на
InnerHeader + FooterV3, тем же способом, что /estimate, /articles и /docs.

ПЕРИМЕТР. /v3 убран из @meraPages, @meraShortSlash и @meraLongPages, но
не выброшен: адрес две недели был открыт наружу и разошёлся по закладкам,
поэтому вместо тишины белого списка — 301 на корень (@meraV3Gone, обе
формы). На это заведена проверка в public-perimeter.test.ts по образцу
соседних; PREVIEW_ONLY стал пустым, а не удалённым — он держит проверку
«наружу не открыто ничего сверх известных страниц» двусторонней.

Из PUBLIC_SHORT_PATHS гварда /v3 убран: редирект отрабатывает в Caddy, до
приложения запрос не доходит, а недостижимая строка в списке публичных
путей сделала бы публичной будущую страницу с тем же адресом. Гейт
Caddy→гвард односторонний и сам бы этого не поймал — обоснование в коде.

NOINDEX ОСТАВЛЕН. Он держится за плейсхолдерные числа marketing-v3.ts
(#2904), а не за то, какой вариант дизайна стоит в корне. Путь в гейте
обновлён на page.tsx: placeholdersInUse() молча пропускает ненайденный
файл, поэтому устаревшая строка не покраснела бы, а перестала бы
сканировать.
2026-08-29 18:59:43 +05:00
d4e106882c feat(mera/b2c): перенести потерянные блоки v1 в лэндинг v3
Корень meraocenka.ru отдаёт v1, новый дизайн живёт на /v3 и скоро станет
корнем. Шесть вещей из v1 в v3 отсутствовали — при переключении они бы
просто исчезли с публичного сайта:

- реестра источников не было вовсе (SourcesV3, из SOURCE_GROUPS);
- «что вы получите» и дисклеймера 135-ФЗ не было (WhatYouGetV3): о том,
  что это не отчёт аккредитованного оценщика, узнавал только тот, кто сам
  догадался открыть нужное возражение;
- герой не называл границу покрытия (полное — Екатеринбург, частично —
  остальные города области, по другим регионам не считаем) и не говорил,
  что мы не покупаем квартиры и не берём их на продажу;
- два вопроса FAQ («откуда вы знаете, сколько стоит именно моя квартира»,
  «почему только Свердловская область») в возражениях v3 не звучали —
  теперь берутся из FAQ по id, а не копией строк;
- карточка проверки не спрашивала город: гео-гейт #2576 на новом лэндинге
  был сломан (без города «Ленина, 1» уходит в одноимённую улицу ЕКБ);
- она же спрашивала состояние обязательным полем и выбрасывала ответ на
  переходе — теперь он доезжает в черновике.

Тексты перенесены дословно (проходили юр-ревью), новых чисел нет.
Новые секции встали после «Точности»: сверяем прогноз с фактом → на каких
данных → что получаете на руки, всё до первого разговора про деньги.

Гейт: __tests__/v3-ported-copy.test.ts — проверен мутацией (снятие секции
со страницы и переименование id вопроса красят его).
2026-08-29 18:52:09 +05:00
bot-backend
4902d1e29e chore(tradein): четыре символа, на которые нет ни одной ссылки
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 13s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m12s
Найдено сверкой двух независимых источников: подсчёт упоминаний по всем
python-файлам (включая тесты) дал имена, встречающиеся ровно один раз — в
собственном объявлении; каждое затем подтверждено serena find_referencing_symbols
(LSP, видит и косвенные ссылки) и проверено grep'ом по yml/sql/ts/md на случай
ссылки строкой.

Удалено:
  * _qr_code_data_url (trade_in_pdf) — QR как SVG data URL, не вызывался
    ниоткуда; вместе с ним ушёл осиротевший import segno. `io` ОСТАВЛЕН —
    io.BytesIO используется дальше по файлу (строка 774);
  * _local_name (gar_flats_loader) — снятие namespace с имени тега;
  * AvitoParseError — класс никто не поднимает и не ловит;
  * IMVCityMismatchError — то же.

Что НЕ тронуто, хотя фильтр их показал:
  * ~200 обработчиков FastAPI и celery-задач — их поднимает декоратор, по имени
    их действительно никто не зовёт;
  * refresh_ddu_price_indicator — точка ручного обслуживания, задокументирована
    в комментарии к матвью (data/sql/152_mv_ddu_price_indicator.sql: «Refresh:
    ... (не в beat)»);
  * schemas/parcel.py::MarketPrice — половина контракта, фронт использует
    (frontend/src/types/site-finder.ts: market_price?: MarketPrice);
  * JobSetting — SQLAlchemy-модель, живёт через metadata Base.

segno остался в backend/pyproject.toml и больше нигде не используется — снятие
зависимости требует пересборки лока, поэтому отдельным PR.

ruff clean, 4974 passed / 37 skipped.
2026-08-29 15:59:54 +03:00
73 changed files with 4442 additions and 1579 deletions

View file

@ -347,11 +347,7 @@ meraocenka.ru {
# `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант
# без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на
# «/», и запрос закольцуется.
# `/v3` — ВРЕМЕННОЕ превью второго варианта дизайна, а не публичная
# страница: владелец сравнивает его с текущим лэндингом. Оно `noindex` и
# ни с одной страницы на него нет ссылки. Убрать эту строку в тот момент,
# когда вариант выберут и он станет корнем.
@meraPages path /estimate /oferta /refund /privacy /v3 /articles /articles/kak-ocenit-kvartiru /docs
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs
handle @meraPages {
rewrite * /trade-in/mera-public{path}
reverse_proxy tradein-frontend:3000 {
@ -365,11 +361,48 @@ meraocenka.ru {
# голый 404 (так было и до этого PR, с момента #2615). Заодно это
# замыкает цепочку для длинных адресов со слэшем: они приходят на короткий
# со слэшем и здесь нормализуются.
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|v3|articles|articles/kak-ocenit-kvartiru|docs)/$
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|docs)/$
handle @meraShortSlash {
redir * /{re.shortslash.1} permanent
}
# `/v3` — АДРЕС ПРЕВЬЮ, КОТОРОГО БОЛЬШЕ НЕТ. С 29.08.2026 выбранный вариант
# дизайна стоит в корне, каталог `v3/` во фронте удалён, и проксировать
# сюда нечего — Next отдал бы 404. Но адрес жил на публичном домене больше
# двух недель: владелец сравнивал по нему версии, и ссылка почти наверняка
# осталась в закладках и в переписке. Поэтому 301 на корень, а не молчание
# белого списка: человек по старой ссылке попадает ровно на то, что по ней
# и смотрел, только теперь это главная.
#
# ЧЕТЫРЕ ФОРМЫ, А НЕ ДВЕ. Превью раздавалось и коротким `/v3`, и длинным
# `/trade-in/mera-public/v3` — вторая пара до 29.08.2026 жила в
# @meraLongPages и оттуда выбыла вместе со страницей. Короткие формы её
# не подхватывают (матчер `path` требует точного совпадения), поэтому
# длинный адрес превью падал в catch-all 404 — тот же класс дефекта,
# ради которого длинные адреса вообще оставлены живыми. Все четыре формы
# ведут в один корень, поэтому и матчер один.
#
# QUERY ПЕРЕНОСИТСЯ — `redir * {uri}`, как в @meraLongPages, а не
# `redir * /`, как было здесь до этой правки. Причина ровно та же, что
# записана у @meraLongPages: по разосланной ссылке приходят с UTM-метками,
# и `/v3?utm=…` не должен терять атрибуцию на редиректе. Оба
# `uri strip_prefix` срезают путь целиком (для `/v3` — до пустого, для
# `/v3/` — до `/`), а `{uri}` при пустом пути даёт `/`, поэтому запрос без
# параметров отдаёт чистый `Location: /` без хвоста `?`. Обёртка `route`
# обязательна по той же причине, что у @meraLongPages: внутри `handle`
# порядок директив определяет Caddy, и без неё `redir` выполнится раньше
# `uri`. Проверено на живом Caddy 2.11 (прод — `caddy:2`): все четыре формы
# с параметрами и без дают 301 на `/` с сохранённой query, а лишний
# сегмент (`/trade-in/mera-public/v3/evil`) по-прежнему падает в 404.
@meraV3Gone path /v3 /v3/ /trade-in/mera-public/v3 /trade-in/mera-public/v3/
handle @meraV3Gone {
route {
uri strip_prefix /trade-in/mera-public
uri strip_prefix /v3
redir * {uri} permanent
}
}
# Длинные адреса поддерева → 301 на короткие. Один канонический адрес у
# страницы, а не два работающих.
#
@ -413,7 +446,7 @@ meraocenka.ru {
# порядок директив внутри `handle` определяет Caddy, и без неё `redir`
# выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу
# (бесконечный цикл; поймано на локальном стенде).
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/v3 /trade-in/mera-public/v3/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/docs /trade-in/mera-public/docs/
@meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/docs /trade-in/mera-public/docs/
handle @meraLongPages {
route {
uri strip_prefix /trade-in/mera-public

View file

@ -548,7 +548,25 @@ _TOKEN_TTL = "7 days"
def _require_public_estimate_enabled() -> None:
"""404, пока публичный расчёт не включён владельцем явно."""
"""404, пока публичный расчёт не включён владельцем явно.
ВЕШАЕТСЯ ЧЕРЕЗ `dependencies=[Depends(...)]` НА ДЕКОРАТОР, А НЕ ВЫЗЫВАЕТСЯ
ПЕРВОЙ СТРОКОЙ ТЕЛА. Разница не косметическая: FastAPI решает зависимости
РАНЬШЕ, чем валидирует тело запроса. Пока проверка стояла в теле, до неё
просто не доходило невалидное тело отбивалось 422 ещё на разборе, и
выключенная ручка отвечала так:
POST /api/public/mera/estimate {} 422 + перечень полей
POST /api/public/mera/estimate {валидное} 404
POST /api/public/mera/nosuchthing 401 (rbac)
То есть 422 подтверждал существование ручки (несуществующий путь даёт 401)
и заодно выдавал её схему: address, area_m2, rooms, consent. Замер на проде
29.08.2026, ровно то, что этот докстринг обещал не делать.
Как зависимость проверка срабатывает до разбора тела, и выключенная ручка
неотличима от отсутствующей при ЛЮБОМ входе.
"""
if not settings.public_estimate_enabled:
raise HTTPException(status_code=404, detail="Not Found")
@ -632,7 +650,11 @@ def _coverage_for(
)
@router.post("/estimate", response_model=PublicEstimateResult)
@router.post(
"/estimate",
response_model=PublicEstimateResult,
dependencies=[Depends(_require_public_estimate_enabled)],
)
async def public_estimate(
request: Request,
response: Response,
@ -652,7 +674,6 @@ async def public_estimate(
контур (сосед, `/api/v1/trade-in/r/{token}`) открывает его после оплаты по
своему токену. Наружу здесь уезжает только `PublicEstimateResult`.
"""
_require_public_estimate_enabled()
_enforce(_estimate_limiter, request, "estimate")
daily_retry = _daily_estimate_limiter.retry_after(_ESTIMATE_GLOBAL_KEY)
@ -709,7 +730,11 @@ async def public_estimate(
)
@router.post("/estimate/read", response_model=PublicEstimateResult)
@router.post(
"/estimate/read",
response_model=PublicEstimateResult,
dependencies=[Depends(_require_public_estimate_enabled)],
)
def public_estimate_read(
request: Request,
payload: PublicEstimateTokenInput,
@ -726,7 +751,6 @@ def public_estimate_read(
Просрочка и «нет такого токена» отвечают ОДИНАКОВО (404): различать их
значит подтверждать существование расчёта тому, кто угадал токен.
"""
_require_public_estimate_enabled()
_enforce(_estimate_read_limiter, request, "estimate-read")
row = db.execute(

View file

@ -0,0 +1,797 @@
"""Платёжный роутер МЕРЫ (Т-Банк эквайринг) — PR-D3: checkout, нотификация, выдача.
Проводка уже готовых слоёв: `app/services/payments/*` (подпись, разбор, httpx-
клиент PR-C, БД и конфига не знают), схема `data/sql/233_payments.sql` (PR-B),
периметр (`ratelimit`/`request_audit`/`sentry_scrub` PR-D2). Здесь только
то, чего не было: HTTP-ручки и статус-машина.
ВСЁ за kill-switch `settings.payments_enabled` (дефолт False): при выключенном
контуре каждая ручка отвечает 503 и не ходит ни в банк, ни в платёжные таблицы.
Merge безопасен на выключенном контуре на проде это ровно ноль изменений
поведения, пока владелец не выставит PAYMENTS_ENABLED=true вместе с ключами
терминала (`app/main.py` роняет старт, если включить без ключей).
Идемпотентность держится на БД, а не на «проверить-потом-вставить»
Все три гонки, которые здесь реальны (двойной клик по кнопке оплаты; банк шлёт
AUTHORIZED и CONFIRMED одновременно; банк ретраит нотификацию почасово сутки),
закрыты UNIQUE-ключами миграций 233 и 279 + `ON CONFLICT DO NOTHING`. Пара
«SELECT, потом INSERT» здесь была бы дефектом: между ними успевает пройти
параллельный запрос, и выдача (или холд на карте) происходит дважды. SELECT
живого платежа в `checkout` остался, но только как быстрый путь для честного
повтора гонку ловит не он, а `payments_live_estimate_product_uidx`.
`payment_notifications.processed_at` единственный признак «выдача
состоялась». Наличие строки нотификации таким признаком НЕ является: процесс
мог упасть между INSERT нотификации и выдачей, и тогда ретрай банка обязан
довести выдачу до конца, а не ответить "OK" на полпути (см. блок про
processed_at в шапке 233_payments.sql).
Никаких исходящих HTTP внутри notify
Бюджет одного вызова `TBankClient` до ~74 с (см. его докстринг), окно ответа
банку порядка 10 с. Поэтому обработчик нотификации только валидирует,
сохраняет и отвечает `"OK"` текстом (банк ждёт именно эту строку); любая сверка
с банком (GetState/CheckOrder) задача реконсиляции, вне HTTP-цикла.
Доставка купленного: capability-ссылка
`/r/<token>` непредсказуемый `secrets.token_urlsafe(32)`, лежит в
`payment_entitlements.subject` (колонка документирована как «username или
anon-token, кому выдано»). Право доступа сам токен: у покупателя-физлица на
meraocenka.ru идентичности нет и не будет. `ref_id` при этом остаётся
`estimate_id` как задокументировано в миграции: именно на `(payment_id, kind,
ref_id)` держится UNIQUE «выдали один раз», и подстановка туда случайного
токена молча отменила бы эту гарантию (каждый повтор дал бы новый ref_id
новую строку).
Токен НЕ логируется: ни в `logger.*` здесь, ни в GlitchTip путь `/r/<token>`
режет `redact_report_link_token` в `app/observability/sentry_scrub.py`.
"""
from __future__ import annotations
import asyncio
import json
import logging
import secrets
from datetime import UTC, datetime
from typing import Annotated, Any
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.api.v1.trade_in import (
ESTIMATE_READABLE_SQL,
_assert_estimate_access,
load_estimate,
)
from app.core.config import settings
from app.core.db import get_db
from app.schemas.trade_in import AggregatedEstimate
from app.services.payments.notification import NotificationParseError, parse_notification
from app.services.payments.receipt import ReceiptItem, build_receipt, receipt_total_kopecks
from app.services.payments.tbank_client import TBankApiError, TBankClient
from app.services.payments.token import verify_notification_token
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Каталог ───────────────────────────────────────────────────────────────────
# Цена — НЕ из тела запроса (иначе клиент назначает её сам), а из этой таблицы
# по product_code. 15 000 копеек = 150 ₽ = `SERVICE_PRICE_RUB` в
# frontend/src/app/mera-public/content.ts, где цена названа в оферте (п. 4.1) и
# в политике возврата. Рассинхронизацию ловит тест
# tests/test_payments_router.py::test_price_matches_published_offer — цена,
# отличающаяся от опубликованной в оферте, это не баг рендера, а неисполнение
# договора.
PRODUCT_PAID_REPORT = "paid_report"
_PRODUCTS: dict[str, tuple[str, int]] = {
# product_code: (наименование позиции чека — <=128 символов, цена в копейках)
PRODUCT_PAID_REPORT: ("Оценка стоимости квартиры (онлайн-отчёт)", 15_000),
}
ENTITLEMENT_REPORT_LINK = "report_link"
# Полный список из CHECK payments_status_check (233_payments.sql). Любой статус
# вне списка пишется как 'UNKNOWN' — контракт миграции: тихо исказить статус
# хуже, чем громко упасть, но и падать на нотификации нельзя (банк ретраит
# сутки). Дрейф относительно миграции ловит
# tests/test_payments_router.py::test_status_whitelist_matches_migration.
_KNOWN_STATUSES = frozenset(
{
"NEW",
"FORM_SHOWED",
"DEADLINE_EXPIRED",
"CANCELED",
"PREAUTHORIZING",
"AUTHORIZING",
"AUTHORIZED",
"AUTH_FAIL",
"REJECTED",
"3DS_CHECKING",
"3DS_CHECKED",
"CHECKING",
"CHECKED",
"PROCESSING",
"CONFIRMING",
"CONFIRMED",
"COMPLETING",
"COMPLETED",
"REVERSING",
"PARTIAL_REVERSED",
"REVERSED",
"REFUNDING",
"PARTIAL_REFUNDED",
"REFUNDED",
"REFUND_FAILED",
"UNKNOWN",
}
)
# Статусы ДО подтверждения: нотификация с таким статусом не имеет права
# затереть уже проставленный CONFIRMED. Банк не гарантирует порядок доставки
# (AUTHORIZED и CONFIRMED уходят одновременно при одностадийной оплате), а
# ретрай «отставшей» нотификации может прийти через час — без этой проверки
# оплаченный платёж откатился бы в AUTHORIZED и отчёт перестал бы выдаваться.
_PRE_CONFIRM_STATUSES = frozenset(
{
"NEW",
"FORM_SHOWED",
"PREAUTHORIZING",
"AUTHORIZING",
"AUTHORIZED",
"3DS_CHECKING",
"3DS_CHECKED",
"CHECKING",
"CHECKED",
"PROCESSING",
"CONFIRMING",
}
)
# Платёж в одном из этих статусов ещё «живой»: повторный checkout по той же
# оценке обязан вернуть ту же ссылку, а не создавать второй холд на карте
# покупателя. Терминальные (CANCELED/REJECTED/REFUNDED/...) сюда не входят —
# после отказа человек вправе попробовать оплатить заново.
#
# Этот же список — предикат частичного UNIQUE(estimate_id, product_code)
# миграции 279, который и делает «один живой платёж» свойством БД, а не
# порядка выполнения. Расхождение кода и миграции ловит
# tests/test_payments_router.py::test_live_status_predicate_matches_code.
_REUSABLE_STATUSES = _PRE_CONFIRM_STATUSES
# Статусы, из которых платёж НИКОГДА не выйдет сам: банк шлёт нотификации по
# исходу платежа, а не по факту «форма открыта», поэтому брошенный checkout
# остаётся в NEW/FORM_SHOWED навсегда. Без границы по времени покупатель
# получал бы на каждый повторный checkout одну и ту же ссылку — а она у банка
# уже протухла, и начать оплату заново становилось бы невозможно.
#
# Границу двигаем ТОЛЬКО по этим двум статусам. Всё, что дальше по цепочке
# (AUTHORIZING/AUTHORIZED/3DS_*/CHECKING/PROCESSING/CONFIRMING), означает, что
# карточный поток уже начался и деньги, возможно, захолдированы: пометить такое
# «просроченным» и выдать вторую ссылку — это и есть второй холд. Разгребать
# зависшие карточные статусы — работа реконсиляции (PR-E, GetState), а не
# checkout'а.
_ABANDONABLE_STATUSES = frozenset({"NEW", "FORM_SHOWED"})
# 30 минут. Обоснование срока: за это окно нельзя «случайно» не дойти до карты —
# ввод карты и 3DS укладываются в минуты, а как только поток начался, статус
# уходит из _ABANDONABLE_STATUSES и окно к платежу вообще не применяется.
# Значит, к моменту истечения окна карточный поток провабельно не начинался, и
# освободить пару (estimate_id, product_code) под новую попытку безопасно.
# Остаточный риск честно называем: если покупатель через час всё-таки дооплатит
# СТАРУЮ форму, банк подтвердит её (выдача состоится по ней — статус-машина
# ниже это переживает), а новая попытка так и останется неоплаченной; два
# списания требуют, чтобы человек намеренно оплатил обе формы.
_ABANDONED_AFTER_MINUTES = 30
_ORDER_ID_PREFIX = "mera-"
# 32 байта энтропии (43 символа base64url) — перебор capability-ссылки
# неосуществим, а сама ссылка остаётся кликабельной в мессенджере.
_REPORT_TOKEN_BYTES = 32
def _require_enabled() -> None:
"""Kill-switch контура. 503, а не 404: путь существует, приём оплаты выключен."""
if not settings.payments_enabled:
raise HTTPException(status_code=503, detail="payments are disabled")
def _client() -> TBankClient:
return TBankClient(
terminal_key=settings.tbank_terminal_key,
password=settings.tbank_password.get_secret_value(),
base_url=settings.tbank_api_base_url,
)
class CheckoutInput(BaseModel):
estimate_id: UUID
product_code: str = Field(default=PRODUCT_PAID_REPORT, max_length=64)
# Чек 54-ФЗ требует Email ИЛИ Phone. Оба опциональны здесь и проверяются
# только когда чек включён (TBANK_RECEIPT_ENABLED) — до подключения ОФД
# требовать контакт незачем.
customer_email: str | None = Field(default=None, max_length=254)
customer_phone: str | None = Field(default=None, max_length=32)
class CheckoutOut(BaseModel):
order_id: str
payment_url: str
amount_kopecks: int
status: str
class ReportLinkOut(BaseModel):
"""Статус оплаты для экрана «после оплаты».
`report_url` None, пока выдача не состоялась. Именно None, а не
правдоподобная ссылка «которая скоро заработает»: пустой результат честнее
ссылки, ведущей в 404.
"""
order_id: str
status: str
report_url: str | None
@router.post("/payments/checkout", response_model=CheckoutOut)
def checkout(
payload: CheckoutInput,
db: Annotated[Session, Depends(get_db)],
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> CheckoutOut:
"""Создаёт платёж и возвращает `PaymentURL` формы Т-Банка.
Идемпотентность свойство БД, а не порядка выполнения: частичный UNIQUE
(estimate_id, product_code) по живым статусам (миграция 279) физически не
даёт существовать двум живым платежам по одной оценке, а `ON CONFLICT DO
NOTHING` превращает проигрыш в гонке в ответ, а не во второй `Init` (и,
значит, во второй холд на карте покупателя).
Три исхода: живой платёж с готовой ссылкой 200 с ТОЙ ЖЕ ссылкой; параллельный
checkout ещё не дошёл до ответа банка 409 (ретрай через секунду вернёт
ссылку); иначе создаём новый платёж.
"""
_require_enabled()
product = _PRODUCTS.get(payload.product_code)
if product is None:
raise HTTPException(status_code=400, detail="unknown product_code")
item_name, amount_kopecks = product
estimate = db.execute(
text(
f"""
SELECT id, created_by
FROM trade_in_estimates
WHERE id = CAST(:id AS uuid) AND {ESTIMATE_READABLE_SQL}
"""
),
{"id": str(payload.estimate_id)},
).fetchone()
if estimate is None:
raise HTTPException(status_code=404, detail="estimate not found or expired")
if estimate.created_by is not None:
# Тот же IDOR-гвард (#690), что у остальных ручек по оценке. Он здесь не
# про «показать чужой отчёт напрямую», а про две другие двери: checkout
# по чужому estimate_id возвращает order_id чужого живого платежа (ветка
# переиспользования ниже), а order_id — это право доступа для
# /payments/status/<order_id>, который отдаёт capability-ссылку на отчёт,
# как только владелец заплатит.
#
# Проверяем только оценки, у которых владелец ЕСТЬ. У анонимной покупки
# на meraocenka.ru идентичности нет (см. блок про capability-ссылку в
# шапке модуля), и правом там работает сам неугадываемый estimate_id —
# требовать заголовок означало бы сделать анонимный checkout
# невозможным, а не более безопасным.
_assert_estimate_access(estimate.created_by, x_authenticated_user)
# Освобождаем пару (estimate_id, product_code) от брошенных попыток ДО
# проверки живого платежа: иначе и переиспользование вернуло бы мёртвую
# ссылку, и UNIQUE миграции 279 не дал бы создать новую (см. комментарий у
# _ABANDONED_AFTER_MINUTES).
db.execute(
text(
"""
UPDATE payments
SET status = 'DEADLINE_EXPIRED', updated_at = NOW()
WHERE estimate_id = CAST(:estimate_id AS uuid)
AND product_code = :product_code
AND status = ANY(CAST(:abandonable AS text[]))
AND created_at < NOW() - make_interval(mins => CAST(:mins AS int))
"""
),
{
"estimate_id": str(payload.estimate_id),
"product_code": payload.product_code,
"abandonable": sorted(_ABANDONABLE_STATUSES),
"mins": _ABANDONED_AFTER_MINUTES,
},
)
existing = _find_live_payment(db, payload)
if existing is not None:
return CheckoutOut(
order_id=existing.order_id,
payment_url=existing.payment_url,
amount_kopecks=existing.amount_kopecks,
status=existing.status,
)
receipt = _build_receipt_or_none(item_name, amount_kopecks, payload)
# order_id генерируем СВОЙ и до похода в банк: он и есть ключ, по которому
# нотификация найдёт платёж, а UNIQUE(order_id) — страховка от двойной
# записи. 5 + 32 = 37 символов, влезает в CHECK(char_length <= 50).
order_id = f"{_ORDER_ID_PREFIX}{uuid4().hex}"
inserted = db.execute( # fetchone() ДО commit(): курсор после коммита пуст
text(
"""
INSERT INTO payments (
order_id, terminal_key, product_code, amount_kopecks, status,
created_by, estimate_id, customer_email, customer_phone
) VALUES (
:order_id, :terminal_key, :product_code, :amount, 'NEW',
:created_by, CAST(:estimate_id AS uuid), :email, :phone
)
ON CONFLICT DO NOTHING
RETURNING order_id
"""
),
{
"order_id": order_id,
"terminal_key": settings.tbank_terminal_key,
"product_code": payload.product_code,
"amount": amount_kopecks,
"created_by": x_authenticated_user,
"estimate_id": str(payload.estimate_id),
"email": payload.customer_email,
"phone": payload.customer_phone,
},
).fetchone()
db.commit()
if inserted is None:
# Гонку выиграл параллельный checkout (двойной клик). Своего Init не
# делаем ни при каких условиях — он и есть второй холд.
rival = _find_live_payment(db, payload)
if rival is not None:
return CheckoutOut(
order_id=rival.order_id,
payment_url=rival.payment_url,
amount_kopecks=rival.amount_kopecks,
status=rival.status,
)
# Соперник вставил строку, но ответа банка ещё не получил: ссылки пока
# нет ни у кого. Честный 409 — придумать ссылку нечем, а ждать чужого
# Init внутри HTTP-цикла нельзя (его бюджет — до ~74 с).
logger.info("checkout: параллельный checkout ещё в полёте, estimate_id известен клиенту")
raise HTTPException(status_code=409, detail="checkout already in progress, retry shortly")
try:
# asyncio.run в синхронном хендлере — тот же мост, что и
# trade_in._try_revive_dead_estimate: Starlette гоняет `def`-хендлер в
# threadpool, поэтому свой event loop здесь никому не мешает, а
# остальные db.execute() остаются синхронными.
init = asyncio.run(
_client().init_payment(
order_id=order_id,
amount_kopecks=amount_kopecks,
description=item_name,
notification_url=settings.tbank_notification_url or None,
success_url=settings.tbank_success_url or None,
fail_url=settings.tbank_fail_url or None,
receipt=receipt,
pay_type=settings.tbank_pay_type,
)
)
except TBankApiError as exc:
# Запись остаётся в БД со статусом NEW и текстом ошибки — иначе факт
# попытки (и возможного холда, если обрыв случился после приёма запроса
# банком) не остался бы нигде. Слепой повтор Init по тому же order_id
# запрещён (см. докстринг init_payment) — это работа реконсиляции.
db.execute(
text(
"""
UPDATE payments
SET error_code = :code, error_message = :message, updated_at = NOW()
WHERE order_id = :order_id
"""
),
{"code": exc.error_code[:64], "message": str(exc)[:500], "order_id": order_id},
)
db.commit()
logger.warning("checkout: Init отклонён банком, order_id=%s: %s", order_id, exc)
raise HTTPException(status_code=502, detail="payment provider error") from exc
payment_url = init.get("PaymentURL")
tbank_payment_id = init.get("PaymentId")
if not isinstance(payment_url, str) or not payment_url:
# Success:true без PaymentURL — контракт банка нарушен; выдумывать
# ссылку нечем.
logger.error("checkout: Init без PaymentURL, order_id=%s", order_id)
raise HTTPException(status_code=502, detail="payment provider returned no payment url")
status = init.get("Status")
db.execute(
text(
"""
UPDATE payments
SET tbank_payment_id = :payment_id,
payment_url = :payment_url,
status = :status,
init_response = CAST(:init AS jsonb),
updated_at = NOW()
WHERE order_id = :order_id
"""
),
{
"payment_id": str(tbank_payment_id) if tbank_payment_id is not None else None,
"payment_url": payment_url,
"status": status if status in _KNOWN_STATUSES else "UNKNOWN",
"init": json.dumps(init, ensure_ascii=False),
"order_id": order_id,
},
)
db.commit()
return CheckoutOut(
order_id=order_id,
payment_url=payment_url,
amount_kopecks=amount_kopecks,
status=status if status in _KNOWN_STATUSES else "UNKNOWN",
)
def _find_live_payment(db: Session, payload: CheckoutInput) -> Any:
"""Живой платёж по этой оценке, у которого уже есть ссылка на форму.
`payment_url IS NOT NULL` обязателен: строка без ссылки это чужой checkout
в полёте (Init ещё не ответил), и отдавать по ней `payment_url=None` значило
бы соврать в схеме ответа. Такой случай 409 у вызывающей стороны.
"""
return db.execute(
text(
"""
SELECT order_id, payment_url, amount_kopecks, status
FROM payments
WHERE estimate_id = CAST(:estimate_id AS uuid)
AND product_code = :product_code
AND payment_url IS NOT NULL
AND status = ANY(CAST(:reusable AS text[]))
ORDER BY created_at DESC
LIMIT 1
"""
),
{
"estimate_id": str(payload.estimate_id),
"product_code": payload.product_code,
"reusable": sorted(_REUSABLE_STATUSES),
},
).fetchone()
def _build_receipt_or_none(
item_name: str, amount_kopecks: int, payload: CheckoutInput
) -> dict[str, Any] | None:
"""Чек 54-ФЗ — только когда владелец включил его и задал систему налогообложения.
Сверка `receipt_total_kopecks == Init.Amount` здесь и есть тот инвариант,
который `build_receipt` намеренно не проверяет у себя (см. его докстринг):
чек на сумму, отличную от списанной, это фискальное нарушение, а не
косметика.
"""
if not settings.tbank_receipt_enabled:
return None
if not settings.tbank_taxation:
logger.error("checkout: TBANK_RECEIPT_ENABLED=true, но TBANK_TAXATION не задан")
raise HTTPException(status_code=503, detail="receipt is not configured")
if not payload.customer_email and not payload.customer_phone:
raise HTTPException(status_code=400, detail="customer_email or customer_phone is required")
receipt = build_receipt(
items=[ReceiptItem(name=item_name, price_kopecks=amount_kopecks)],
taxation=settings.tbank_taxation, # type: ignore[arg-type]
email=payload.customer_email,
phone=payload.customer_phone,
)
if receipt_total_kopecks(receipt) != amount_kopecks:
logger.error("checkout: сумма чека разошлась с суммой заказа")
raise HTTPException(status_code=500, detail="receipt total mismatch")
return receipt
@router.post("/payments/notify")
async def notify(request: Request, db: Annotated[Session, Depends(get_db)]) -> Response:
"""Вебхук Т-Банка. Отвечает `"OK"` текстом — банк ждёт именно эту строку.
Любой другой ответ банк трактует как недоставленную нотификацию и ретраит.
Поэтому "OK" отдаётся и на том, что мы обработать не можем, но что нашей
проблемой не является (неизвестный OrderId); отказ (4xx) остаётся только
там, где принять событие означало бы солгать про деньги: неверная подпись
и расхождение суммы.
"""
_require_enabled()
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="malformed body") from None
if not isinstance(body, dict):
raise HTTPException(status_code=400, detail="malformed body")
token_valid = verify_notification_token(body, settings.tbank_password.get_secret_value())
# Пишем сырой лог ДО любых выводов о содержимом (включая невалидную
# подпись): append-only лог входящих — единственное место, где остаётся
# факт попытки. Ключи читаем «как есть», без разбора: разбор может упасть,
# запись факта — нет.
notif = _log_notification(db, body, token_valid=token_valid)
if not token_valid:
db.commit()
logger.warning("notify: подпись не сошлась — нотификация отвергнута")
raise HTTPException(status_code=403, detail="invalid token")
try:
parsed = parse_notification(body)
except NotificationParseError as exc:
db.commit()
logger.warning("notify: тело не разобралось: %s", exc)
raise HTTPException(status_code=400, detail="malformed notification") from None
if notif is not None and notif.processed_at is not None:
# Выдача по этой нотификации уже состоялась — ретрай банка.
db.commit()
return Response(content="OK", media_type="text/plain")
payment = db.execute(
text(
"""
SELECT id, order_id, status, amount_kopecks, estimate_id, created_by
FROM payments
WHERE order_id = :order_id
"""
),
{"order_id": parsed.order_id},
).fetchone()
if payment is None:
# Чужой/устаревший OrderId. Ретраи не помогут — отвечаем "OK", факт
# уже лежит в payment_notifications.
db.commit()
logger.warning("notify: нотификация по неизвестному order_id")
return Response(content="OK", media_type="text/plain")
if parsed.amount_kopecks != payment.amount_kopecks:
# Подпись Т-Банка конкатенирует значения БЕЗ разделителя, поэтому сама
# по себе не гарантирует сумму (см. докстринг notification.py). Сверка
# с суммой, записанной при Init, — единственная реальная защита.
db.commit()
logger.error(
"notify: сумма нотификации разошлась с суммой заказа (order_id=%s)", parsed.order_id
)
raise HTTPException(status_code=400, detail="amount mismatch")
status = parsed.status if parsed.status in _KNOWN_STATUSES else "UNKNOWN"
if not (payment.status == "CONFIRMED" and status in _PRE_CONFIRM_STATUSES):
_apply_status(db, payment.order_id, status, parsed.payment_id)
if status == "CONFIRMED" and parsed.success:
_fulfill(db, payment_id=payment.id, estimate_id=payment.estimate_id)
if notif is not None:
# processed_at ставится ПОСЛЕ выдачи — иначе ретрай банка увидел бы
# «обработано» по нотификации, выдача по которой не состоялась.
db.execute(
text(
"UPDATE payment_notifications SET processed_at = NOW() "
"WHERE id = :id AND processed_at IS NULL"
),
{"id": notif.id},
)
db.commit()
return Response(content="OK", media_type="text/plain")
def _log_notification(db: Session, body: dict[str, Any], *, token_valid: bool) -> Any:
"""INSERT ... ON CONFLICT DO NOTHING + добор уже существующей строки.
Дедуп делает БД (UNIQUE NULLS NOT DISTINCT на (tbank_payment_id, status,
amount_kopecks, token), миграция 233), а не «SELECT, потом INSERT»: между
проверкой и вставкой проходит параллельный ретрай банка, и выдача
происходит дважды.
Если строка уже была достаём её тем же ключом через IS NOT DISTINCT FROM
(зеркало NULLS NOT DISTINCT), потому что решение «выдавать или нет»
принимается по её `processed_at`, а не по факту существования.
"""
key = {
"order_id": _raw_str(body.get("OrderId")),
"payment_id": _raw_str(body.get("PaymentId")),
"status": _raw_str(body.get("Status")),
"amount": body.get("Amount") if isinstance(body.get("Amount"), int) else None,
"token": _raw_str(body.get("Token")),
}
inserted = db.execute(
text(
"""
INSERT INTO payment_notifications (
order_id, tbank_payment_id, status, amount_kopecks, token, token_valid, body
) VALUES (
:order_id, :payment_id, :status, :amount, :token, :token_valid,
CAST(:body AS jsonb)
)
ON CONFLICT DO NOTHING
RETURNING id, processed_at
"""
),
{**key, "token_valid": token_valid, "body": json.dumps(body, ensure_ascii=False)},
).fetchone()
if inserted is not None:
return inserted
return db.execute(
text(
"""
SELECT id, processed_at
FROM payment_notifications
WHERE tbank_payment_id IS NOT DISTINCT FROM :payment_id
AND status IS NOT DISTINCT FROM :status
AND amount_kopecks IS NOT DISTINCT FROM :amount
AND token IS NOT DISTINCT FROM :token
"""
),
key,
).fetchone()
def _raw_str(value: Any) -> str | None:
"""Значение для сырого лога: строка как есть, всё остальное — NULL.
Приводить чужие типы к строке здесь нельзя: дедуп-ключ должен совпадать у
оригинала и ретрая побайтово, а `str(1)` и `"1"` уже разные истории.
"""
return value if isinstance(value, str) else None
def _apply_status(db: Session, order_id: str, status: str, tbank_payment_id: str) -> None:
"""Двигает статус платежа и проставляет отметки времени переходов."""
db.execute(
text(
"""
UPDATE payments
SET status = :status,
tbank_payment_id = COALESCE(tbank_payment_id, :payment_id),
authorized_at = CASE WHEN :status = 'AUTHORIZED'
THEN COALESCE(authorized_at, NOW()) ELSE authorized_at END,
confirmed_at = CASE WHEN :status = 'CONFIRMED'
THEN COALESCE(confirmed_at, NOW()) ELSE confirmed_at END,
refunded_at = CASE WHEN :status IN ('REFUNDED', 'PARTIAL_REFUNDED',
'REVERSED', 'PARTIAL_REVERSED')
THEN COALESCE(refunded_at, NOW()) ELSE refunded_at END,
updated_at = NOW()
WHERE order_id = :order_id
"""
),
{"status": status, "payment_id": tbank_payment_id, "order_id": order_id},
)
def _fulfill(db: Session, *, payment_id: Any, estimate_id: Any) -> None:
"""Выдача: capability-ссылка на оплаченный отчёт + продление хранения оценки.
«Выдали один раз» гарантирует UNIQUE NULLS NOT DISTINCT (payment_id, kind,
ref_id) миграции 233: повторная нотификация не вернёт строку из RETURNING и
второй токен не родится. Проверять существование заранее нельзя гонка.
"""
if estimate_id is None:
logger.error("fulfill: платёж без estimate_id — выдавать нечего")
return
row = db.execute(
text(
"""
INSERT INTO payment_entitlements (payment_id, subject, kind, ref_id, expires_at)
VALUES (
CAST(:payment_id AS uuid), :subject, :kind, CAST(:ref_id AS uuid),
NOW() + make_interval(days => CAST(:days AS int))
)
ON CONFLICT DO NOTHING
RETURNING id
"""
),
{
"payment_id": str(payment_id),
"subject": secrets.token_urlsafe(_REPORT_TOKEN_BYTES),
"kind": ENTITLEMENT_REPORT_LINK,
"ref_id": str(estimate_id),
"days": settings.trade_in_paid_retention_days,
},
).fetchone()
if row is None:
logger.info("fulfill: выдача по этому платежу уже была — повтор не создаётся")
return
# Оплаченная оценка живёт год (retain_until, миграция 240) — иначе purge-
# джоба удалит строку через 24 часа и capability-ссылка укажет в пустоту.
# GREATEST — чтобы повторная покупка не УКОРАЧИВАЛА уже выданный срок.
db.execute(
text(
"""
UPDATE trade_in_estimates
SET retain_until = GREATEST(
COALESCE(retain_until, NOW()),
NOW() + make_interval(days => CAST(:days AS int))
)
WHERE id = CAST(:id AS uuid)
"""
),
{"days": settings.trade_in_paid_retention_days, "id": str(estimate_id)},
)
@router.get("/payments/status/{order_id}", response_model=ReportLinkOut)
def payment_status(
order_id: str,
db: Annotated[Session, Depends(get_db)],
) -> ReportLinkOut:
"""Экран «после оплаты»: статус заказа и ссылка на отчёт, когда выдача была.
Правом здесь работает сам `order_id` 128 бит случайности, известные
только браузеру покупателя (он же уходит в банк как OrderId и возвращается
на SuccessURL). Пока выдачи нет `report_url: null`.
"""
_require_enabled()
row = db.execute(
text(
"""
SELECT p.order_id, p.status, pe.subject AS report_token
FROM payments p
LEFT JOIN payment_entitlements pe
ON pe.payment_id = p.id AND pe.kind = :kind
WHERE p.order_id = :order_id
"""
),
{"order_id": order_id, "kind": ENTITLEMENT_REPORT_LINK},
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="order not found")
return ReportLinkOut(
order_id=row.order_id,
status=row.status,
report_url=f"/api/v1/trade-in/r/{row.report_token}" if row.report_token else None,
)
@router.get("/r/{token}", response_model=AggregatedEstimate)
def report_by_link(
token: str,
db: Annotated[Session, Depends(get_db)],
) -> AggregatedEstimate:
"""Оплаченный отчёт по capability-ссылке — БЕЗ RBAC, право доступа = токен.
Одна и та же 404 на «токена нет», «токен протух» и «оценка удалена»:
различать их значило бы подтверждать существование чужих токенов
перебирающему.
"""
_require_enabled()
row = db.execute(
text(
"""
SELECT ref_id, expires_at
FROM payment_entitlements
WHERE kind = :kind AND subject = :token
"""
),
{"kind": ENTITLEMENT_REPORT_LINK, "token": token},
).fetchone()
if row is None or row.ref_id is None:
raise HTTPException(status_code=404, detail="report not found")
if row.expires_at is not None and row.expires_at.replace(tzinfo=UTC) <= datetime.now(tz=UTC):
raise HTTPException(status_code=404, detail="report not found")
# capability_granted=True: право уже доказано токеном выше. Флаг не является
# полем запроса — подобрать его снаружи нельзя (см. докстринг load_estimate).
return load_estimate(
db, UUID(str(row.ref_id)), x_authenticated_user=None, capability_granted=True
)

View file

@ -627,6 +627,31 @@ def get_estimate(
Возвращает 404 если оценка не найдена или TTL истёк.
"""
return load_estimate(db, estimate_id, x_authenticated_user=x_authenticated_user)
def load_estimate(
db: Session,
estimate_id: UUID,
*,
x_authenticated_user: str | None,
capability_granted: bool = False,
) -> AggregatedEstimate:
"""Тело GET /estimate/{id} без FastAPI-обвязки — чтобы у ВТОРОГО права
доступа был тот же самый загрузчик, а не его копия.
`capability_granted=True` вызывающая сторона уже доказала право доступа
ДРУГИМ способом, чем `X-Authenticated-User` + roles.yaml: capability-ссылка
`/r/<token>` (app/api/v1/payments.py) отдаёт оплаченный отчёт анониму,
у которого идентичности нет и не будет там правом является сам
непредсказуемый токен, сверенный по `payment_entitlements`.
Флаг ИМЕННО параметр обычной функции, а не поле запроса: у route-хендлера
`get_estimate` выше его нет, поэтому подобрать его снаружи (query/заголовком)
невозможно включить его может только код в этом процессе. Обратное
(добавить параметр в сам хендлер с default=False) сделало бы обход IDOR-
гварда #690 доступным любому клиенту через `?capability_granted=true`.
"""
row = db.execute(
text(
f"""
@ -654,7 +679,8 @@ def get_estimate(
if row is None:
raise HTTPException(status_code=404, detail="estimate not found or expired")
_assert_estimate_access(row.created_by, x_authenticated_user)
if not capability_granted:
_assert_estimate_access(row.created_by, x_authenticated_user)
# #incident-2026-08-10: строка «мертва» (median_price<=0/NULL) — посчитана
# ДО фикса оценщика (#oblast-E/#oblast-F, PR #2823/#2825). Пробуем

View file

@ -886,21 +886,17 @@ class Settings(BaseSettings):
"""
return self.scraper_proxy_url_env
# ── Ban-recovery budget knobs (changeip-механизм снят #2616 шаг 2) ────────
# Раньше эти поля тюнили retry/settle для GET-changeip mobileproxy
# (AVITO_PROXY_ROTATE_URL и т.д., см. историю выше) — сама ссылка удалена
# (закрытый аккаунт), поэтому IP-ротация сейчас всегда no-op (_rotate_ip /
# _rotate_proxy_ip возвращают False без сетевого похода). Поля оставлены:
# `*_proxy_max_rotations` продолжают гейтить бюджет попыток в ban-rotation
# state machine (scraper_kit.orchestration.pipeline._try_rotate_within_budget)
# — те же 0 попыток "успеха", что и раньше при мёртвом changeip, просто без
# затрат на HTTP; `avito_proxy_rotate_settle_s` — верхняя граница
# asyncio.wait_for в app.tasks.avito_detail_backfill (страховка от зависания).
# Живая ротация IP — ASOCKS_API_TOKEN / app.services.proxy_rotation (#2611).
# ── Ban-recovery budget knobs (changeip снят #2616 шаг 2) ────────────────
# Живая ротация IP — ASOCKS_API_TOKEN / app.services.proxy_rotation (#2611);
# `_rotate_ip`/`_rotate_proxy_ip` остались no-op после удаления changeip-ссылки.
# Обе ручки читаются: `*_proxy_max_rotations` гейтит бюджет попыток в
# ban-rotation (scraper_kit.orchestration.pipeline._max_rotations),
# `avito_proxy_rotate_settle_s` — верхняя граница asyncio.wait_for в
# app.tasks.avito_detail_backfill.
# #3212: proxy_rotate_attempts / proxy_rotate_attempt_timeout_s удалены —
# ретраи changeip-GET, которых больше нет; ни одного читателя не осталось.
avito_proxy_max_rotations: int = 4
avito_proxy_rotate_settle_s: float = 9.0
proxy_rotate_attempt_timeout_s: float = 8.0
proxy_rotate_attempts: int = 3
# ── ASocks pool-proxy rotation (#2600) ───────────────────────────────────
# Bearer-токен веб-кабинета ASocks для POST .../unlimited-proxy/{portId}/refresh-ip

View file

@ -129,8 +129,27 @@ _PUBLIC_PATHS = frozenset(
# Обе ручки дополнительно закрыты флагом settings.public_estimate_enabled.
"/api/public/mera/estimate",
"/api/public/mera/estimate/read",
# Вебхук Т-Банка (app/api/v1/payments.py::notify): сервер-к-серверу,
# X-Authenticated-User/сессии у банка нет и быть не может. Путь не
# секрет — аутентификацией здесь работает подпись `Token` тела,
# которую проверяет сам хендлер (services/payments/token.py), плюс
# отдельный узкий rate-limit (core/ratelimit.py). Всё остальное
# платёжное (checkout, статус заказа) остаётся ЗАКРЫТЫМ — открыт ровно
# тот путь, который иначе получил бы 401 у банка.
"/api/v1/trade-in/payments/notify",
}
)
# Capability-ссылка на оплаченный отчёт: /api/v1/trade-in/r/<token>. Точной
# строкой её в множество выше не положить — токен переменный, а множество
# проверяется как `path in`. Отдельный кортеж префиксов, а не превращение
# `_PUBLIC_PATHS` в префиксный матчер: тот механизм держит auth-гейт всего
# бэкенда, расширять его семантику ради одного роута нельзя.
#
# Открывается ИМЕННО подпуть /r/ и ничего выше: правом доступа служит сам
# непредсказуемый токен (32 байта энтропии), сверяемый по payment_entitlements
# в app/api/v1/payments.py. У покупателя-физлица на meraocenka.ru идентичности
# нет и не будет, поэтому иного способа отдать ему купленное не существует.
_PUBLIC_PATH_PREFIXES = ("/api/v1/trade-in/r/",)
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
# tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для
# scope-проверки восстанавливаем внешний путь.
@ -217,7 +236,7 @@ async def rbac_guard(
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
path = request.url.path
if path in _PUBLIC_PATHS:
if path in _PUBLIC_PATHS or path.startswith(_PUBLIC_PATH_PREFIXES):
return await call_next(request)
username: str | None = None

View file

@ -31,6 +31,7 @@ from app.api.v1 import (
glitchtip,
lead,
me,
payments,
privacy_admin,
search,
support,
@ -286,6 +287,11 @@ app.include_router(version.router, prefix="/api/v1/trade-in", tags=["trade-in-ve
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"])
app.include_router(glitchtip.router, prefix="/api/v1/trade-in", tags=["trade-in-ops"])
# Платёжный контур — весь за settings.payments_enabled (дефолт False): роутер
# подключён всегда, но каждая его ручка отвечает 503, пока контур выключен.
# Подключать по флагу было бы хуже: путь /payments/notify обязан существовать
# и отвечать предсказуемо, а не менять форму ответа вместе с конфигом.
app.include_router(payments.router, prefix="/api/v1/trade-in", tags=["trade-in-payments"])
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
app.include_router(search.router, prefix="/api/v1", tags=["search"])
app.include_router(me.router, prefix="/api/v1", tags=["me"])

View file

@ -131,6 +131,20 @@ _URL_SECRET_QUERY_REPLACEMENT = r"\g<1>" + _REDACTED
_HTTPX_ERROR_URL_QUERY_RE = re.compile(r"(for url '[^'?]*)\?[^']*(')")
_HTTPX_ERROR_URL_QUERY_REPLACEMENT = r"\g<1>?" + _REDACTED + r"\g<2>"
# Capability-токен оплаченного отчёта живёт В ПУТИ (`/api/v1/trade-in/r/<token>`,
# app/api/v1/payments.py), а не в query и не в теле — значит ни `_PII_KEYS`
# (ключ-based), ни `scrub_payment_request_body` (режет request.data по сегменту
# `/payments/`), ни sentry_sdk `sanitize_url` (режет только userinfo и query)
# его не касаются. А путь попадает в событие несколькими путями сразу:
# `event.request.url`, `transaction`, breadcrumb'ы, текст исключения. Токен —
# это ПРАВО ДОСТУПА целиком: утёкший в GlitchTip путь равен выданному отчёту.
# Поэтому — full-text regex по всему событию, как у TG-токена.
# `[\w-]` покрывает алфавит `secrets.token_urlsafe` (base64url), хвост
# `(?=[/?#]|$)` оставляет нетронутым остаток URL (query/фрагмент) — он полезен
# для диагностики и секретом не является.
_REPORT_LINK_TOKEN_RE = re.compile(r"(/api/v1/trade-in/r/)[\w-]+(?=[/?#]|$)")
_REPORT_LINK_TOKEN_REPLACEMENT = r"\g<1>" + _REDACTED
def _scrub(obj: Any) -> None:
"""Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place)."""
@ -205,6 +219,7 @@ def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
_scrub(event.get("contexts"))
_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)
_regex_redact_inplace(event, _REPORT_LINK_TOKEN_RE, _REPORT_LINK_TOKEN_REPLACEMENT)
return event

View file

@ -46,7 +46,6 @@ import matplotlib
matplotlib.use("Agg") # headless-safe backend — ОБЯЗАТЕЛЬНО до любого рендера: в Docker-образе
# нет GUI toolkit (Tkinter/Qt/GTK), импорт matplotlib без явного backend может тянуть их/падать.
import matplotlib.font_manager as _font_manager
import segno
from matplotlib.figure import Figure # object API, НЕ pyplot — см. _price_range_chart_svg
from matplotlib.patches import Rectangle
@ -441,14 +440,6 @@ def _expires_date(estimate: AggregatedEstimate) -> dt.date:
return dt.date.today() + dt.timedelta(days=30)
def _qr_code_data_url(text: str, size: int = 4) -> str:
"""QR-код как SVG data URL."""
qr = segno.make(text, error="m")
buf = io.BytesIO()
qr.save(buf, kind="svg", scale=size, dark="#1a1d23", light="#ffffff")
return f"data:image/svg+xml;base64,{base64.b64encode(buf.getvalue()).decode('ascii')}"
def _conf_label(confidence: str) -> str:
return {"high": "Высокая", "medium": "Средняя", "low": "Низкая"}.get(confidence, confidence)

View file

@ -141,13 +141,6 @@ def _find_files(dir_path: str, prefix: str) -> list[str]:
# ─────────────────────────────────────────────────────────────────────────────
# Стриминговый парсер (lxml iterparse + очистка)
# ─────────────────────────────────────────────────────────────────────────────
def _local_name(tag: object) -> str:
"""Локальное имя тега без namespace; '' для не-элементов (комментарии/PI)."""
if not isinstance(tag, str):
return ""
return tag.rsplit("}", 1)[-1]
def _stream_rows(path: str, marker_attr: str) -> Iterator[etree._Element]:
"""Yield row-элементов файла, у которых есть атрибут marker_attr, освобождая память.

View file

@ -175,14 +175,6 @@ class RealScraperConfig:
def avito_proxy_rotate_settle_s(self) -> float:
return _settings.avito_proxy_rotate_settle_s
@property
def proxy_rotate_attempts(self) -> int:
return _settings.proxy_rotate_attempts
@property
def proxy_rotate_attempt_timeout_s(self) -> float:
return _settings.proxy_rotate_attempt_timeout_s
@property
def cian_proxy_max_rotations(self) -> int:
return _settings.cian_proxy_max_rotations

View file

@ -0,0 +1,65 @@
-- 279_payments_live_checkout_uidx.sql
-- Идемпотентность checkout на уровне БД: не больше одного ЖИВОГО платежа на
-- пару (estimate_id, product_code).
--
-- ── WHY ──────────────────────────────────────────────────────────────────────
-- До этой миграции переиспользование живого платежа в
-- app/api/v1/payments.py::checkout держалось на «SELECT, потом INSERT» — ровно
-- на том, что шапка того же модуля называет дефектом. Обычный двойной клик по
-- кнопке оплаты даёт два параллельных запроса: оба проходят SELECT до того, как
-- любой из них вставит строку, оба делают INSERT, оба зовут Init — и на карте
-- покупателя появляются ДВА ХОЛДА. Порядок выполнения тут ничего не решает,
-- решает уникальный ключ: второй INSERT обязан отбиться самой БД.
--
-- ── Почему частичный, а не полный UNIQUE(estimate_id, product_code) ──────────
-- Полный запретил бы повторную покупку навсегда: после CANCELED/REJECTED
-- человек вправе попробовать оплатить заново, а после CONFIRMED — купить
-- второй раз. Поэтому в предикате только «живые» статусы — те, при которых
-- платёжная сессия ещё не завершилась. Список = _REUSABLE_STATUSES
-- (= _PRE_CONFIRM_STATUSES) в app/api/v1/payments.py; расхождение ловит
-- tests/test_payments_router.py::test_live_status_predicate_matches_code —
-- индекс, который шире кода, запрещает легитимный повтор, а который уже —
-- пропускает второй холд.
--
-- Предикат ссылается только на неизменяемые выражения (сравнение колонок с
-- литералами), поэтому индекс легален как частичный, а строка входит в него и
-- выходит автоматически при UPDATE статуса.
--
-- estimate_id IS NOT NULL — обязательная часть предиката: колонка nullable
-- (FK ON DELETE SET NULL, 233_payments.sql), а обычный UNIQUE считает NULL
-- отличным от NULL, так что без этого условия строки с NULL просто копились бы
-- в индексе без пользы.
--
-- ── Безопасность применения ─────────────────────────────────────────────────
-- Контур выключен (PAYMENTS_ENABLED=false), таблица на проде пуста
-- (проверено 2026-08-29: SELECT count(*) FROM payments → 0), поэтому обычный
-- CREATE UNIQUE INDEX не может упасть на существующих дублях и не блокирует
-- ничью запись. IF NOT EXISTS — повторное применение безвредно.
BEGIN;
-- Конвенция проекта (#2752): CREATE UNIQUE INDEX на существующей таблице берёт
-- блокировку и без lock_timeout встанет в очередь за чужой сессией.
SET LOCAL lock_timeout = '5s';
CREATE UNIQUE INDEX IF NOT EXISTS payments_live_estimate_product_uidx
ON payments (estimate_id, product_code)
WHERE estimate_id IS NOT NULL
AND status IN (
'NEW',
'FORM_SHOWED',
'PREAUTHORIZING',
'AUTHORIZING',
'AUTHORIZED',
'3DS_CHECKING',
'3DS_CHECKED',
'CHECKING',
'CHECKED',
'PROCESSING',
'CONFIRMING'
);
COMMENT ON INDEX payments_live_estimate_product_uidx IS
'Не больше одного живого платежа на (estimate_id, product_code): двойной '
'клик по кнопке оплаты не создаёт второй холд на карте покупателя.';
COMMIT;

View file

@ -155,8 +155,6 @@ def _config() -> SimpleNamespace:
avito_proxy_max_rotations=0,
avito_serp_ok_not_banned=True,
avito_proxy_rotate_settle_s=0.0,
proxy_rotate_attempts=1,
proxy_rotate_attempt_timeout_s=1.0,
avito_full_load_price_cap=1400,
avito_full_load_max_pages=100,
avito_full_load_concurrency=1,

View file

@ -99,8 +99,6 @@ def _config() -> SimpleNamespace:
avito_proxy_max_rotations=0,
avito_serp_ok_not_banned=True,
avito_proxy_rotate_settle_s=0.0,
proxy_rotate_attempts=1,
proxy_rotate_attempt_timeout_s=1.0,
cian_proxy_max_rotations=0,
yandex_proxy_max_rotations=0,
cian_full_load_per_fetch_timeout_s=0.0,

View file

@ -195,8 +195,6 @@ def _serp_config() -> SimpleNamespace:
avito_proxy_max_rotations=0,
avito_serp_ok_not_banned=True,
avito_proxy_rotate_settle_s=0.0,
proxy_rotate_attempts=1,
proxy_rotate_attempt_timeout_s=1.0,
scraper_skip_seen_today=False,
)

View file

@ -635,12 +635,18 @@ def test_estimate_readable_sql_uses_disjunction() -> None:
def test_get_estimate_sql_built_from_shared_constant() -> None:
"""GET /estimate/{id} SQL filter is built FROM ESTIMATE_READABLE_SQL, not a
hand-copied literal regression guard against the two gates drifting apart
again (that's exactly what happened before this PR: 404 here, 410 in /pdf)."""
again (that's exactly what happened before this PR: 404 here, 410 in /pdf).
Inspects `load_estimate`, not the `get_estimate` route: the payments PR moved
the body there so the paid capability link (`/r/<token>`, app/api/v1/
payments.py) reuses the SAME loader instead of growing a third copy of the
readability gate which is precisely what this guard exists to prevent.
"""
import inspect
from app.api.v1.trade_in import get_estimate
from app.api.v1.trade_in import load_estimate
src = inspect.getsource(get_estimate)
src = inspect.getsource(load_estimate)
assert "ESTIMATE_READABLE_SQL" in src
assert "expires_at > NOW()" not in src, "hand-copied predicate, not the shared constant"
assert "retain_until" in src, "SELECT must also fetch retain_until"

View file

@ -0,0 +1,800 @@
"""Роутер платежей (app/api/v1/payments.py): идемпотентность выдачи, подпись,
kill-switch, capability-ссылка.
ПОЧЕМУ здесь свой мини-эмулятор БД, а не MagicMock. Проверяемое свойство
«повторная нотификация НЕ создаёт вторую выдачу» целиком держится на UNIQUE
из миграции 233 плюс `ON CONFLICT DO NOTHING`. MagicMock отдаёт то, что ему
скажут, поэтому такой тест был бы зелёным по построению: он не покраснел бы,
если убрать `ON CONFLICT` или заменить его на «SELECT, потом INSERT».
`_FakeDb` ниже объявляет UNIQUE-ключи ОТДЕЛЬНО от проверяемого SQL ровно
теми колонками, что записаны в миграции, и ведёт себя как Postgres: дубль
без `ON CONFLICT` падает ошибкой, дубль с `ON CONFLICT DO NOTHING` не
возвращает строку.
Фальсификация проверена руками (каждый раз краснеет ИМЕННО тот тест, который
про это свойство, и по значению, а не по ImportError):
- убрать `ON CONFLICT DO NOTHING` из INSERT в `payment_entitlements`
`test_retry_after_crash_between_issue_and_processed_does_not_double_issue`
падает 500 вместо "OK";
- убрать его же из INSERT в `payment_notifications` падают все три теста
про идемпотентность;
- отключить проверку подписи `test_notification_with_invalid_token_is_rejected`;
- отключить проверку срока `test_report_link_rejects_expired_token`;
- убрать `ON CONFLICT DO NOTHING` из INSERT в `payments`
`test_parallel_checkout_does_not_create_second_payment` краснеет
_UniqueViolationError вместо 409;
- растянуть `_ABANDONED_AFTER_MINUTES` до бесконечности (= убрать границу по
времени) `test_abandoned_checkout_does_not_lock_the_buyer_out` получает в
ответе мёртвую ссылку красное ПО ЗНАЧЕНИЮ;
- убрать `_assert_estimate_access` из checkout `test_checkout_rejects_foreign_estimate`
видит 200 и созданный платёж вместо 404.
SQLite вместо этого не годится: NULLS NOT DISTINCT, jsonb, make_interval и
CAST(:x AS uuid) там не существуют, а настоящий Postgres в юнит-тестах этого
репозитория не поднимается (см. tests/conftest.py DATABASE_URL заглушка).
"""
from __future__ import annotations
import os
import re
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
import pytest # noqa: E402
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from pydantic import SecretStr # noqa: E402
_PASSWORD = "test-terminal-password"
_ORDER_ID = "mera-0123456789abcdef0123456789abcdef"
_PAYMENT_ID = "3000000001"
_PAYMENT_UUID = "22222222-2222-2222-2222-222222222222"
_ESTIMATE_UUID = "33333333-3333-3333-3333-333333333333"
_AMOUNT = 15_000
_REPO_ROOT = Path(__file__).resolve().parents[1].parent
_MIGRATION = _REPO_ROOT / "backend" / "data" / "sql" / "233_payments.sql"
_CONTENT_TS = _REPO_ROOT / "frontend" / "src" / "app" / "mera-public" / "content.ts"
class _UniqueViolationError(RuntimeError):
"""Стенд-in для psycopg UniqueViolation — INSERT без ON CONFLICT в дубль."""
class _FakeDb:
"""Мини-Postgres на словарях: только те statement'ы, что шлёт роутер.
UNIQUE-ключи заданы ЗДЕСЬ, по миграции 233, а не выведены из проверяемого
SQL иначе тест поедет вслед за дефектом вместо того, чтобы его поймать.
NULL считается равным NULL (NULLS NOT DISTINCT), как в миграции.
"""
_NOTIFICATION_KEY = ("tbank_payment_id", "status", "amount_kopecks", "token")
_ENTITLEMENT_KEY = ("payment_id", "kind", "ref_id")
# Частичный UNIQUE миграции 279: ключ (estimate_id, product_code), предикат —
# «живые» статусы. Переписан здесь ПО МИГРАЦИИ, а не импортирован из
# payments.py: иначе тест поехал бы вслед за дефектом. Совпадение списка с
# кодом отдельно гейтит test_live_status_predicate_matches_code.
_LIVE_PAYMENT_KEY = ("estimate_id", "product_code")
_LIVE_STATUSES = frozenset(
{
"NEW",
"FORM_SHOWED",
"PREAUTHORIZING",
"AUTHORIZING",
"AUTHORIZED",
"3DS_CHECKING",
"3DS_CHECKED",
"CHECKING",
"CHECKED",
"PROCESSING",
"CONFIRMING",
}
)
def __init__(self) -> None:
self.notifications: list[SimpleNamespace] = []
self.entitlements: list[SimpleNamespace] = []
self.payments: list[SimpleNamespace] = [
SimpleNamespace(
id=_PAYMENT_UUID,
order_id=_ORDER_ID,
status="NEW",
amount_kopecks=_AMOUNT,
estimate_id=_ESTIMATE_UUID,
created_by=None,
# product_code=None — эта строка обслуживает тесты нотификаций и
# не должна попадать в ключ живого платежа checkout-тестов.
product_code=None,
payment_url=None,
created_at=datetime.now(tz=UTC),
)
]
self.estimate_created_by: str | None = None
self.retain_until_updates: list[str] = []
self.commits = 0
# -- SQLAlchemy-совместимая поверхность -------------------------------
def execute(self, statement: Any, params: dict[str, Any] | None = None) -> Any:
sql = " ".join(str(statement).split())
params = params or {}
if "INSERT INTO payment_notifications" in sql:
return self._insert_notification(sql, params)
if "FROM payment_notifications" in sql and sql.startswith("SELECT"):
return _Result(self._find_notification(params))
if "UPDATE payment_notifications" in sql:
for row in self.notifications:
if row.id == params["id"] and row.processed_at is None:
row.processed_at = datetime.now(tz=UTC)
return _Result(None)
if "INSERT INTO payment_entitlements" in sql:
return self._insert_entitlement(sql, params)
if "FROM payment_entitlements" in sql and sql.startswith("SELECT"):
return _Result(
next(
(
row
for row in self.entitlements
if row.kind == params["kind"] and row.subject == params["token"]
),
None,
)
)
if "INSERT INTO payments" in sql:
return self._insert_payment(sql, params)
if "FROM payments" in sql and sql.startswith("SELECT"):
if "estimate_id" in params:
return _Result(self._find_live_payment(params))
return _Result(
next((p for p in self.payments if p.order_id == params.get("order_id")), None)
)
if "UPDATE payments" in sql and "DEADLINE_EXPIRED" in sql:
return _Result(self._expire_abandoned(params))
if "UPDATE payments" in sql:
for payment in self.payments:
if payment.order_id == params["order_id"] and "status" in params:
payment.status = params["status"]
if "payment_url" in params:
payment.payment_url = params["payment_url"]
return _Result(None)
if "FROM trade_in_estimates" in sql and sql.startswith("SELECT"):
if params.get("id") != _ESTIMATE_UUID:
return _Result(None)
return _Result(SimpleNamespace(id=_ESTIMATE_UUID, created_by=self.estimate_created_by))
if "UPDATE trade_in_estimates" in sql:
self.retain_until_updates.append(params["id"])
return _Result(None)
raise AssertionError(f"неожиданный SQL в тесте: {sql[:120]}")
def commit(self) -> None:
self.commits += 1
def close(self) -> None:
pass
# -- эмуляция UNIQUE ---------------------------------------------------
def _insert_notification(self, sql: str, params: dict[str, Any]) -> _Result:
key = tuple(
params[name]
for name in ("payment_id", "status", "amount", "token") # порядок = _NOTIFICATION_KEY
)
if any(self._key_of(row, self._NOTIFICATION_KEY) == key for row in self.notifications):
return self._conflict(sql)
row = SimpleNamespace(
id=len(self.notifications) + 1,
order_id=params["order_id"],
tbank_payment_id=params["payment_id"],
status=params["status"],
amount_kopecks=params["amount"],
token=params["token"],
token_valid=params["token_valid"],
processed_at=None,
)
self.notifications.append(row)
return _Result(row)
def _insert_payment(self, sql: str, params: dict[str, Any]) -> _Result:
"""Ведёт себя как Postgres с частичным UNIQUE миграции 279.
Конфликт наступает только когда УЖЕ есть строка с той же парой
(estimate_id, product_code) И статусом из предиката ровно как у
частичного индекса. Без `ON CONFLICT DO NOTHING` в SQL исключение.
"""
key = (params["estimate_id"], params["product_code"])
if key[0] is not None and any(
self._key_of(row, self._LIVE_PAYMENT_KEY) == key and row.status in self._LIVE_STATUSES
for row in self.payments
):
return self._conflict(sql)
row = SimpleNamespace(
id=f"pay-{len(self.payments) + 1}",
order_id=params["order_id"],
status="NEW",
amount_kopecks=params["amount"],
estimate_id=params["estimate_id"],
created_by=params["created_by"],
product_code=params["product_code"],
payment_url=None,
created_at=datetime.now(tz=UTC),
)
self.payments.append(row)
return _Result(row)
def _find_live_payment(self, params: dict[str, Any]) -> SimpleNamespace | None:
return next(
(
row
for row in self.payments
if self._key_of(row, self._LIVE_PAYMENT_KEY)
== (params["estimate_id"], params["product_code"])
and row.payment_url is not None
and row.status in set(params["reusable"])
),
None,
)
def _expire_abandoned(self, params: dict[str, Any]) -> None:
deadline = datetime.now(tz=UTC) - timedelta(minutes=int(params["mins"]))
for row in self.payments:
if (
row.estimate_id == params["estimate_id"]
and row.product_code == params["product_code"]
and row.status in set(params["abandonable"])
and row.created_at < deadline
):
row.status = "DEADLINE_EXPIRED"
return None
def _insert_entitlement(self, sql: str, params: dict[str, Any]) -> _Result:
key = (params["payment_id"], params["kind"], params["ref_id"])
if any(self._key_of(row, self._ENTITLEMENT_KEY) == key for row in self.entitlements):
return self._conflict(sql)
row = SimpleNamespace(
id=f"ent-{len(self.entitlements) + 1}",
payment_id=params["payment_id"],
subject=params["subject"],
kind=params["kind"],
ref_id=params["ref_id"],
expires_at=datetime.now(tz=UTC) + timedelta(days=int(params["days"])),
)
self.entitlements.append(row)
return _Result(row)
@staticmethod
def _key_of(row: SimpleNamespace, names: tuple[str, ...]) -> tuple[Any, ...]:
return tuple(getattr(row, name) for name in names)
@staticmethod
def _conflict(sql: str) -> _Result:
if "ON CONFLICT DO NOTHING" not in sql:
raise _UniqueViolationError("duplicate key value violates unique constraint")
return _Result(None)
def _find_notification(self, params: dict[str, Any]) -> SimpleNamespace | None:
key = (params["payment_id"], params["status"], params["amount"], params["token"])
return next(
(row for row in self.notifications if self._key_of(row, self._NOTIFICATION_KEY) == key),
None,
)
class _Result:
def __init__(self, row: Any) -> None:
self._row = row
def fetchone(self) -> Any:
return self._row
@pytest.fixture()
def db() -> _FakeDb:
return _FakeDb()
@pytest.fixture()
def client(db: _FakeDb, monkeypatch: pytest.MonkeyPatch) -> TestClient:
from app.api.v1 import payments as payments_module
from app.core.config import settings
from app.core.db import get_db
monkeypatch.setattr(settings, "payments_enabled", True)
monkeypatch.setattr(settings, "tbank_password", SecretStr(_PASSWORD))
monkeypatch.setattr(settings, "tbank_terminal_key", "TERM-TEST")
app = FastAPI()
app.include_router(payments_module.router, prefix="/api/v1/trade-in")
app.dependency_overrides[get_db] = lambda: db
return TestClient(app)
def _signed_notification(**overrides: Any) -> dict[str, Any]:
from app.services.payments.token import sign
body: dict[str, Any] = {
"TerminalKey": "TERM-TEST",
"OrderId": _ORDER_ID,
"PaymentId": _PAYMENT_ID,
"Status": "CONFIRMED",
"Success": True,
"Amount": _AMOUNT,
}
body.update(overrides)
body["Token"] = sign(body, _PASSWORD)
return body
# ── идемпотентность выдачи ───────────────────────────────────────────────────
def test_repeat_notification_issues_only_one_entitlement(client: TestClient, db: _FakeDb) -> None:
"""Ретрай банка (тот же Token) не выдаёт второй отчёт и не рвёт ответ "OK".
Фальсификация (проверено руками): убрать `ON CONFLICT DO NOTHING` из
INSERT в payment_entitlements второй запрос падает _UniqueViolationError и
тест краснеет на status_code 500; заменить дедуп на «SELECT потом INSERT»
и снять UNIQUE len(entitlements) == 2.
"""
body = _signed_notification()
first = client.post("/api/v1/trade-in/payments/notify", json=body)
second = client.post("/api/v1/trade-in/payments/notify", json=body)
assert first.status_code == 200, first.text
assert first.text == "OK"
assert second.status_code == 200, second.text
assert second.text == "OK"
assert len(db.entitlements) == 1, "повторная нотификация выдала второй отчёт"
assert len(db.notifications) == 1, "дубль нотификации записался второй строкой"
assert db.notifications[0].processed_at is not None
assert db.retain_until_updates == [_ESTIMATE_UUID]
def test_unprocessed_duplicate_is_fulfilled_on_retry(client: TestClient, db: _FakeDb) -> None:
"""Строка нотификации есть, а processed_at пуст → выдача ОБЯЗАНА состояться.
Это контракт processed_at из миграции 233: падение процесса между записью
нотификации и выдачей не должно оставить клиента без товара при списанных
деньгах. Красный вариант трактовать существование строки как «уже
обработано» (тогда entitlements пуст).
"""
body = _signed_notification()
db.notifications.append(
SimpleNamespace(
id=1,
order_id=_ORDER_ID,
tbank_payment_id=_PAYMENT_ID,
status="CONFIRMED",
amount_kopecks=_AMOUNT,
token=body["Token"],
token_valid=True,
processed_at=None,
)
)
response = client.post("/api/v1/trade-in/payments/notify", json=body)
assert response.status_code == 200, response.text
assert len(db.entitlements) == 1
assert db.notifications[0].processed_at is not None
def test_retry_after_crash_between_issue_and_processed_does_not_double_issue(
client: TestClient, db: _FakeDb
) -> None:
"""Худший реальный случай: выдача прошла, а processed_at проставить не успели.
Ретрай банка ОБЯЗАН дойти до конца (иначе processed_at не проставится
никогда и так будет каждый час сутки) и при этом не выдать второй отчёт.
Единственное, что здесь работает, UNIQUE (payment_id, kind, ref_id) +
ON CONFLICT DO NOTHING: `_FakeDb` ведёт себя как Postgres и на INSERT без
ON CONFLICT кидает _UniqueViolationError (проверено руками: убрать ON CONFLICT
из INSERT в payment_entitlements 500 вместо "OK", тест краснеет).
"""
body = _signed_notification()
db.notifications.append(
SimpleNamespace(
id=1,
order_id=_ORDER_ID,
tbank_payment_id=_PAYMENT_ID,
status="CONFIRMED",
amount_kopecks=_AMOUNT,
token=body["Token"],
token_valid=True,
processed_at=None,
)
)
_issue_entitlement(db, "already-issued-token", expires_at=None)
response = client.post("/api/v1/trade-in/payments/notify", json=body)
assert response.status_code == 200, response.text
assert response.text == "OK"
assert len(db.entitlements) == 1, "выдан второй отчёт по тому же платежу"
assert db.entitlements[0].subject == "already-issued-token", "токен подменён на новый"
assert db.notifications[0].processed_at is not None
# ── подпись и сумма ──────────────────────────────────────────────────────────
def test_notification_with_invalid_token_is_rejected(client: TestClient, db: _FakeDb) -> None:
body = _signed_notification()
body["Token"] = "deadbeef" * 8 # подпись не от нашего пароля
response = client.post("/api/v1/trade-in/payments/notify", json=body)
assert response.status_code == 403
assert db.entitlements == [], "выдача по неподписанной нотификации"
assert len(db.notifications) == 1, "факт попытки должен остаться в append-only логе"
assert db.notifications[0].token_valid is False
def test_amount_mismatch_is_rejected(client: TestClient, db: _FakeDb) -> None:
"""Подпись Т-Банка не гарантирует сумму (см. docstring notification.py) —
расхождение с суммой заказа обязано быть отказом, а не выдачей."""
response = client.post(
"/api/v1/trade-in/payments/notify", json=_signed_notification(Amount=_AMOUNT + 1)
)
assert response.status_code == 400
assert db.entitlements == []
def test_non_confirmed_status_does_not_fulfill(client: TestClient, db: _FakeDb) -> None:
response = client.post(
"/api/v1/trade-in/payments/notify", json=_signed_notification(Status="AUTHORIZED")
)
assert response.status_code == 200
assert db.entitlements == []
assert db.payments[0].status == "AUTHORIZED"
def test_pre_confirm_notification_does_not_downgrade_confirmed(
client: TestClient, db: _FakeDb
) -> None:
"""Отставший AUTHORIZED не имеет права откатить уже подтверждённый платёж."""
db.payments[0].status = "CONFIRMED"
client.post("/api/v1/trade-in/payments/notify", json=_signed_notification(Status="AUTHORIZED"))
assert db.payments[0].status == "CONFIRMED"
# ── capability-ссылка ────────────────────────────────────────────────────────
def _issue_entitlement(db: _FakeDb, token: str, *, expires_at: datetime | None) -> None:
db.entitlements.append(
SimpleNamespace(
id="ent-1",
payment_id=_PAYMENT_UUID,
subject=token,
kind="report_link",
ref_id=_ESTIMATE_UUID,
expires_at=expires_at,
)
)
def test_report_link_serves_estimate_for_valid_token(
client: TestClient, db: _FakeDb, monkeypatch: pytest.MonkeyPatch
) -> None:
from app.api.v1 import payments as payments_module
from app.schemas.trade_in import AggregatedEstimate
seen: dict[str, Any] = {}
def _fake_loader(_db: Any, estimate_id: Any, **kwargs: Any) -> AggregatedEstimate:
seen["estimate_id"] = str(estimate_id)
seen.update(kwargs)
return AggregatedEstimate(
estimate_id=_ESTIMATE_UUID,
median_price_rub=5_000_000,
range_low_rub=4_500_000,
range_high_rub=5_500_000,
median_price_per_m2=100_000,
confidence="medium",
n_analogs=7,
period_months=6,
analogs=[],
actual_deals=[],
expires_at=datetime.now(tz=UTC) + timedelta(hours=12),
)
monkeypatch.setattr(payments_module, "load_estimate", _fake_loader)
_issue_entitlement(db, "good-token", expires_at=datetime.now(tz=UTC) + timedelta(days=1))
response = client.get("/api/v1/trade-in/r/good-token")
assert response.status_code == 200, response.text
assert seen["estimate_id"] == _ESTIMATE_UUID
assert seen["capability_granted"] is True
assert seen["x_authenticated_user"] is None
def test_report_link_rejects_foreign_token(client: TestClient, db: _FakeDb) -> None:
_issue_entitlement(db, "good-token", expires_at=datetime.now(tz=UTC) + timedelta(days=1))
response = client.get("/api/v1/trade-in/r/someone-elses-token")
assert response.status_code == 404
def test_report_link_rejects_expired_token(client: TestClient, db: _FakeDb) -> None:
_issue_entitlement(db, "stale-token", expires_at=datetime.now(tz=UTC) - timedelta(seconds=1))
response = client.get("/api/v1/trade-in/r/stale-token")
assert response.status_code == 404
def test_issued_token_is_unpredictable(client: TestClient, db: _FakeDb) -> None:
"""Токен — это всё право доступа: он обязан быть случайным, а не производной
от order_id/payment_id (иначе выводится по данным, которые видит покупатель)."""
client.post("/api/v1/trade-in/payments/notify", json=_signed_notification())
token = db.entitlements[0].subject
assert len(token) >= 40
assert _ORDER_ID not in token
assert _PAYMENT_ID not in token
# ── kill-switch ──────────────────────────────────────────────────────────────
def test_endpoints_are_closed_when_payments_disabled(
db: _FakeDb, monkeypatch: pytest.MonkeyPatch
) -> None:
"""PAYMENTS_ENABLED=false — 503 на всех ручках, без падений и без записи в БД."""
from app.api.v1 import payments as payments_module
from app.core.config import settings
from app.core.db import get_db
monkeypatch.setattr(settings, "payments_enabled", False)
app = FastAPI()
app.include_router(payments_module.router, prefix="/api/v1/trade-in")
app.dependency_overrides[get_db] = lambda: db
disabled = TestClient(app)
assert disabled.get("/api/v1/trade-in/r/any-token").status_code == 503
assert disabled.get(f"/api/v1/trade-in/payments/status/{_ORDER_ID}").status_code == 503
assert disabled.post("/api/v1/trade-in/payments/notify", json={}).status_code == 503
assert (
disabled.post(
"/api/v1/trade-in/payments/checkout", json={"estimate_id": _ESTIMATE_UUID}
).status_code
== 503
)
assert db.notifications == []
assert db.entitlements == []
# ── периметр и синхронизация констант ────────────────────────────────────────
def test_notify_is_public_and_checkout_is_not() -> None:
from app.core.rbac import _PUBLIC_PATH_PREFIXES, _PUBLIC_PATHS
assert "/api/v1/trade-in/payments/notify" in _PUBLIC_PATHS
assert "/api/v1/trade-in/payments/checkout" not in _PUBLIC_PATHS
assert "/api/v1/trade-in/r/some-token".startswith(_PUBLIC_PATH_PREFIXES)
assert not "/api/v1/trade-in/history".startswith(_PUBLIC_PATH_PREFIXES)
def test_report_link_token_is_redacted_from_sentry_events() -> None:
from app.observability.sentry_scrub import scrub_pii_event
event = {
"request": {"url": "https://meraocenka.ru/api/v1/trade-in/r/s3cr3t-token-value"},
"transaction": "GET /api/v1/trade-in/r/s3cr3t-token-value",
}
scrubbed = scrub_pii_event(event, {}) # type: ignore[arg-type]
assert "s3cr3t-token-value" not in str(scrubbed)
assert "/api/v1/trade-in/r/[REDACTED]" in scrubbed["transaction"] # type: ignore[index]
def test_status_whitelist_matches_migration() -> None:
"""Свой список статусов не должен разъезжаться с CHECK миграции 233:
статус, которого нет в CHECK, уронит INSERT уже на проде."""
from app.api.v1.payments import _KNOWN_STATUSES
source = _MIGRATION.read_text(encoding="utf-8")
check = re.search(
r"ADD CONSTRAINT\s+payments_status_check\s+CHECK \(status IN \((.*?)\)\)", source, re.S
)
assert check is not None, "CHECK payments_status_check не найден в миграции 233"
migration_statuses = set(re.findall(r"'([^']+)'", check.group(1)))
assert _KNOWN_STATUSES == migration_statuses
def test_price_matches_published_offer() -> None:
"""Цена в коде обязана совпадать с ценой, названной в оферте (п. 4.1) —
единственный её источник для юр-текстов, mera-public/content.ts."""
from app.api.v1.payments import _PRODUCTS
match = re.search(r"SERVICE_PRICE_RUB\s*=\s*(\d+)", _CONTENT_TS.read_text(encoding="utf-8"))
assert match is not None, "SERVICE_PRICE_RUB не найден в mera-public/content.ts"
_name, price_kopecks = _PRODUCTS["paid_report"]
assert price_kopecks == int(match.group(1)) * 100
def test_live_status_predicate_matches_code() -> None:
"""Предикат частичного UNIQUE (279) и `_REUSABLE_STATUSES` — один список.
Индекс шире кода запрещает легитимную повторную попытку оплаты; индекс уже
кода пропускает второй холд на карте. И то и другое про деньги, поэтому
дрейф ловит тест, а не внимательность читателя.
"""
from app.api.v1.payments import _REUSABLE_STATUSES
path = _REPO_ROOT / "backend" / "data" / "sql" / "279_payments_live_checkout_uidx.sql"
source = path.read_text(encoding="utf-8")
predicate = re.search(r"AND status IN \((.*?)\)", source, re.S)
assert predicate is not None, "предикат по статусам не найден в миграции 279"
assert set(re.findall(r"'([^']+)'", predicate.group(1))) == set(_REUSABLE_STATUSES)
# ── checkout: один живой платёж на оценку ────────────────────────────────────
@pytest.fixture()
def bank(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
"""Подменяет Т-Банк: собирает вызовы Init и отдаёт предсказуемую ссылку.
Список вызовов не украшение: «второй Init» и есть «второй холд на карте»,
поэтому проверяется именно его длина, а не только тело ответа.
"""
from app.api.v1 import payments as payments_module
calls: list[dict[str, Any]] = []
class _FakeClient:
async def init_payment(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {
"Success": True,
"Status": "NEW",
"PaymentId": f"300000000{len(calls)}",
"PaymentURL": f"https://securepayments.tinkoff.ru/{len(calls)}",
}
monkeypatch.setattr(payments_module, "_client", _FakeClient)
return calls
def _seed_live_payment(db: _FakeDb, *, payment_url: str | None, age: timedelta) -> SimpleNamespace:
row = SimpleNamespace(
id="pay-seed",
order_id="mera-seed",
status="NEW",
amount_kopecks=_AMOUNT,
estimate_id=_ESTIMATE_UUID,
created_by=None,
product_code="paid_report",
payment_url=payment_url,
created_at=datetime.now(tz=UTC) - age,
)
db.payments.append(row)
return row
def _paid_report_rows(db: _FakeDb) -> list[SimpleNamespace]:
return [p for p in db.payments if p.product_code == "paid_report"]
def test_parallel_checkout_does_not_create_second_payment(
client: TestClient, db: _FakeDb, bank: list[dict[str, Any]]
) -> None:
"""Двойной клик: соперник уже вставил строку, но ссылки от банка ещё нет.
Второй запрос обязан отбиться о частичный UNIQUE (миграция 279) и не пойти
в банк второй Init это второй холд на карте покупателя. Ответ 409, а не
выдуманная ссылка.
Фальсификация (проверено): убрать `ON CONFLICT DO NOTHING` из INSERT в
payments _FakeDb кидает _UniqueViolationError, как настоящий Postgres, и
тест краснеет вместо ответа 409; вернуть «SELECT, потом INSERT» без
обработки конфликта вторая строка payments и второй вызов Init.
"""
_seed_live_payment(db, payment_url=None, age=timedelta(seconds=1))
response = client.post(
"/api/v1/trade-in/payments/checkout", json={"estimate_id": _ESTIMATE_UUID}
)
assert response.status_code == 409, response.text
assert len(_paid_report_rows(db)) == 1, "параллельный checkout создал второй платёж"
assert bank == [], "второй Init = второй холд на карте покупателя"
def test_repeat_checkout_reuses_live_link(
client: TestClient, db: _FakeDb, bank: list[dict[str, Any]]
) -> None:
"""Честный повтор по живому платежу возвращает ТУ ЖЕ ссылку и не зовёт Init."""
seeded = _seed_live_payment(
db, payment_url="https://securepayments/live", age=timedelta(minutes=5)
)
response = client.post(
"/api/v1/trade-in/payments/checkout", json={"estimate_id": _ESTIMATE_UUID}
)
assert response.status_code == 200, response.text
assert response.json()["payment_url"] == "https://securepayments/live"
assert response.json()["order_id"] == seeded.order_id
assert len(_paid_report_rows(db)) == 1
assert bank == []
def test_abandoned_checkout_does_not_lock_the_buyer_out(
client: TestClient, db: _FakeDb, bank: list[dict[str, Any]]
) -> None:
"""Брошенный NEW старше окна не переиспользуется: ссылка у банка протухла.
Без границы по времени покупатель навсегда получал бы одну и ту же мёртвую
ссылку и не мог начать оплату заново нотификации по зависшему NEW может
не прийти вовсе, сам из этого статуса платёж не выйдет.
Фальсификация (проверено руками): убрать UPDATE ... DEADLINE_EXPIRED (или
условие по created_at в нём) в ответе старая мёртвая ссылка
https://securepayments/dead, Init не вызывается: тест краснеет по значению,
а не по исключению.
"""
stale = _seed_live_payment(
db, payment_url="https://securepayments/dead", age=timedelta(hours=2)
)
response = client.post(
"/api/v1/trade-in/payments/checkout", json={"estimate_id": _ESTIMATE_UUID}
)
assert response.status_code == 200, response.text
assert response.json()["payment_url"] == "https://securepayments.tinkoff.ru/1"
assert stale.status == "DEADLINE_EXPIRED"
assert len(bank) == 1, "новая попытка оплаты обязана получить свежую ссылку банка"
assert len(_paid_report_rows(db)) == 2
def test_checkout_rejects_foreign_estimate(
client: TestClient, db: _FakeDb, bank: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch
) -> None:
"""IDOR: checkout по чужой оценке — 404, платёж не создаётся.
Дверь здесь не «показать чужой отчёт», а order_id: он же право доступа для
/payments/status/<order_id>, который отдаёт capability-ссылку на отчёт,
как только владелец заплатит.
Фальсификация (проверено руками): убрать вызов `_assert_estimate_access` в
checkout 200 и строка в payments, тест краснеет по значению.
"""
from app.core import auth
monkeypatch.setattr(auth, "get_role", lambda username: "pilot")
db.estimate_created_by = "victim"
response = client.post(
"/api/v1/trade-in/payments/checkout",
json={"estimate_id": _ESTIMATE_UUID},
headers={"X-Authenticated-User": "attacker"},
)
assert response.status_code == 404, response.text
assert _paid_report_rows(db) == []
assert bank == []

View file

@ -382,3 +382,36 @@ def test_daily_budget_stops_estimates_for_everyone(client: TestClient, flag_on:
assert resp.status_code == 429, resp.text
assert int(resp.headers["Retry-After"]) > 0
assert not estimate_mock.called
# ── Выключенная ручка не должна подтверждать, что она существует ─────────────
#
# Замер на проде 29.08.2026 (флаг выключен) показал, что обещание докстринга не
# выполнялось: проверка флага стояла ПЕРВОЙ СТРОКОЙ ТЕЛА, а FastAPI валидирует
# тело запроса раньше, чем доходит до кода хендлера. Итог:
# {} → 422 со списком полей address/area_m2/rooms/consent
# {валидное} → 404
# чужой путь → 401
# 422 отличался и от 404, и от 401 — то есть подтверждал существование ручки и
# заодно печатал её схему. Гейт переехал в `dependencies=[Depends(...)]`:
# зависимости решаются ДО разбора тела.
def test_disabled_estimate_hides_itself_from_any_input(client: TestClient) -> None:
for body in ({}, {"address": "x"}, _BODY):
got = client.post(f"{PREFIX}/estimate", json=body)
assert got.status_code == 404, (
f"выключенная ручка ответила {got.status_code} на теле {body!r}. "
"Любой другой код отличает её от несуществующего пути, а 422 "
"вдобавок печатает схему запроса"
)
assert "address" not in got.text, (
"в ответе выключенной ручки видно имя поля запроса — схема утекла"
)
def test_disabled_read_hides_itself_from_any_input(client: TestClient) -> None:
for body in ({}, {"token": "x"}):
got = client.post(f"{PREFIX}/estimate/read", json=body)
assert got.status_code == 404, (
f"выключенное чтение по токену ответило {got.status_code} на {body!r}"
)
assert "token" not in got.text, "схема чтения утекла при выключенном флаге"

View file

@ -128,8 +128,6 @@ class _Scenario:
avito_proxy_max_rotations=self.avito_proxy_max_rotations,
avito_serp_ok_not_banned=self.avito_serp_ok_not_banned,
avito_proxy_rotate_settle_s=0.0,
proxy_rotate_attempts=1,
proxy_rotate_attempt_timeout_s=1.0,
cian_proxy_max_rotations=0,
yandex_proxy_max_rotations=0,
scraper_skip_seen_today=False,

View file

@ -48,6 +48,21 @@ describe("черновик", () => {
expect(takeDraft()).toBeNull();
});
it("доносит всё, что человек ввёл на лэндинге", () => {
// Город — гео-гейт (#2576), состояние — обязательное поле карточки v3:
// спросить и выбросить хуже, чем донести до платного шага.
saveDraft({
address: "Ленина 5",
rooms: "2",
area: "54",
city: "Нижний Тагил",
condition: "Хорошее",
});
const draft = takeDraft();
expect(draft?.city).toBe("Нижний Тагил");
expect(draft?.condition).toBe("Хорошее");
});
it("не падает на мусоре в хранилище", () => {
window.sessionStorage.setItem("mera:estimate-draft", "{это не json");
expect(takeDraft()).toBeNull();

View file

@ -0,0 +1,242 @@
/**
* Гейт #2904 (переписан 29.08.2026): ни одна величина не выходит на витрину
* без источника.
*
* ЧТО ОХРАНЯЛ ПРЕДЫДУЩИЙ ГЕЙТ. Он следил за парой «плейсхолдеры на странице
* `noindex` в layout.tsx»: пока числа из дизайн-макета живы, страницу нельзя
* открыть наружу. Плейсхолдеров больше нет числа заменены замеренными
* (`/api/public/mera/stats`, `/showcase`) либо сняты вместе с блоком, и
* второй тест того гейта («ни один плейсхолдер не найден зонд сломан»)
* покраснел бы ПО ПОСТРОЕНИЮ ровно за честную работу. Правило пришлось
* сформулировать заново.
*
* ЧТО ОХРАНЯЕТ ЭТОТ. Не наличие плейсхолдеров, а происхождение чисел:
*
* 1. в компонентах лэндинга v3 нет вписанных руками величин число
* приходит либо из ручки (`formatStat`/`/showcase`), либо из
* `landing-facts.ts`, где рядом с ним лежат выборка и оговорки;
* 2. каждая запись `landing-facts.ts` заполнена: значение, размер выборки,
* источник, оговорки пустых полей быть не может;
* 3. сам сканер проверен на образцах: он обязан найти вписанное число и не
* обязан ругаться на честный код. Без этого пункта правило (1) стало бы
* зелёным по построению при первой же опечатке в регулярке;
* 4. блок `robots` в layout.tsx на месте. Снятие `noindex` решение
* владельца, но оно должно быть осознанным, а не побочным.
*
* ЧЕГО ГЕЙТ НЕ ДЕЛАЕТ. Он не сканирует `articles/articles-content.ts`: числа
* статьи это связный текст, у каждого блока которого стоит собственное поле
* `source`/`footnote` с датой и выборкой замера (см. шапку того файла). Ловить
* их регуляркой значило бы требовать переписать статью в константы; правило
* (2) там не поможет, а правило (1) дало бы шум вместо охраны.
*/
import { readdirSync, readFileSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { BACKTEST, type MeasuredValue } from "../landing-facts";
const ROOT = join(__dirname, "..");
function read(rel: string): string {
return readFileSync(join(ROOT, rel), "utf8");
}
/**
* Корень витрины и каталог её компонентов.
*
* КОРЕНЬ `page.tsx`, а не `v3/page.tsx`: 29.08.2026 превью стало корнем,
* каталог `v3/` удалён. Путь тут один и используется и для скана, и для
* разбора импортов ниже; разъехаться половинки не могут, а `read()` на
* несуществующем файле падает громко, а не молча пропускает проверку.
*/
const LANDING_ROOT = "page.tsx";
const V3_DIR = "_components/v3";
/**
* Что сканируем. Список НЕ пишется руками: он выводится из каталога
* компонентов витрины плюс сам корень страницы. Захардкоженный перечень уже
* отстал однажды страница рендерила пятнадцать компонентов, в списке стояло
* одиннадцать, и «возражения» (естественное место для «в 9 из 10 случаев»)
* оказались вне охраны. Каталог отстать не может: файл, положенный рядом с
* остальными, попадает под гейт тем же коммитом, которым появился.
*
* Компонент, положенный ВНЕ каталога, каталогом не ловится за это отвечает
* отдельная проверка «всё, что импортирует корень, попало в список».
*/
const V3_SOURCES = [
LANDING_ROOT,
...readdirSync(join(ROOT, V3_DIR))
.filter((name) => /\.tsx?$/u.test(name))
.sort()
.map((name) => `${V3_DIR}/${name}`),
];
/**
* Формы, в которых величина попадает на экран. Ищутся ИМЕННО они, а не любые
* цифры: `useState(0)`, `slice(0, 3)` и `toFixed(2)` это код, а «14,5 %»,
* «42 700», «±6 дн.» и «×2,4» утверждения о рынке.
*
* ЧЕГО СКАНЕР НЕ ЛОВИЛ ДО 29.08.2026. Ревьюер вписал в DealsTickerV3 четыре
* маркетинговых утверждения, и гейт остался зелёным: он проверял ФОРМУ записи,
* а формы были соседние. «42700» разделитель тысяч не пробелом, а никак;
* «квартир»/«городах» единица вне короткого списка; «в 2 раза» кратность
* словами вместо «×2»; «9 из 10» доля словами. Последнее особенно дорого:
* «42 города РФ» стояло отдельной строкой в таблице расхождений СТАРОГО гейта,
* то есть новый пропускал ровно тот класс вранья, ради которого заводился.
*/
const MEASUREMENT_SHAPES: readonly { rx: RegExp; what: string }[] = [
{ rx: /\d[\s ]\d{3}/u, what: "число с разделителем тысяч (42 700)" },
{ rx: /(?<!\d)\d{4,}(?!\d)/u, what: "число от четырёх знаков без разделителя (42700)" },
{ rx: /\d,\d/u, what: "десятичная дробь через запятую (1,8)" },
{
rx: /\d\s*(?:%||дн\.|дней|мес\.|млн|тыс\.|м²|сек|минут|сделок|аналог|объявлен|оцен|квартир|город|объект|дом[ао]|проверок|прогноз|район|клиент|человек|регион)/iu,
what: "число с единицей измерения (83%, 150 ₽, ±6 дн., 42 700 квартир, 42 города)",
},
{ rx: /×\s*\d/u, what: "кратность (×2,4)" },
// `\b` здесь не годится: границей слова JS считает край [A-Za-z0-9_], и
// перед кириллическим «в» она не срабатывает никогда. Якорим по цифре.
{ rx: /\d\s*раз/iu, what: "кратность словами (в 2 раза точнее)" },
{ rx: /\d\s+из\s+\d/iu, what: "доля словами (9 из 10)" },
];
/**
* Комментарии не витрина: в них величины ЦИТИРУЮТСЯ, чтобы объяснить снятое.
* `&nbsp;` заодно превращается в пробел: «150&nbsp;» на экране то же самое
* число с единицей, что и «150 », и именно в таком виде цена однажды уже
* проехала мимо сканера.
*/
function stripComments(src: string): string {
return src
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/(^|[^:"'`])\/\/[^\n]*/g, "$1")
.replace(/&nbsp;|\\u00a0/g, " ");
}
/** Находит вписанные руками величины. Пусто — значит все числа пришли из данных. */
function handwrittenValues(src: string): string[] {
const code = stripComments(src);
return MEASUREMENT_SHAPES.filter(({ rx }) => rx.test(code)).map(({ what }) => what);
}
describe("гейт витринных чисел (#2904)", () => {
it.each(V3_SOURCES)("в %s нет чисел, вписанных руками", (rel) => {
const found = handwrittenValues(read(rel));
expect(found, [
`в ${rel} найдена величина, вписанная в код: ${found.join(", ")}.`,
"Витринное число обязано приходить из данных — formatStat() по /stats,",
"строки /showcase — либо из landing-facts.ts, где у него есть выборка,",
"источник и оговорки. Нет источника — блок не рендерится.",
].join(" ")).toEqual([]);
});
it.each(Object.entries(BACKTEST))("величина %s в landing-facts заполнена целиком", (key, value: MeasuredValue) => {
expect(value.text.trim(), `${key}: пустое значение`).not.toBe("");
expect(Number.isInteger(value.sampleN) && value.sampleN > 0, `${key}: выборка не указана`).toBe(true);
expect(value.source.trim().length, `${key}: не сказано, откуда число`).toBeGreaterThan(20);
expect(value.caveats.length, `${key}: нет ни одной оговорки`).toBeGreaterThan(0);
for (const caveat of value.caveats) {
expect(caveat.trim(), `${key}: пустая оговорка`).not.toBe("");
}
});
it("сканер видит вписанное число и молчит на честном коде — иначе он проходил бы вхолостую", () => {
// Контроль на инструмент. Слева — ровно то, что гейт обязан ловить
// (величины из прежнего marketing-v3.ts), справа — код, который в
// компонентах есть и ругаться на который нельзя.
for (const bad of [
"<div>СРЕДНЯЯ ОШИБКА — 1,8%</div>",
"<div>{`42 700 проверок`}</div>",
"const days = '±6 дн.';",
"<div>×2,4 дольше продаётся</div>",
"<div>150 ₽</div>",
"<button>Открыть за 150&nbsp;₽</button>",
// Четыре строки ревьюера. Он вписал их в DealsTickerV3, и гейт остался
// зелёным 17/17: каждая проходила МИМО прежних шести форм. Они здесь не
// ради полноты — без них правило «сканер проверен на образцах» повторяло
// то, что сканер и так знает, то есть покрытия не расширяло.
"<div>Точность 9 из 10 прогнозов</div>",
"<div>Проверено более 42700 квартир по всей России</div>",
"<div>МЕРА в 2 раза точнее риелтора</div>",
"<div>Работаем в 42 городах</div>",
]) {
expect(handwrittenValues(bad), `сканер не увидел вписанное число: ${bad}`).not.toEqual([]);
}
for (const good of [
'const MIN_ROUNDS = 3;\nconst [idx, setIdx] = useState(0);',
'deals.slice(0, 3).map(toGameRound)',
'const STEP_MLN = 0.05;\nMath.ceil(v * 2) / 2',
'<div id="hero-v3-title">{formatStat(stats, "analogs_median", { digits: 1 })?.text}</div>',
'/* в макете стояло 1,8% и 42 700 — снято */\nconst x = 1;',
// Живые строки из компонентов: счётчик берётся из длины массива, а не
// вписан. Расширяя список единиц, легко начать ругаться и на них.
'ДАННЫЕ ПО {OBLAST_CITIES.length} ГОРОДАМ: {names}',
'РАУНД {Math.min(idx + 1, rounds.length)} ИЗ {rounds.length}',
'`Показано ${count(s.written)} строк из ${count(s.eligible)} годных`',
]) {
expect(handwrittenValues(good), `сканер ругается на честный код: ${good}`).toEqual([]);
}
});
/**
* Список сканируемых файлов обязан покрывать то, что реально рендерит
* корень. Каталог `_components/v3` ловит соседей автоматически; эта проверка
* ловит компонент, положенный ВНЕ каталога и подключённый к странице,
* случай, при котором каталожный вывод отстал бы так же, как отставал
* захардкоженный список.
*/
it("каждый компонент, который импортирует корень страницы, попал в список сканируемых", () => {
// Корень лежит в самом `mera-public/`, поэтому его относительные импорты
// разрешаются от ROOT. Пока страница жила в `v3/`, здесь стоял `v3` —
// после переезда тот же путь дал бы `_components/` мимо каталога, и
// проверка молча перестала бы что-либо находить.
const pageDir = resolve(ROOT);
const rendered = [...read(LANDING_ROOT).matchAll(/from\s+"(\.[^"]+)"/gu)]
.map(([, spec]) => relative(ROOT, resolve(pageDir, spec)).split("\\").join("/"))
.filter((rel) => rel.startsWith("_components/"));
expect(rendered.length, "в корне не нашлось ни одного импорта — зонд сломан").toBeGreaterThan(0);
const missed = rendered.filter((rel) => !V3_SOURCES.some((src) => src === rel || src === `${rel}.tsx` || src === `${rel}.ts`));
expect(missed, [
`корень рендерит ${missed.join(", ")}, а гейт эти файлы не читает.`,
"Компоненты витрины живут в _components/v3 — оттуда список набирается сам.",
].join(" ")).toEqual([]);
});
it("плейсхолдерные экспорты не вернулись под новым именем", () => {
for (const rel of V3_SOURCES) {
expect(stripComments(read(rel)), `${rel} снова тянет *_PLACEHOLDER`).not.toMatch(/_PLACEHOLDER\b/);
}
});
/**
* НАЛИЧИЯ СЛОВА `robots` НЕДОСТАТОЧНО. Проверка на подстроку остаётся
* зелёной при `index: true` то есть ровно тогда, когда страница уходит в
* индекс. Поэтому сверяется значение.
*
* ПРОВЕРЯЕТСЯ ПАРА, А НЕ ОДИН ФАЙЛ. Пока витрина жила на `v3/page.tsx`, у
* неё был собственный `export const metadata` и флаг стоял в двух местах
* обоих и требовали. С переездом в корень второй экземпляр снят намеренно
* (см. шапку `page.tsx`): метаданные держит только layout. Требовать
* `noindex` в `page.tsx` теперь значило бы требовать вернуть дубль, а просто
* убрать проверку оставить дыру: страничные `metadata` в Next
* ПЕРЕКРЫВАЮТ layout, и `index: true` на уровне корня погасил бы флаг, не
* тронув файла, за которым тут следят. Отсюда две половины: в layout стоит
* noindex, а корень своих метаданных не заводит вовсе.
*/
const NOINDEX = /robots:\s*\{\s*index:\s*false\s*,\s*follow:\s*false\s*\}/u;
it("в layout.tsx стоит noindex, а не просто слово robots", () => {
expect(
read("layout.tsx"),
"layout.tsx: витрина открыта поисковикам — маршрут наружу ещё не открыт",
).toMatch(NOINDEX);
});
it("корень не заводит своих metadata — иначе noindex из layout перекрыт незаметно", () => {
expect(
stripComments(read(LANDING_ROOT)),
`${LANDING_ROOT}: появился export const metadata — он перекрывает layout, и noindex надо проверять здесь же`,
).not.toMatch(/export\s+const\s+metadata\b/u);
});
});

View file

@ -0,0 +1,133 @@
/**
* Проверка «нет данных нет блока» на самой разметке.
*
* Гейт `landing-numbers-gate` следит, что числа не вписаны руками; здесь
* проверяется вторая половина правила что при отсутствии величины блок
* ИСЧЕЗАЕТ, а не показывает ноль, прочерк или подпись без числа. Отказ ручки
* `/stats` в проде выглядит именно так: пустой объект.
*/
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { AccuracyV3 } from "../_components/v3/AccuracyV3";
import { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3";
import { HeroV3 } from "../_components/v3/HeroV3";
import type { LandingStat, ShowcaseResponse } from "../public-api";
const stat = (value: number, sample_n: number | null, note: string): LandingStat => ({
value,
sample_n,
note,
computed_at: "2026-08-29T03:00:00+00:00",
});
const STATS = {
estimates_total: stat(1123, 1123, "Расчётов сделано в системе"),
estimates_period_days: stat(62, 1123, "Дней между первым и последним расчётом"),
listing_age_median_days: stat(26, 25943, "Медианная ЭКСПОЗИЦИЯ активного объявления"),
price_cut_share_pct: stat(48.1, 6276, "Доля объявлений, снижавших цену"),
price_cut_median_pct_per_month: stat(-2.17, 3018, "Медианное изменение цены за 30 дней среди снижавших"),
};
/** Hero печатает ещё и медиану аналогов — величину БЕЗ фильтра по городу. */
const HERO_STATS = {
...STATS,
analogs_median: stat(
14,
1123,
"Медиана числа аналогов на расчёт (только расчёты, где аналоги нашлись)",
),
};
const SHOWCASE: ShowcaseResponse = {
computed_at: "2026-08-29T03:00:00+00:00",
deals: [
{
district: "Юго-Западный",
rooms: 2,
area_m2: 52,
floor: 3,
total_floors: 9,
deal_quarter: "II кв. 2026",
predicted_rub: 5210000,
fact_rub: 5180000,
err_pct: 0.58,
n_analogs: 12,
note: "Прогноз посчитан по активным объявлениям на дату пересчёта",
},
],
stats: {
considered: 4000,
priced: 900,
no_prediction: 3100,
incomplete: 12,
eligible: 888,
written: 20,
with_district: 17,
rejection_rule: "Строка не попадает на витрину, только если данных нет",
},
};
describe("витрина лэндинга v3 без данных", () => {
it("«Точность»: с данными показывает величины вместе с выборкой и коридором", () => {
render(<AccuracyV3 stats={STATS} showcase={SHOWCASE} />);
expect(screen.getByText("14,5 %")).toBeTruthy();
expect(screen.getByText(/коридор шириной ±37 %/)).toBeTruthy();
expect(screen.getByText("325 из 327")).toBeTruthy();
expect(screen.getByText(/по 25 943 объявлениям/)).toBeTruthy();
// Подпись витрины: сколько рассмотрено и по какому правилу отсеяно.
expect(screen.getByText(/рассмотрено сделок: 4 000/)).toBeTruthy();
});
it("«Точность»: пустой /stats снимает плитки и таблицу, а не обнуляет их", () => {
const { container } = render(<AccuracyV3 stats={{}} showcase={null} />);
expect(screen.queryByText("расчётов сделано")).toBeNull();
expect(screen.queryByText("медианная экспозиция активного объявления")).toBeNull();
expect(container.querySelector('[role="table"]')).toBeNull();
expect(container.textContent).not.toMatch(/[—-]\s*дн\./);
// Бэктест не зависит от ручки — он остаётся вместе со своими оговорками.
expect(screen.getByText("14,5 %")).toBeTruthy();
});
it("«Цена ошибки»: у двух величин РАЗНЫЕ выборки, и обе подписаны", () => {
render(<CostOfErrorV3 stats={STATS} />);
expect(screen.getByText("-2,17 %")).toBeTruthy();
expect(screen.getByText(/по 3 018 объявлениям/)).toBeTruthy();
expect(screen.getByText("48,1 %")).toBeTruthy();
expect(screen.getByText(/по 6 276 объявлениям/)).toBeTruthy();
});
it("«Цена ошибки»: без /stats остаётся только цена — она из оферты, а не из замера", () => {
render(<CostOfErrorV3 stats={{}} />);
expect(screen.queryByText(/столько объявлений на Домклике снижали цену/)).toBeNull();
expect(screen.getByText("150 ₽")).toBeTruthy();
});
/**
* Величина не должна стоять под подписью, к которой не привязана. Экспозиция
* считается с фильтром по городу (`_LISTING_AGE_SQL`), медиана аналогов по
* всей таблице `trade_in_estimates`; под общей шапкой «пример по рынку
* Екатеринбурга» вторая была бы подписана чужой границей. Проверяем не
* наличие двух строк, а СОДЕРЖИМОЕ каждой: слить их обратно в одну и
* оставить тест зелёным невозможно.
*/
it("HeroV3: аналоги подписаны своей границей и своей оговоркой, а не Екатеринбургом", () => {
render(<HeroV3 stats={HERO_STATS} />);
const ekbLine = screen.getByText(/Пример по рынку Екатеринбурга/).parentElement;
expect(ekbLine?.textContent).toMatch(/активное объявление висит\s+26\s+дн\./u);
expect(ekbLine?.textContent, "экспозиция и аналоги снова в одной строке").not.toMatch(/аналог/);
const allLine = screen.getByText(/По всем расчётам МЕРЫ/).parentElement;
expect(allLine?.textContent).toMatch(/медиана аналогов на расчёт/);
// Отбор выживших: расчёты без аналогов в знаменатель не попали.
expect(allLine?.textContent, "note бэкенда выброшен").toMatch(
/только расчёты, где аналоги нашлись/,
);
});
it("HeroV3: без /stats обе строки примера исчезают, а не показывают подпись без числа", () => {
render(<HeroV3 stats={{}} />);
expect(screen.queryByText(/Пример по рынку Екатеринбурга/)).toBeNull();
expect(screen.queryByText(/По всем расчётам МЕРЫ/)).toBeNull();
});
});

View file

@ -1,113 +0,0 @@
/**
* Гейт #2904: снять `noindex` можно только вместе с плейсхолдерными числами.
*
* В `marketing-v3.ts` лежат величины из дизайн-макета, НЕ подтверждённые замером:
*
* | здесь | факт по замеру |
* | ошибка 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 }`, эти строки никому не видны, и
* держать их допустимо. Публикация же недостоверных характеристик ПЛАТНОЙ услуги
* подпадает под ст. 5 ФЗ «О рекламе» и ЗоЗПП.
*
* До сих пор это правило жило ТОЛЬКО в комментарии модуля. Правило, записанное
* словами, не мешает снять одну строку в layout.tsx и выкатить страницу поэтому
* гейт здесь механический: он краснеет ровно в тот момент, когда noindex исчезает,
* а плейсхолдеры остаются.
*
* Тест НЕ запрещает открывать страницу. Он требует, чтобы открытие шло вместе с
* заменой чисел на вычисляемые (или их снятием) то есть чтобы решение было
* осознанным, а не побочным следствием правки метаданных.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = join(__dirname, "..");
function read(rel: string): string {
return readFileSync(join(ROOT, rel), "utf8");
}
/** Компоненты лэндинга v3, которые могут ссылаться на плейсхолдеры. */
const V3_SOURCES = [
"v3/page.tsx",
"_components/v3/HeroV3.tsx",
"_components/v3/AccuracyV3.tsx",
"_components/v3/FreeCheckCard.tsx",
"_components/v3/FreeResultV3.tsx",
// Макет 29.08: лента и игра тоже стоят на реквизитных величинах.
"_components/v3/DealsTickerV3.tsx",
"_components/v3/GuessGameV3.tsx",
// Раздел статей (29.08) — плитки §4 статьи берут те же плейсхолдеры. Без
// этой строки гейт остался бы зелёным при снятии noindex: он сканировал бы
// только лэндинг, а величины уже живут и на статье.
"articles/articles-content.ts",
];
const PLACEHOLDER_EXPORTS = [
"ACCURACY_PLACEHOLDER",
"COST_OF_ERROR_PLACEHOLDER",
"FREE_SAMPLE_PLACEHOLDER",
"PROOF_ROWS_PLACEHOLDER",
"GAME_ROUNDS_PLACEHOLDER",
];
function placeholdersInUse(): string[] {
const used: string[] = [];
for (const rel of V3_SOURCES) {
let src: string;
try {
src = read(rel);
} catch {
continue; // файл переименован/удалён — не повод падать
}
for (const name of PLACEHOLDER_EXPORTS) {
if (src.includes(name) && !used.includes(name)) used.push(name);
}
}
return used;
}
function noindexIsOn(): boolean {
const layout = read("layout.tsx").replace(/\s+/g, " ");
return layout.includes("robots: { index: false");
}
describe("гейт noindex ↔ плейсхолдерные числа (#2904)", () => {
it("пока плейсхолдеры на странице — noindex обязан стоять", () => {
const used = placeholdersInUse();
if (used.length === 0) {
// Числа заменены вычисляемыми — гейт больше не нужен, открывать можно.
return;
}
expect(noindexIsOn(), [
`лэндинг v3 всё ещё использует непроверенные величины: ${used.join(", ")},`,
"но `robots: { index: false }` в layout.tsx снят.",
"Перед открытием страницы наружу каждая цифра обязана быть заменена",
"вычисляемой либо снята — см. шапку marketing-v3.ts и issue #2904.",
].join(" ")).toBe(true);
});
it("сам гейт видит и плейсхолдеры, и noindex — иначе он проходил бы вхолостую", () => {
// Контроль на инструмент: если оба зонда перестанут находить своё
// (переименование файлов, смена формы записи), проверка выше станет
// зелёной по построению и перестанет что-либо охранять.
expect(placeholdersInUse().length, "ни один плейсхолдер не найден в компонентах v3 — зонд сломан").toBeGreaterThan(0);
expect(read("layout.tsx"), "layout.tsx не содержит блока robots — зонд сломан").toContain("robots");
});
it("расхождение «заявлено против замера» остаётся в шапке модуля", () => {
// Таблица — единственное место, где зафиксировано, НАСКОЛЬКО числа расходятся
// с фактом. Потеряется она — и следующий читатель решит, что цифры проверены.
const mod = read("marketing-v3.ts");
expect(mod).toContain("13,2%");
expect(mod).toContain("НЕ СУЩЕСТВУЕТ");
expect(mod).toContain("1 084");
});
});

View file

@ -0,0 +1,205 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
fetchLandingStats,
fetchShowcase,
formatStat,
type LandingStat,
type LandingStats,
} from "../public-api";
/**
* Гейт честной деградации серверного слоя витрины.
*
* Проверяется не «функция что-то вернула», а ровно то, ради чего слой написан:
* при любом сбое на витрину не попадает НИ ОДНОГО числа, которого никто не
* измерял. Кейсы двусторонние рядом с провальным входом стоит успешный,
* иначе тест зеленел бы и на функции `() => null`.
*
* Фальсификация (проделана вручную 29.08.2026): вернуть из `formatStat` при
* отсутствии величины `{ text: "0", sample: null, ... }` вместо `null`
* краснеют «ключа нет», «пустой ответ», «сеть легла», «500»; вернуть из
* `fetchLandingStats` при сбое заглушку с нулями краснеют те же четыре.
*/
/**
* Intl.NumberFormat("ru-RU") разделяет разряды НЕРАЗРЫВНЫМ пробелом, и наша
* единица приклеивается таким же сравнивать с обычным пробелом в литерале
* нельзя. Нормализуем, чтобы ожидания в тесте читались глазами.
*/
const norm = (s: string | null | undefined) => s?.replace(/[ ]/g, " ").replace(//g, "-");
const OK: LandingStats = {
estimates_total: {
value: 1123,
sample_n: 1123,
note: "оценок за период",
computed_at: "2026-08-29T03:00:00+00:00",
},
};
function mockFetch(impl: () => Promise<unknown>) {
vi.stubGlobal("fetch", vi.fn(impl));
}
/** Ответ ручки с произвольным статусом и телом. */
function response(status: number, body: unknown): Response {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
} as unknown as Response;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("fetchLandingStats: сбой не превращается в число", () => {
it("исправная ручка отдаёт метрику вместе с выборкой", async () => {
mockFetch(async () => response(200, OK));
const out = formatStat(await fetchLandingStats(), "estimates_total");
expect(norm(out?.text)).toBe("1 123");
expect(norm(out?.sample)).toBe("n = 1 123");
expect(out?.note).toBe("оценок за период");
expect(out?.computedAt).toBe("2026-08-29T03:00:00+00:00");
});
it("пустой объект — величины нет, а не ноль", async () => {
mockFetch(async () => response(200, {}));
const stats = await fetchLandingStats();
expect(stats).toEqual({});
expect(formatStat(stats, "estimates_total")).toBeNull();
});
it("нужного ключа в ответе нет — величины нет", async () => {
mockFetch(async () => response(200, OK));
expect(formatStat(await fetchLandingStats(), "deals_total_12m")).toBeNull();
});
it("сеть легла — пустой набор, а не подстановка", async () => {
mockFetch(() => Promise.reject(new TypeError("fetch failed")));
const stats = await fetchLandingStats();
expect(stats).toEqual({});
expect(formatStat(stats, "estimates_total")).toBeNull();
});
it("500 — пустой набор, а не подстановка", async () => {
mockFetch(async () => response(500, { detail: "boom" }));
const stats = await fetchLandingStats();
expect(stats).toEqual({});
expect(formatStat(stats, "estimates_total")).toBeNull();
});
it("200 с не-JSON телом — пустой набор", async () => {
mockFetch(
async () =>
({
ok: true,
status: 200,
json: async () => {
throw new SyntaxError("Unexpected token < in JSON");
},
}) as unknown as Response,
);
expect(await fetchLandingStats()).toEqual({});
});
it("страница не падает: функция не бросает ни на одном виде сбоя", async () => {
const failures = [
() => Promise.reject(new TypeError("fetch failed")),
async () => response(503, null),
async () => response(200, null),
];
for (const impl of failures) {
mockFetch(impl);
await expect(fetchLandingStats()).resolves.toBeDefined();
}
});
});
describe("formatStat: n нельзя потерять, ноль нельзя выдумать", () => {
const stat = (over: Partial<LandingStat>): LandingStats => ({
m: {
value: 48.1,
sample_n: 6276,
note: null,
computed_at: "2026-08-29T03:00:00+00:00",
...over,
},
});
it("число едет вместе с подписью выборки", () => {
const out = formatStat(stat({}), "m", { unit: "%", digits: 1, sampleWord: "объявлениям" });
expect(norm(out?.text)).toBe("48,1 %");
expect(norm(out?.sample)).toBe("по 6 276 объявлениям");
});
it("отрицательное значение не теряет знак", () => {
const out = formatStat(stat({ value: -2.17, sample_n: 3018 }), "m", { digits: 2 });
expect(norm(out?.text)).toBe("-2,17");
expect(norm(out?.sample)).toBe("n = 3 018");
});
it("value = null — величины нет", () => {
expect(formatStat(stat({ value: null }), "m")).toBeNull();
});
it("пустая строка — величины нет", () => {
expect(formatStat(stat({ value: " " }), "m")).toBeNull();
});
it("sample_n = null — величина есть, подписи нет (но не выдуманный ноль)", () => {
const out = formatStat(stat({ sample_n: null }), "m");
expect(norm(out?.text)).toBe("48,1");
expect(out?.sample).toBeNull();
});
it("ноль как ИЗМЕРЕННАЯ величина показывается — это не то же, что отсутствие", () => {
expect(norm(formatStat(stat({ value: 0, sample_n: 12 }), "m")?.text)).toBe("0");
});
});
describe("fetchShowcase: нечего показать — секции нет", () => {
const SHOWCASE = {
computed_at: "2026-08-29T03:00:00+00:00",
deals: [
{
district: "Пионерский",
rooms: 2,
area_m2: 54.3,
floor: 5,
total_floors: 9,
deal_quarter: "II квартал 2026",
predicted_rub: 7_100_000,
fact_rub: 7_250_000,
err_pct: -2.1,
n_analogs: 14,
note: "",
},
],
stats: null,
};
it("строки есть — витрина едет целиком", async () => {
mockFetch(async () => response(200, SHOWCASE));
const out = await fetchShowcase();
expect(out?.deals).toHaveLength(1);
expect(out?.deals[0].district).toBe("Пионерский");
});
it("пустой список — null, а не витрина из нуля строк", async () => {
mockFetch(async () => response(200, { computed_at: null, deals: [], stats: null }));
expect(await fetchShowcase()).toBeNull();
});
it("500 — null", async () => {
mockFetch(async () => response(500, {}));
expect(await fetchShowcase()).toBeNull();
});
it("сеть легла — null, без исключения", async () => {
mockFetch(() => Promise.reject(new TypeError("fetch failed")));
await expect(fetchShowcase()).resolves.toBeNull();
});
});

View file

@ -92,11 +92,22 @@ describe("короткие адреса публичного домена", () =
});
/**
* Разрешённый «лишний» путь: временное превью второго варианта дизайна.
* Ссылок на него нет, страница noindex; строка удаляется вместе с выбором
* варианта. Держим здесь, чтобы проверка ниже была ДВУСТОРОННЕЙ.
* Раньше здесь стоял список PREVIEW_ONLY = ["/v3"] путь, открытый наружу
* сверх PUBLIC_ROUTES, ради превью второго варианта дизайна. 29.08.2026
* вариант выбран и стал корнем, каталог `v3/` во фронте удалён, поэтому
* список пуст: наружу не открыто ничего сверх известных страниц.
*
* Пустая константа, а не удалённая: она несёт формат, в котором такое
* исключение вообще допустимо (поимённо, с объяснением, в коде), и держит
* проверку ниже ДВУСТОРОННЕЙ. Заведётся следующее превью строка вернётся
* сюда, а не тихо в Caddyfile.
*
* Сам `/v3` при этом не исчез с домена: он стал 301 на корень (проверка
* «адрес выбывшего превью остаётся живым» ниже). Редирект живёт в другом
* матчере, `@meraV3Gone`, и в @meraPages его быть не должно путь, который
* проксируется во фронт, отдал бы 404.
*/
const PREVIEW_ONLY = ["/v3"];
const PREVIEW_ONLY: string[] = [];
const meraPagesLine = meraBlock.match(/@meraPages path ([^\n]+)/);
@ -173,6 +184,57 @@ describe("короткие адреса публичного домена", () =
).toBe(true);
}
});
/**
* ВЫБЫВШЕЕ ПРЕВЬЮ ОБЯЗАНО ОСТАТЬСЯ ЖИВЫМ АДРЕСОМ. `/v3` две недели был
* открыт на публичном домене владелец сравнивал по нему варианты дизайна,
* и ссылка разошлась по закладкам и переписке. 29.08.2026 вариант стал
* корнем, каталог `v3/` во фронте удалён; проксировать этот путь больше
* нельзя (Next отдаст 404), но и просто выкинуть его из белого списка
* нельзя это тот же класс дефекта, ради которого заведены проверки выше:
* рабочая ссылка молча превращается в 404.
*
* Правильный ответ ровно один 301 на корень, и он проверяется здесь, а не
* держится на памяти о том, почему в конфиге появился ещё один матчер.
*
* ФОРМ ЧЕТЫРЕ, А НЕ ДВЕ. Превью раздавалось и коротким `/v3`, и длинным
* `/trade-in/mera-public/v3`; вторая пара выбыла из @meraLongPages вместе со
* страницей и никуда не попала длинный адрес превью отдавал 404, ровно тот
* же дефект, что проверка выше ловит для страниц.
*/
it("адрес выбывшего превью /v3 отдаёт 301 на корень, а не 404", () => {
const matcher = meraBlock.match(/@meraV3Gone path ([^\n]+)/)?.[1] ?? "";
const paths = matcher.split(/\s+/).filter(Boolean);
expect(
paths,
"нет матчера @meraV3Gone — старые ссылки на превью упадут в catch-all 404",
).toEqual([
"/v3",
"/v3/",
"/trade-in/mera-public/v3",
"/trade-in/mera-public/v3/",
]);
// Тело `handle` содержит вложенный `route { … }`, поэтому берём до
// закрывающей скобки НА УРОВНЕ handle (четыре пробела), а не до первой.
const handler =
meraBlock.match(/handle @meraV3Gone \{([\s\S]*?)\n {4}\}/)?.[1] ?? "";
expect(
handler,
"матчер есть, а редиректа в нём нет — зонд выше прошёл бы вхолостую",
).toMatch(/redir\s+\*\s+\{uri\}\s+permanent/);
/**
* Query обязана переноситься иначе `/v3?utm=…` теряет атрибуцию у уже
* разосланных ссылок. Один `redir * {uri}` этого не гарантирует: без
* срезания пути он вернул бы Location, равный исходному адресу (цикл).
* Поэтому проверяется вся связка, а не только форма редиректа.
*/
expect(
handler,
"путь не срезается — {uri} вернёт исходный адрес, редирект закольцуется",
).toContain("uri strip_prefix /v3");
expect(handler).toContain("uri strip_prefix /trade-in/mera-public");
});
});
describe("basePath не протекает в публичные ссылки", () => {

View file

@ -0,0 +1,131 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { FAQ } from "../content";
/**
* Гейт «при переключении корня на v3 ничего не потерялось».
*
* Новый дизайн жил на /v3 и 29.08.2026 стал корнем meraocenka.ru. Перед этим
* разведка нашла блоки лэндинга v1, которых в v3 не было вовсе: реестр
* источников, «что вы получите» с дисклеймером 135-ФЗ, плашка покрытия и
* строка «мы не покупаем квартиры» в герое, вопросы FAQ, город в форме.
* Они перенесены здесь проверяется, что перенос ЖИВОЙ: секции подключены к
* странице, а компоненты читают те же константы, а не копии строк.
*
* Проверки читают исходники с диска тот же приём, что в
* `public-perimeter.test.ts` и `marketing-placeholders-gate.test.ts`: ломается
* здесь связь между файлами (страница секция, секция content.ts), а её
* юнит-тест на модуле не видит. Удалённая из вёрстки секция собирается,
* тайпчек молчит, и заметить это может только человек на живой странице.
*/
const MERA_PUBLIC = path.resolve(__dirname, "..");
function read(rel: string): string {
return readFileSync(path.join(MERA_PUBLIC, rel), "utf8");
}
describe("страница v3 рендерит перенесённые секции", () => {
const page = read("page.tsx");
it.each(["SourcesV3", "WhatYouGetV3"])("<%s /> подключён", (name) => {
expect(page).toContain(`<${name} />`);
expect(page).toContain(`_components/v3/${name}`);
});
});
describe("секции читают константы, а не копии строк", () => {
it("реестр источников выводится из SOURCE_GROUPS", () => {
expect(read("_components/v3/SourcesV3.tsx")).toContain(
'SOURCE_GROUPS } from "../../content"',
);
});
it("«что вы получите» несёт дисклеймер 135-ФЗ рядом со списком", () => {
const src = read("_components/v3/WhatYouGetV3.tsx");
expect(src).toContain("DELIVERABLES");
expect(
src,
"дисклеймер обязан стоять рядом со списком того, что человек получает",
).toContain("DELIVERABLES_DISCLAIMER");
});
it("герой называет границу покрытия и то, чем сервис не является", () => {
const hero = read("_components/v3/HeroV3.tsx");
expect(hero).toContain("SECONDARY_CITIES");
expect(hero).toContain("не покупаем квартиры и не берём их на продажу");
});
it("карточка проверки спрашивает город и не додумывает его", () => {
const card = read("_components/v3/FreeCheckCard.tsx");
// #2576: без города «Ленина, 1» давало уверенную цену по одноимённой улице
// в Екатеринбурге жителю Нижнего Тагила.
expect(card).toContain("COVERED_CITIES");
expect(card).toContain("detectCityInText");
// Состояние спрашивается обязательным полем — значит обязано доехать.
expect(card).toMatch(/saveDraft\([^)]*condition/s);
});
});
describe("КАЖДЫЙ вопрос FAQ живёт в возражениях v3", () => {
const objections = read("_components/v3/ObjectionsV3.tsx");
/**
* Список вопросов НЕ ЗАХАРДКОЖЕН он перебирается из самого `FAQ`. В этом
* вся разница с прежней версией проверки: она перечисляла два id вручную и
* потому была зелёной ровно в тот момент, когда `Faq.tsx` (единственный, кто
* рендерил все шесть) удалили вместе с лэндингом v1, а в возражения v3
* перенесли только эти два. Четыре ответа, прошедшие юр-ревью, не
* рендерились нигде, и ни один гейт этого не сказал.
*
* Перебор по `FAQ` краснеет в обе стороны:
* - вопрос убрали из ObjectionsV3 его id в файле нет красный;
* - id переименовали в content.ts в файле остался СТАРЫЙ id, `FAQ.find`
* молча вернёт undefined и вопрос исчезнет со страницы красный;
* - в content.ts добавили седьмой вопрос он обязан быть либо отрисован,
* либо осознанно исключён (тогда правится и этот тест), а не потерян.
*
* Ищем `...fromFaq("id")` со спредом, а не просто id: спред это то, что
* кладёт вопрос в `ITEMS`. Объявление без спреда собралось бы, тайпчек бы
* промолчал, а на странице вопроса не было бы.
*/
it("в FAQ вообще есть вопросы — иначе перебор ниже пустой", () => {
expect(FAQ.length).toBeGreaterThan(0);
});
it.each(FAQ.map((f) => f.id))("%s отрисован", (id) => {
expect(
objections,
`вопрос "${id}" не попадает в ITEMS — на странице его не будет, и заметить это может только человек`,
).toContain(`...fromFaq("${id}")`);
});
/**
* Отсылка к странице про обработку персональных данных обязана быть ЖИВОЙ
* ССЫЛКОЙ. В `content.ts` она лежит текстом, ObjectionsV3 линкует её по
* дословному совпадению значит переформулировка предложения в content.ts
* тихо превращает ссылку обратно в текст. Проверка сверяет обе стороны.
*/
it("отсылка к странице ПДн отдаётся ссылкой, а не текстом", () => {
const phrase =
objections.match(/const PRIVACY_PHRASE = "([^"]+)"/)?.[1] ?? "";
expect(
phrase,
"зонд сломан: в ObjectionsV3 не найдена константа связующей фразы",
).not.toBe("");
const answer = FAQ.find((f) => f.id === "personal-data")?.a.join(" ") ?? "";
expect(
answer,
"фраза в content.ts переформулирована — ссылка на страницу ПДн молча станет обычным текстом",
).toContain(phrase);
expect(
objections,
"ссылка обязана идти через PublicLink (обычный <a>): next/link подставит basePath",
).toContain('<PublicLink to="privacy"');
});
});

View file

@ -1,225 +0,0 @@
"use client";
/**
* AddressForm поле адреса на первом экране.
*
* ЧТО ЭТА ФОРМА ДЕЛАЕТ СЕГОДНЯ И ПОЧЕМУ ИМЕННО ТАК
*
* Она не считает цену и не притворяется, что считает. Причина техническая и
* жёсткая: `rbac_guard` (backend/app/core/rbac.py) пропускает анонима только на
* пути из `_PUBLIC_PATHS`, а `/api/v1/geocode/suggest` и
* `/api/v1/trade-in/estimate` туда не входят любой запрос отсюда вернул бы
* 401. Открытие анонимного периметра отдельный backend-PR, вне границ этой
* задачи. Поэтому здесь честная валидация на клиенте + прямой ответ «публичный
* расчёт ещё не открыт» вместо фейкового спиннера.
*
* Что форма всё-таки делает по-настоящему:
* - проверяет, что адрес введён;
* - требует явно назвать город и не подставляет Екатеринбург молча. Это ровно
* тот баг, который чинил бэкенд в #2576: житель Нижнего Тагила вводил
* «Ленина, 1» и получал уверенную цену по одноимённой улице в ЕКБ. Правило
* из шапки `lib/city-registry.ts` город считается известным только если
* пользователь его выбрал ИЛИ `detectCityInText` нашёл его в тексте;
* - если названного города нет в покрытии мягко и честно говорит про
* Свердловскую область, не обещая «оценим любую квартиру в РФ».
*
* Осознанно НЕ переиспользован автокомплит из закрытого контура
* (ParamsPanel.tsx / AddressInput.tsx): он ходит в `/geocode/suggest` через
* `useGeocodeSuggest`, что для анонима = 401. Тянуть сюда хуки B2B-контура
* (useMe/useQuota/useHistory и соседей) запрещено публичный экран не должен
* иметь к ним доступа даже теоретически.
*
* КУДА ВЕДЁТ КНОПКА (15.08.2026). Раньше сабмит упирался в сообщение «расчёт
* ещё не открыли» анонимных ручек не существовало. Теперь они есть
* (`/api/public/mera/*`), а сама проверка живёт на отдельном экране
* `/estimate`: там у автокомплита есть место под список подсказок, а у
* результата место рядом с полями. Эта форма осталась входной точкой
* лэндинга и делает ровно две полезные вещи не пускает дальше без адреса и
* требует явно назвать город (#2576: «Ленина, 1» без города давало уверенную
* цену по одноимённой улице в ЕКБ жителю Нижнего Тагила).
*/
import { useId, useRef, useState } from "react";
import type { FormEvent } from "react";
import { detectCityInText } from "@/lib/city-registry";
import {
COVERED_CITIES,
PRIMARY_CITY,
PUBLIC_ROUTES,
REGION_NAME,
SECONDARY_CITIES,
} from "../content";
import { saveDraft } from "../estimate-draft";
import styles from "../landing.module.css";
/** Значение <option> «моего города нет в списке». */
const OTHER_CITY = "__other__";
type Feedback =
| { kind: "none" }
| { kind: "error"; field: "address" | "city"; text: string }
| { kind: "info"; title: string; lines: readonly string[] };
const NONE: Feedback = { kind: "none" };
export function AddressForm() {
const addressId = useId();
const cityId = useId();
const feedbackId = useId();
const [address, setAddress] = useState("");
const [city, setCity] = useState("");
const [feedback, setFeedback] = useState<Feedback>(NONE);
const addressRef = useRef<HTMLInputElement>(null);
const cityRef = useRef<HTMLSelectElement>(null);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmed = address.trim();
if (!trimmed) {
setFeedback({
kind: "error",
field: "address",
text: "Напишите улицу и номер дома — например, «Ленина, 5».",
});
addressRef.current?.focus();
return;
}
// Город известен, только если его выбрали руками или назвали в тексте
// адреса. Ничего не додумываем — см. шапку файла.
const detected = detectCityInText(trimmed);
const resolvedCity =
city === OTHER_CITY ? OTHER_CITY : city || detected || "";
if (!resolvedCity) {
setFeedback({
kind: "error",
field: "city",
text: "Выберите город: одинаковые названия улиц есть в разных городах области, и без города цена будет не про вашу квартиру.",
});
cityRef.current?.focus();
return;
}
if (resolvedCity === OTHER_CITY) {
setFeedback({
kind: "info",
title: `Пока мы считаем только по одному региону — ${REGION_NAME}`,
lines: [
`Данные мы собираем сами, город за городом: полностью — ${PRIMARY_CITY}, частично — ${SECONDARY_CITIES.join(", ")}. По остальным адресам оценка была бы догадкой, поэтому мы её не показываем.`,
],
});
return;
}
if (city === "" && detected) {
// Город распознали в тексте — синхронизируем селект, чтобы человек видел,
// что именно мы поняли, и мог поправить.
setCity(detected);
}
// Уходим на отдельный экран проверки. Черновик кладём в sessionStorage, а
// НЕ в query-параметры: адрес в URL попал бы в access-логи Caddy рядом с
// IP посетителя — то есть мы бы хранили персональные данные там, где
// публично обещали ничего не хранить (см. estimate-draft.ts).
saveDraft({ address: trimmed, city: resolvedCity });
window.location.assign(PUBLIC_ROUTES.estimate);
}
const addressInvalid =
feedback.kind === "error" && feedback.field === "address";
const cityInvalid = feedback.kind === "error" && feedback.field === "city";
const describedBy = feedback.kind === "none" ? undefined : feedbackId;
return (
<form className={styles.form} onSubmit={handleSubmit} noValidate>
<div className={styles.formRow}>
<div className={`${styles.field} ${styles.fieldCity}`}>
<label className={styles.label} htmlFor={cityId}>
Город
</label>
<select
id={cityId}
ref={cityRef}
className={`${styles.select} ${cityInvalid ? styles.inputInvalid : ""}`}
value={city}
onChange={(event) => {
setCity(event.target.value);
setFeedback(NONE);
}}
aria-invalid={cityInvalid || undefined}
aria-describedby={cityInvalid ? describedBy : undefined}
>
<option value="">Выберите город</option>
{COVERED_CITIES.map((label) => (
<option key={label} value={label}>
{label}
</option>
))}
<option value={OTHER_CITY}>Другой город</option>
</select>
</div>
<div className={`${styles.field} ${styles.fieldAddress}`}>
<label className={styles.label} htmlFor={addressId}>
Улица и дом
</label>
<input
id={addressId}
ref={addressRef}
className={`${styles.input} ${addressInvalid ? styles.inputInvalid : ""}`}
type="text"
name="address"
autoComplete="street-address"
enterKeyHint="go"
placeholder="Например, Ленина, 5"
value={address}
onChange={(event) => {
setAddress(event.target.value);
setFeedback(NONE);
}}
aria-invalid={addressInvalid || undefined}
aria-describedby={addressInvalid ? describedBy : undefined}
/>
</div>
{/* Кнопка НИКОГДА не disabled по валидности: disabled-кнопка не
диспатчит submit, и невалидная попытка молча ничего бы не делала
вместо объяснения (тот же фикс, что в v2/LeadForm.tsx). */}
<button type="submit" className={styles.cta}>
Узнать цену
</button>
</div>
<p className={styles.formHint}>
Ничего не спишется и не позвонит: телефон мы спрашиваем, только если вы
сами оставите заявку.
</p>
{/* Живая область объявляется скринридеру при любой смене содержимого.
Держим её в DOM постоянно регион, добавленный в момент ошибки,
часть скринридеров не озвучивает. */}
<div id={feedbackId} role="status" aria-live="polite">
{feedback.kind === "error" && (
<div className={`${styles.formFeedback} ${styles.formFeedbackError}`}>
<p className={styles.formFeedbackText}>{feedback.text}</p>
</div>
)}
{feedback.kind === "info" && (
<div className={styles.formFeedback}>
<p className={styles.formFeedbackTitle}>{feedback.title}</p>
{feedback.lines.map((line) => (
<p key={line} className={styles.formFeedbackText}>
{line}
</p>
))}
</div>
)}
</div>
</form>
);
}

View file

@ -1,55 +0,0 @@
/**
* DataSources блок доверия «откуда мы берём цифры». Серверный компонент.
*
* Названия площадок НЕ вбиты строками: группы собираются в content.ts из
* `lib/source-registry.ts`, который и есть единственный источник правды по
* источникам (контракт честности #2211). Добавится площадка в реестр она
* появится здесь сама; исчезнет исчезнет и тут.
*
* Логотипов площадок нет намеренно: чужие товарные знаки на публичной странице
* отдельный юридический вопрос, а картинки пришлось бы тянуть с чужих
* доменов (запрещено). Текстовые чипы решают ту же задачу.
*/
import { SOURCE_GROUPS } from "../content";
import styles from "../landing.module.css";
export function DataSources() {
return (
<section className={styles.section} aria-labelledby="sources-title">
<div className={styles.container}>
<div className={styles.sectionHead}>
<h2 id="sources-title" className={styles.h2}>
Откуда мы берём данные
</h2>
{/* Без числа групп в тексте: группы выводятся из реестра источников,
и «два типа данных» уже однажды разъехалось с кодом оценочные
модели площадок участвуют в расчёте (estimator.py, IMV/Yandex
blend), но в тексте их не было. */}
<p className={styles.sectionLead}>
Мы не опрашиваем экспертов и не берём цифры из головы. Данные
разного происхождения отвечают на разные вопросы поэтому мы держим
их раздельно и показываем, что откуда.
</p>
</div>
<div className={styles.sourceGroups}>
{SOURCE_GROUPS.map((group) => (
<div key={group.title} className={styles.sourceGroup}>
<h3 className={styles.sourceGroupTitle}>{group.title}</h3>
<ul className={styles.sourceChips} role="list">
{group.items.map((item) => (
<li key={item} className={styles.sourceChip}>
<span className={styles.sourceChipDot} aria-hidden="true" />
{item}
</li>
))}
</ul>
<p className={styles.sourceGroupNote}>{group.note}</p>
</div>
))}
</div>
</div>
</section>
);
}

View file

@ -1,65 +0,0 @@
/**
* Faq аккордеон на нативных <details>/<summary>. Серверный компонент.
*
* Почему без "use client": браузер уже умеет раскрывать details по Enter/Space,
* ставит фокус на summary и сам сообщает состояние скринридеру. Самописный
* аккордеон на useState потребовал бы вручную воспроизвести aria-expanded,
* управление фокусом и клавиатуру и работал бы хуже до гидратации. Клиентский
* JS здесь не нужен вообще.
*/
import { FAQ } from "../content";
import styles from "../landing.module.css";
export function Faq() {
return (
<section
className={`${styles.section} ${styles.sectionAlt}`}
aria-labelledby="faq-title"
>
<div className={styles.container}>
<div className={styles.sectionHead}>
<h2 id="faq-title" className={styles.h2}>
Частые вопросы
</h2>
</div>
<div className={styles.faqList}>
{FAQ.map((item) => (
<details key={item.id} className={styles.faqItem}>
<summary className={styles.faqSummary}>
<span>{item.q}</span>
<ChevronIcon />
</summary>
<div className={styles.faqAnswer}>
{item.a.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</div>
</details>
))}
</div>
</div>
</section>
);
}
function ChevronIcon() {
return (
<svg
className={styles.faqChevron}
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<polyline points="6 9 12 15 18 9" />
</svg>
);
}

View file

@ -1,87 +0,0 @@
/**
* Hero первый экран: что это, для кого, и сразу граница по географии.
*
* Плашка «Свердловская область» стоит ДО формы намеренно (решение владельца):
* человек должен узнать про ограничение раньше, чем потратит время на ввод
* адреса, а не после. Города берутся из реестра (`COVERED_CITIES`), не из
* строки, иначе разъедутся с реальным покрытием сбора.
*
* Серверный компонент; клиентская часть только `AddressForm`.
*/
import { PRIMARY_CITY, REGION_NAME, SECONDARY_CITIES } from "../content";
import styles from "../landing.module.css";
import { AddressForm } from "./AddressForm";
export function Hero() {
return (
<section className={styles.hero} aria-labelledby="hero-title">
{/* Два вложенных div'а, а не два класса на одном: `.container` задаёт
общую 1120px-сетку страницы и центрирует её, `.heroInner` узкую
колонку измерения (720px) ВНУТРИ неё, прижатую к левому краю. Пока оба
класса висели на одном элементе, побеждал max-width: 720px, и весь
первый экран уезжал вправо относительно всех секций ниже (на 1440px
на 200px). */}
<div className={styles.container}>
<div className={styles.heroInner}>
<p className={styles.eyebrow}>Мера · оценка квартиры</p>
<h1 id="hero-title" className={styles.h1}>
Сколько на самом деле стоит ваша квартира
</h1>
<p className={styles.heroLead}>
Введите адрес покажем, за сколько продаются похожие квартиры рядом
и, если по вашему дому или поблизости есть зарегистрированные
сделки, за сколько их реально покупают. Без звонка риелтора и без
визита оценщика.
</p>
{/* Покрытие подано неравномерно намеренно см. COVERED_CITIES в
content.ts: список городов в реестре карта покрытия. */}
<div className={styles.regionBadge}>
<p className={styles.regionBadgeTitle}>
<PinIcon />
{REGION_NAME}
</p>
<p className={styles.regionBadgeCities}>
Полное покрытие {PRIMARY_CITY}. По остальным городам области (
{SECONDARY_CITIES.join(", ")}) данных меньше, и оценка там может
быть ориентировочной. По другим регионам не считаем вовсе не
хотим гадать.
</p>
</div>
<AddressForm />
<p className={styles.heroNote}>
«Мера» сервис оценки вторичного жилья по рыночным данным. Мы не
покупаем квартиры и не берём их на продажу: наша работа показать
цифру и то, откуда она взялась.
</p>
</div>
</div>
</section>
);
}
function PinIcon() {
return (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
);
}

View file

@ -1,46 +0,0 @@
/**
* HowItWorks три шага. Серверный компонент.
*
* Разметка упорядоченный список <ol>: порядок шагов несёт смысл, и
* скринридер объявит «список из 3 элементов, элемент 1», без декоративных
* кружков с цифрами.
*
* `role="list"` не избыточность. WebKit СНИМАЕТ роли list/listitem со списка,
* у которого `list-style: none` (а он здесь есть, см. `.steps`), и в
* Safari/VoiceOver порядковый номер исчезал бы полностью: из семантики из-за
* этого quirk'а, из текста потому что видимая подпись «ШАГ N» помечена
* aria-hidden именно чтобы не дублировать семантику. Атрибут возвращает роли
* обратно. То же самое сделано у остальных списков лэндинга с list-style: none.
*/
import { STEPS } from "../content";
import styles from "../landing.module.css";
export function HowItWorks() {
return (
<section className={styles.section} aria-labelledby="how-title">
<div className={styles.container}>
<div className={styles.sectionHead}>
<h2 id="how-title" className={styles.h2}>
Как это работает
</h2>
<p className={styles.sectionLead}>
Три шага, никакой регистрации на входе.
</p>
</div>
<ol className={styles.steps} role="list">
{STEPS.map((step, index) => (
<li key={step.title} className={styles.step}>
<span className={styles.stepNum} aria-hidden="true">
ШАГ {index + 1}
</span>
<h3 className={styles.stepTitle}>{step.title}</h3>
<p className={styles.stepText}>{step.text}</p>
</li>
))}
</ol>
</div>
</section>
);
}

View file

@ -8,13 +8,39 @@ import { PublicLink } from "./PublicLink";
* (`v3/HeaderV3.tsx`).
*
* Из неё убраны навигация по секциям лэндинга, селектор города и CTA
* «Проверить за 30 секунд». Причина у всех трёх одна: человек уже ушёл с
* «Проверить квартиру». Причина у всех трёх одна: человек уже ушёл с
* лэндинга. Якоря вроде `#accuracy` тут просто не на что навести (секций нет
* на этих страницах), город выбирается прямо в форме проверки а второй
* селектор города в шапке означал бы два места, задающих одно значение.
*
* Логотип отсюда ссылка на главную: это единственный обратный путь на
* лэндинг, и он же то, что человек инстинктивно нажимает.
*
* ДЕСКРИПТОРА «ОЦЕНКА ВТОРИЧНОГО ЖИЛЬЯ ПО РЫНОЧНЫМ ДАННЫМ · <РЕГИОН>» ЗДЕСЬ
* НЕТ И ЭТО РЕШЕНИЕ, А НЕ ПОТЕРЯ. Строка жила в `SiteHeader.tsx` (шапка
* лэндинга v1) с формулировкой, о которой просил юрист 14.08.2026: слова «по
* рыночным данным» то, чем сервис отличается от оценки по 135-ФЗ, и они
* должны стоять в заметном дескрипторе, а не только в дисклеймерах внизу.
* 29.08.2026 `SiteHeader` удалён, юр-страницы переведены на эту шапку, и
* строка с них ушла.
*
* Почему она сюда не возвращается:
* - ЭТО ШАПКА НЕ ТОЛЬКО ЮР-СТРАНИЦ. Её же носят `/estimate`, `/articles`,
* `/docs`, у которых дескриптора не было никогда: возврат «на место»
* означал бы новую строку на трёх страницах, которых правка не касается.
* - ТРЕБОВАНИЕ ЮРИСТА БЫЛО ПРО ЛЭНДИНГ про первое, что видит посетитель.
* На корне оно выполняется и сейчас, только не шапкой: `HeroV3` первым же
* экраном пишет «Мера сервис оценки вторичного жилья по рыночным
* данным», и там же стоит граница покрытия.
* - НА САМИХ ЮР-СТРАНИЦАХ ЭТО СКАЗАНО СИЛЬНЕЕ, ЧЕМ В ДЕСКРИПТОРЕ. Оферта
* говорит то же в тексте договора (п. 1.3 «индикативный расчёт
* ориентировочной рыночной стоимости», п. 6.2 «не является отчётом об
* оценке по 135-ФЗ»), а `FooterV3` на каждой из этих страниц несёт и
* рыночную природу оценки с регионом («Оценка квартир на вторичном рынке
* по сделкам и объявлениям. <регион>»), и прямой отказ («не является
* офертой или отчётом об оценке»).
* Появится страница, где ни то ни другое не выполняется, дескриптор нужно
* вернуть ей, а не этой общей шапке.
*/
export function InnerHeader() {
return (

View file

@ -1,117 +0,0 @@
/**
* SiteFooter подвал. Серверный компонент.
*
* 2026-08-13: блоки реквизитов и почты, которых здесь раньше не было, теперь
* рендерятся данные появились (`LEGAL_ENTITY`, `SUPPORT_EMAIL` в content.ts),
* и это не косметика: наименование, ИНН, ОГРН и работающий контакт на странице
* с офертой прямое требование модерации эквайера и условие идентифицируемости
* оператора по 152-ФЗ. Значения импортируются, а не пишутся строками: тот же
* набор рендерят оферта и политика ПДн, и разъехаться они не должны.
*
* ССЫЛКИ НА ДОКУМЕНТЫ КОРОТКИЕ (изменено 15.08.2026 по решению владельца).
* Раньше здесь стояли внутренние пути `/mera-public/oferta` и соседи, чтобы
* подвал работал и при открытии поддерева с gendsgn.ru. Теперь наоборот:
* человек на публичном сервисе не должен видеть `/trade-in/mera-public/...` в
* адресной строке, и именно короткие адреса напечатаны внутри самих
* документов. Цена решения переходы в подвале работают только на
* meraocenka.ru; разбор и что с этим делать в шапке `PUBLIC_ROUTES`
* (content.ts).
*
* Внешняя ссылка проверяется `safeUrl` (правило frontend.md: ничего в href без
* валидации схемы) и открывается в новой вкладке с rel="noreferrer".
*/
import { safeUrl } from "@/lib/safeUrl";
import {
LEGAL_ENTITY,
REGION_NAME,
SUPPORT_EMAIL,
SUPPORT_TELEGRAM_LABEL,
SUPPORT_TELEGRAM_URL,
} from "../content";
import styles from "../landing.module.css";
import { PublicLink } from "./PublicLink";
export function SiteFooter() {
const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL);
const year = new Date().getFullYear();
return (
<footer className={styles.footer}>
<div className={styles.container}>
<div className={styles.footerGrid}>
<div>
<div className={styles.wordmark}>
<span className={styles.wordmarkDot} aria-hidden="true" />
МЕРА
</div>
<p className={styles.footerText} style={{ marginTop: 10 }}>
Оценка квартир на вторичном рынке по сделкам и объявлениям.{" "}
{REGION_NAME}.
</p>
</div>
<div>
<p className={styles.footerTitle}>Связаться</p>
<p className={styles.footerText}>
Почта:{" "}
<a className={styles.link} href={`mailto:${SUPPORT_EMAIL}`}>
{SUPPORT_EMAIL}
</a>
</p>
{telegramHref ? (
<p className={styles.footerText}>
Поддержка в Telegram:{" "}
<a
className={styles.link}
href={telegramHref}
target="_blank"
rel="noreferrer"
>
{SUPPORT_TELEGRAM_LABEL}
</a>
</p>
) : null}
</div>
<div>
<p className={styles.footerTitle}>Документы</p>
<ul className={styles.footerLinks} role="list">
<li>
<PublicLink className={styles.link} to="oferta">
Публичная оферта
</PublicLink>
</li>
<li>
<PublicLink className={styles.link} to="refund">
Политика возврата
</PublicLink>
</li>
<li>
<PublicLink className={styles.link} to="privacy">
Обработка персональных данных
</PublicLink>
</li>
</ul>
</div>
</div>
<div className={styles.footerBottom}>
<span>© {year} МЕРА</span>
{LEGAL_ENTITY && (
<span>
{LEGAL_ENTITY.name}, ИНН {LEGAL_ENTITY.inn}, ОГРН{" "}
{LEGAL_ENTITY.ogrn}, {LEGAL_ENTITY.address}
</span>
)}
<span>
Оценка носит информационный характер и не является офертой или
отчётом об оценке.
</span>
</div>
</div>
</footer>
);
}

View file

@ -1,34 +0,0 @@
/**
* SiteHeader шапка лэндинга. Серверный компонент: интерактивности нет.
*
* Логотип НЕ является ссылкой на "/": в next.config.ts стоит redirect "/" "/v2",
* то есть клик по нему выкинул бы публичного посетителя в закрытое B2B-приложение
* (и дальше на /login). Пока публичная страница одна, вордмарк просто текст.
*
* ФОРМУЛИРОВКА ДЕСКРИПТОРА (14.08.2026, юр-документ владельца, блок 3). Строка
* шапки обязана читаться как «МЕРА · Оценка вторичного жилья по рыночным данным ·
* <регион>»: слова «по рыночным данным» то, чем сервис отличается от оценки по
* 135-ФЗ, и юрист попросил, чтобы это стояло в самом заметном дескрипторе, а не
* только в дисклеймерах внизу. «МЕРА» приходит из вордмарка ниже (у него свой
* letter-spacing), поэтому в headerTag её нет строка складывается из двух узлов.
* Регион из REGION_NAME, не строкой (правило content.ts).
*/
import { REGION_NAME } from "../content";
import styles from "../landing.module.css";
export function SiteHeader() {
return (
<header className={styles.header}>
<div className={`${styles.container} ${styles.headerInner}`}>
<div className={styles.wordmark}>
<span className={styles.wordmarkDot} aria-hidden="true" />
МЕРА
</div>
<p className={styles.headerTag}>
Оценка вторичного жилья по рыночным данным · {REGION_NAME}
</p>
</div>
</header>
);
}

View file

@ -1,93 +0,0 @@
/**
* WhatYouGet что человек получает по итогу. Серверный компонент.
*
* Каждый пункт соответствует блоку, который результат оценки рендерит сегодня
* (`v2/types.ts` ResultCard.value/range/ppm/delta/ResultMeta.builtOn).
* Ничего «планируемого» и ничего недоступного анониму в списке нет правило и
* разбор по PDF в шапке content.ts.
*
* Дисклеймер про 135-ФЗ обязателен: «Мера» даёт рыночную оценку по
* сопоставимым объектам, а не отчёт аккредитованного оценщика.
*/
import { DELIVERABLES, DELIVERABLES_DISCLAIMER } from "../content";
import styles from "../landing.module.css";
export function WhatYouGet() {
return (
<section
className={`${styles.section} ${styles.sectionAlt}`}
aria-labelledby="get-title"
>
<div className={styles.container}>
<div className={styles.sectionHead}>
<h2 id="get-title" className={styles.h2}>
Что вы получите
</h2>
<p className={styles.sectionLead}>
Не «примерную стоимость», а расчёт, который видно насквозь.
</p>
</div>
<ul className={styles.deliverables} role="list">
{DELIVERABLES.map((item) => (
<li key={item.title} className={styles.deliverable}>
<CheckIcon />
<div>
<h3 className={styles.deliverableTitle}>{item.title}</h3>
<p className={styles.deliverableText}>{item.text}</p>
</div>
</li>
))}
</ul>
<p className={styles.disclaimer}>
<InfoIcon />
<span>{DELIVERABLES_DISCLAIMER}</span>
</p>
</div>
</section>
);
}
function CheckIcon() {
return (
<svg
className={styles.checkIcon}
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<polyline points="20 6 9 17 4 12" />
</svg>
);
}
function InfoIcon() {
return (
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
style={{ flex: "0 0 auto", marginTop: 2 }}
>
<circle cx="12" cy="12" r="9" />
<line x1="12" y1="11" x2="12" y2="16.5" />
<line x1="12" y1="7.6" x2="12" y2="7.7" />
</svg>
);
}

View file

@ -23,11 +23,24 @@
* «возраст активного объявления», а НЕ макетное «срок продажи по дому»
* выборка цензурирована, честное имя величины важнее красоты.
*
* Возврат 150 при ненадёжном расчёте политика с /refund; в тизере она
* Возврат при ненадёжном расчёте политика с /refund; в тизере она
* упоминается той же строкой, что в макете, со ссылкой на полный текст.
*
* ЦЕНА ТОЛЬКО `SERVICE_PRICE_RUB`. Здесь оставались три литерала «150 »
* (плашка тизера, текст и строка возврата). Цена стоит в оферте и в политике
* возврата; вписанная в экран копия разъехалась бы с ними молча, и человек
* увидел бы одну цифру на витрине, а другую в документе, который его
* обязывает.
*
* СРОК. Строка тизера обещала «прогноз срока продажи»: платный расчёт отдаёт
* `est_days_on_market` медиану `days_on_market` аналогов, то есть сколько
* похожие объявления УЖЕ висят. `deals.days_on_market` заполнен 0 раз из
* 108 623, поэтому сверить прогноз срока не с чем и прогнозом эта величина
* не является. Названа тем, что считается, ровно как в пункте 5 выше про
* бесплатные плитки.
*/
import { SUPPORT_TELEGRAM_LABEL, SUPPORT_TELEGRAM_URL } from "../../content";
import { SERVICE_PRICE_RUB, SUPPORT_TELEGRAM_LABEL, SUPPORT_TELEGRAM_URL } from "../../content";
import type { CoverageVerdict } from "../../coverage-copy";
import styles from "../../landing-v3.module.css";
@ -129,7 +142,7 @@ function FreeCards({ verdict }: { verdict: CoverageVerdict }) {
const PAID_ROWS = [
{ label: "РЕКОМЕНДОВАННАЯ ЦЕНА", w: "60%" },
{ label: "ПРОГНОЗ СРОКА ПРОДАЖИ ПРИ ЭТОЙ ЦЕНЕ", w: "45%" },
{ label: "СКОЛЬКО УЖЕ ВИСЯТ ПОХОЖИЕ ОБЪЯВЛЕНИЯ", w: "45%" },
{ label: "ДИАПАЗОН И ПОЛОЖЕНИЕ НА РЫНКЕ", w: "70%" },
{ label: "АНАЛОГИ С АДРЕСАМИ И ЦЕНАМИ", w: "88%" },
] as const;
@ -139,7 +152,9 @@ function PaidTeaser() {
return (
<div className={styles.repPaid}>
<div className={styles.repPaidBar}>
<span className={styles.repPaidBarText}>ПОЛНЫЙ ОТЧЁТ · 150 </span>
<span className={styles.repPaidBarText}>
ПОЛНЫЙ ОТЧЁТ · {SERVICE_PRICE_RUB}&nbsp;
</span>
</div>
<div className={styles.repPaidGrid}>
<div className={styles.repPaidLeft}>
@ -159,17 +174,17 @@ function PaidTeaser() {
</div>
<div className={styles.repPaidRight}>
<div className={styles.repPaidTitle}>
Цена и срок продажи следующий шаг
Ваша цена следующий шаг
</div>
<p className={styles.repPaidText}>
Полный расчёт стоит 150&nbsp;: диапазон цены, цена за квадратный
метр и список квартир, на которых он построен. Один платёж, без
подписки. Приём оплаты сейчас подключается как только заработает,
кнопка появится здесь.
Полный расчёт стоит {SERVICE_PRICE_RUB}&nbsp;: диапазон цены, цена
за квадратный метр и список квартир, на которых он построен. Один
платёж, без подписки. Приём оплаты сейчас подключается как только
заработает, кнопка появится здесь.
</p>
<p className={styles.repPaidFine}>
Если надёжный расчёт не получится вернём 150&nbsp;:{" "}
<PublicLink to="refund">условия возврата</PublicLink>.
Если надёжный расчёт не получится вернём {SERVICE_PRICE_RUB}
&nbsp;: <PublicLink to="refund">условия возврата</PublicLink>.
</p>
</div>
</div>

View file

@ -1,21 +1,27 @@
/**
* AccuracyV3 «Точность»: KPI-плитки + таблица сверок прогноза с фактом
* сделки (макет v3, ~строки 231-283, id="accuracy"). Серверный компонент.
* AccuracyV3 «Точность»: KPI-плитки + таблица «прогноз против цены сделки»
* (макет v3, ~строки 231-283, id="accuracy"). Серверный компонент.
*
* KPI-значения из `ACCURACY_PLACEHOLDER` (marketing-v3.ts), не хардкод: это
* плейсхолдерные маркетинговые величины, гейтящиеся `noindex` до подтверждения
* замером (см. шапку файла).
* ОТКУДА ЧИСЛА. Три первых плитки разовая сверка прогноза с ценой ДКП
* (`landing-facts.ts`, там же источник и оговорки). Экспозиция и число
* расчётов `/stats`, каждая плитка рендерится только если величина пришла:
* недоступная ручка снимает плитку, а не показывает ноль. Таблица `/showcase`
* (реальные сделки Росреестра против прогноза), и вместе со строками едет
* подпись: сколько сделок рассмотрено, сколько годных не поместилось и по
* какому правилу отсеяно остальное.
*
* Таблица сверок `PROOF_ROWS_PLACEHOLDER`. В самом макете под неё не было
* заполнено ни одной реальной строки (только `{{ r.addr }}`-шаблон), поэтому
* строки витринный пример формата отчёта, а не факт. Ровно поэтому подпись
* под таблицей ЗАМЕНЕНА: в макете там было «ДАННЫЕ ОБЕЗЛИЧЕНЫ · » фраза,
* прямо утверждающая, что за строками стоят настоящие обезличенные сделки.
* Мы этого утверждать не можем (строки придуманы для примера), поэтому подпись
* честно называет таблицу иллюстрацией формата, а вторую половину исходной
* фразы («"выше средней цены" разница со средней ценой сделки по похожим
* квартирам в районе») сохраняет она объясняет, ЧТО значит колонка, а не
* заявляет что-то о происхождении данных.
* ТРИ ПЛИТКИ БЭКТЕСТА ИДУТ КОМПЛЕКТОМ И ПОРОЗНЬ НЕ ПОКАЗЫВАЮТСЯ. «88 % в
* коридоре» без ширины коридора (±37 %) и без того, что уверенность расчёта
* низкая у 325 из 327, это три разных способа выглядеть точнее, чем есть.
* Поэтому ширина коридора стоит в ПОДПИСИ к самой плитке, а не отдельной
* строкой мелким шрифтом, и оговорки рендерятся тут же, под сеткой.
*
* ЧЕГО ЗДЕСЬ НЕТ. Плитки «точность по сроку продажи»: `deals.days_on_market`
* заполнен 0 раз из 108 623 сверять прогноз срока не с чем. На её месте
* стоит медианная ЭКСПОЗИЦИЯ активного объявления, названная своим именем:
* это «сколько висит», а не «за сколько продаётся», и знака ± у неё нет.
* Счётчик «из них сверено» тоже снят контура сверки прогноза с фактом
* сделки клиента не существует.
*
* Таблица размечена ARIA role="table/row/columnheader/cell" (а не <table>):
* на мобильном брейке (см. landing-v3.module.css) она превращается в карточки
@ -26,31 +32,74 @@
*/
import {
ACCURACY_PERIOD_LABEL,
ACCURACY_PLACEHOLDER,
PROOF_ROWS_PLACEHOLDER,
} from "../../marketing-v3";
BACKTEST,
BACKTEST_CORRIDOR_LABEL,
BACKTEST_PERIOD_LABEL,
} from "../../landing-facts";
import { formatStat, type LandingStats, type ShowcaseResponse } from "../../public-api";
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;
import { count, dealMeta, dealTitle, errPct, rub } from "./deal-view";
interface Tile {
readonly key: string;
readonly value: string;
readonly label: string;
readonly note: string;
}
export function AccuracyV3({
stats,
showcase,
}: {
stats: LandingStats;
showcase: ShowcaseResponse | null;
}) {
const estimates = formatStat(stats, "estimates_total", { digits: 0 });
const period = formatStat(stats, "estimates_period_days", { unit: "дн.", digits: 0 });
const age = formatStat(stats, "listing_age_median_days", {
unit: "дн.",
digits: 0,
sampleWord: "объявлениям",
});
const tiles: Tile[] = [
{
key: "priceError",
value: BACKTEST.priceError.text,
label: "медианное расхождение прогноза с ценой ДКП",
note: `по ${BACKTEST.priceError.sampleN} сделкам · ${BACKTEST_PERIOD_LABEL}`,
},
{
key: "coverage",
value: BACKTEST.coverage.text,
label: `сделок попали в коридор шириной ${BACKTEST_CORRIDOR_LABEL}`,
note: "попадание обеспечено шириной коридора, а не точностью точки",
},
{
key: "confidence",
value: BACKTEST.confidenceLow.text,
label: "расчётов с пометкой «уверенность низкая»",
note: "оценки «уверенность высокая» в выборке не встретилось ни разу",
},
];
if (age) {
tiles.push({
key: "listingAge",
value: age.text,
label: "медианная экспозиция активного объявления",
note: [age.note, age.sample].filter(Boolean).join(" · "),
});
}
const caveats = [
...BACKTEST.priceError.caveats,
...BACKTEST.coverage.caveats,
];
const deals = showcase?.deals ?? [];
const showcaseStats = showcase?.stats ?? null;
export function AccuracyV3() {
return (
<section
id="accuracy"
@ -63,91 +112,90 @@ export function AccuracyV3() {
ПРОГНОЗ ПРОТИВ ФАКТА
</div>
<h2 id="accuracy-v3-title" className={styles.accTitle}>
Мы сверяем каждый прогноз с реальной сделкой
Мы сверили прогноз с ценой сделки вот что вышло
</h2>
<p className={styles.accLead}>
{`Когда квартира продана, мы возвращаемся к своему прогнозу и считаем расхождение. Вот сводка за ${ACCURACY_PERIOD_LABEL}.`}
{`Сверка прогноза с ценой ДКП Росреестра: ${BACKTEST_PERIOD_LABEL}, ${BACKTEST.priceError.sampleN} сделок.`}
{estimates
? ` Расчёты в системе живут в другом окне: ${estimates.text}${
period ? ` за ${period.text}` : ""
}.`
: ""}
</p>
</div>
<ul className={styles.accKpiGrid} role="list">
{KPI_TILES.map((tile) => (
<li key={tile.label} className={styles.accKpiTile}>
{tiles.map((tile) => (
<li key={tile.key} 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>
{estimates && (
<li className={`${styles.accKpiTile} ${styles.accKpiTileDark}`}>
<div className={styles.accKpiValue}>{estimates.text}</div>
<div className={styles.accKpiLabel}>расчётов сделано</div>
<div className={styles.accKpiNoteDark}>
{[estimates.note, period ? `окно наблюдения — ${period.text}` : null]
.filter(Boolean)
.join(" · ")}
</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>
<ul className={styles.accFootnote} role="list">
{caveats.map((line) => (
<li key={line}>{line}</li>
))}
</div>
<p className={styles.accFootnote}>
Таблица иллюстрирует формат отчёта, это не список конкретных сделок.
«Выше средней цены» разница со средней ценой сделки по похожим
квартирам в районе.
</p>
</ul>
{deals.length > 0 && (
<>
<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>
{deals.map((deal, index) => (
<div key={`${deal.deal_quarter}-${index}`} className={styles.accRow} role="row">
<div role="cell">
<span className={styles.accObjectAddr}>{dealTitle(deal)}</span>
<span className={styles.accObjectMeta}>{dealMeta(deal)}</span>
</div>
<div role="cell" data-label="Квартал сделки">
{deal.deal_quarter}
</div>
<div role="cell" className={styles.accCellSaid} data-label="Мера сказала">
{rub(deal.predicted_rub)}
</div>
<div role="cell" className={styles.accCellFact} data-label="Цена ДКП">
{rub(deal.fact_rub)}
</div>
<div role="cell" className={styles.accCellErr} data-label="Расхождение">
{errPct(deal.err_pct)}
</div>
</div>
))}
</div>
<p className={styles.accFootnote}>
{showcaseStats
? `Показано ${count(showcaseStats.written)} строк из ${count(showcaseStats.eligible)} годных, рассмотрено сделок: ${count(showcaseStats.considered)}. Район известен у ${count(showcaseStats.with_district)} из показанных. ${showcaseStats.rejection_rule}`
: "Подпись прогона не пришла — из чего отобраны строки, сказать нечем."}
</p>
<p className={styles.accFootnote}>{deals[0].note}</p>
</>
)}
</div>
</section>
);

View file

@ -1,18 +1,74 @@
/**
* CostOfErrorV3 «Цена ошибки»: три плитки про стоимость неверной цены
* CostOfErrorV3 «Цена ошибки»: плитки про стоимость неверной цены
* (макет v3, ~строки 376-400, без id). Серверный компонент.
*
* Значения и подписи из `COST_OF_ERROR_PLACEHOLDER` (marketing-v3.ts),
* заголовок цитирует `SERVICE_PRICE_RUB` (content.ts) тот же источник
* правды, что и у цены отчёта во всём остальном лэндинге, а не отдельный
* литерал «150».
* ОТКУДА ЧИСЛА. Обе рыночные величины `/stats`, и у них РАЗНЫЕ знаменатели:
* доля снижавших считается по всем наблюдаемым объявлениям, а медиана
* просадки только по тем, кто снижал. Поэтому размер выборки печатается у
* каждой плитки отдельно, а в заголовке плитки прямо сказано «среди тех, кто
* снижал»: одна подпись на две величины была бы подменой знаменателя.
* Цена `SERVICE_PRICE_RUB` (content.ts), тот же источник, что у оферты.
*
* ОБЪЯВЛЕНИЯ, А НЕ ПРОДАВЦЫ. `price_cut_share_pct` доля ОБЪЯВЛЕНИЙ, и
* только по Домклику (единственный источник, где триггер пишет стартовую
* цену, см. шапку `landing_stats.py`). Один продавец ведёт несколько
* объявлений, поэтому «столько продавцов снижают цену» приписывало величине
* единицу, которой её не мерили: подпись приведена к измеренному, а источник
* назван прямо в ней, а не только в `note` под плиткой.
*
* ЧЕГО ЗДЕСЬ БОЛЬШЕ НЕТ. Плитки «×2,4 дольше продаётся квартира с завышенной
* ценой»: замер даёт ×1,04 (79 дней против 76), и это уже с цензурой в пользу
* заявления. Её место заняла доля снижающих цену измеренная величина про то
* же самое: рынок не соглашается с завышенной ценой.
*/
import { SERVICE_PRICE_RUB } from "../../content";
import { COST_OF_ERROR_PLACEHOLDER } from "../../marketing-v3";
import { formatStat, type LandingStats } from "../../public-api";
import styles from "../../landing-v3.module.css";
export function CostOfErrorV3() {
interface CostTile {
readonly key: string;
readonly value: string;
readonly title: string;
readonly text: string;
}
export function CostOfErrorV3({ stats }: { stats: LandingStats }) {
const drop = formatStat(stats, "price_cut_median_pct_per_month", {
unit: "%",
digits: 2,
sampleWord: "объявлениям",
});
const share = formatStat(stats, "price_cut_share_pct", {
unit: "%",
digits: 1,
sampleWord: "объявлениям",
});
const tiles: CostTile[] = [];
if (drop) {
tiles.push({
key: "drop",
value: drop.text,
title: "медианное изменение цены за месяц — среди тех, кто снижал",
text: [drop.note, drop.sample].filter(Boolean).join(" · "),
});
}
if (share) {
tiles.push({
key: "share",
value: share.text,
title: "столько объявлений на Домклике снижали цену",
text: [share.note, share.sample].filter(Boolean).join(" · "),
});
}
tiles.push({
key: "price",
value: `${SERVICE_PRICE_RUB}`,
title: "стоит узнать правильную цену заранее",
text: "Один платёж. Без подписки. Отчёт остаётся у вас по ссылке.",
});
return (
<section aria-labelledby="cost-v3-title" className={styles.costSection}>
<div className={styles.costContainer}>
@ -21,16 +77,16 @@ export function CostOfErrorV3() {
ПОЧЕМУ ЭТО ВАЖНО
</div>
<h2 id="cost-v3-title" className={styles.costTitle}>
{`${SERVICE_PRICE_RUB} ₽ против двух месяцев вашей жизни`}
{`${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;
{tiles.map((tile, index) => {
const dark = index === tiles.length - 1;
return (
<li
key={tile.title}
key={tile.key}
className={
dark
? `${styles.costTile} ${styles.costTileDark}`

View file

@ -2,11 +2,17 @@
* DealsTickerV3 бегущая строка «ПРОГНОЗ ФАКТ» (макет 29.08, секция
* «V3 лента сделок»): тёмная полоса сразу под шапкой.
*
* Данные ТЕ ЖЕ строки, что в таблице сверок (`PROOF_ROWS_PLACEHOLDER`):
* лента и таблица иллюстрируют один и тот же будущий контур «прогноз факт»,
* которого в продукте пока нет заводить второй набор выдуманных сделок
* значило бы удвоить сущности под одним и тем же гейтом (#2904). Список
* дублируется дважды подряд стандартный приём бесшовного CSS-marquee
* Данные ТЕ ЖЕ строки `/showcase`, что в таблице сверок ниже: лента и
* таблица показывают один набор сделок, а не два. Пустая витрина = полосы нет
* (решает родитель, `v3/page.tsx`) лента из выдуманных строк была бы ровно
* тем, против чего продукт и продаётся.
*
* ЧЕГО В СТРОКЕ БОЛЬШЕ НЕТ: колонки « +165 000 выше средней цены по
* району». Средней цены сделки по району в данных нет, считать эту величину
* не из чего. Адреса тоже нет: улица и дом известны у 2.7% сделок, поэтому
* объект описан тем, что есть комнаты, площадь, район, этаж.
*
* Список дублируется дважды подряд стандартный приём бесшовного CSS-marquee
* (анимация уводит ровно на 50%).
*
* `aria-hidden` на второй копии и `role="marquee"`-семантика намеренно НЕ
@ -15,11 +21,13 @@
* `prefers-reduced-motion` глушится глобальным правилом из css-модуля.
*/
import { PROOF_ROWS_PLACEHOLDER } from "../../marketing-v3";
import type { ShowcaseDeal } from "../../public-api";
import styles from "../../landing-v3.module.css";
export function DealsTickerV3() {
const items = [...PROOF_ROWS_PLACEHOLDER, ...PROOF_ROWS_PLACEHOLDER];
import { dealTitle, errPct, rub } from "./deal-view";
export function DealsTickerV3({ deals }: { deals: readonly ShowcaseDeal[] }) {
const items = [...deals, ...deals];
return (
<div className={styles.tickerRoot}>
<div className={styles.tickerBadge}>
@ -27,23 +35,20 @@ export function DealsTickerV3() {
<span className={styles.tickerBadgeText}>ПРОГНОЗ ФАКТ</span>
</div>
<div className={styles.tickerTrack} aria-hidden="true">
{items.map((r, i) => (
<div key={`${r.addr}-${i}`} className={styles.tickerItem}>
<span className={styles.tickerObj}>{r.addr}</span>
{items.map((deal, i) => (
<div key={`${deal.deal_quarter}-${i}`} className={styles.tickerItem}>
<span className={styles.tickerObj}>{dealTitle(deal)}</span>
<span className={styles.tickerPair}>
<span className={styles.tickerLabelMuted}>ПРОГНОЗ</span>
<span className={styles.tickerSaid}>{r.said}</span>
<span className={styles.tickerSaid}>{rub(deal.predicted_rub)}</span>
</span>
<span className={styles.tickerArrow}></span>
<span className={styles.tickerPair}>
<span className={styles.tickerLabelAccent}>ФАКТ</span>
<span className={styles.tickerFact}>{r.fact}</span>
</span>
<span className={styles.tickerErr}>{r.err}</span>
<span className={styles.tickerGain}>
<span className={styles.tickerGainCaret}></span>
<span>{r.gain}</span>
<span className={styles.tickerLabelAccent}>ДКП</span>
<span className={styles.tickerFact}>{rub(deal.fact_rub)}</span>
</span>
<span className={styles.tickerErr}>{errPct(deal.err_pct)}</span>
<span className={styles.tickerGain}>{deal.deal_quarter}</span>
</div>
))}
</div>

View file

@ -4,7 +4,7 @@
* FreeCheckCard карточка «Бесплатная проверка» на первом экране (макет v3,
* `МЕРА для себя v3.dc.html` строки ~129-170).
*
* ЧТО ОНА ДЕЛАЕТ СЕГОДНЯ. Проверяет пять полей и уводит на `/estimate`, где
* ЧТО ОНА ДЕЛАЕТ СЕГОДНЯ. Проверяет шесть полей и уводит на `/estimate`, где
* живёт сама проверка. Макетный сценарий «спиннер 1100&nbsp;мс готовый
* результат прямо в карточке» не воспроизводится и не будет: результату нужно
* место рядом с полями, а автокомплиту место под список; в карточке первого
@ -15,32 +15,72 @@
* Раздел «шаг 1 из 2» / полоска прогресса / ярлык рынка (`{{ cityMarket }}`,
* `{{ stepLabel }}`) из макета сюда сознательно НЕ перенесены: они требуют
* состояния города, которым владеет шапка сайта вне участка этой задачи
* («первый экран: герой и карточка»), и вносили бы числовые литералы (33%,
* 66%, 100%), которых нет ни в макете-константах, ни в `marketing-v3.ts`.
* («первый экран: герой и карточка»), и вносили бы числовые литералы (доли
* прогресса), которых нет ни в макете-константах, ни в замерах.
*
* Ветка результата ПОСТРОЕНА (`FreeResultV3.tsx`), но не подключена см.
* TODO у `PUBLIC_ESTIMATE_ENABLED` ниже и JSDoc в `FreeResultV3.tsx`.
*
* ГОРОД (порт из `../AddressForm.tsx`, v1). До этого карточка города не
* спрашивала вовсе то есть гео-гейт продукта на новом лэндинге был сломан:
* это ровно тот баг, который бэкенд чинил в #2576 (житель Нижнего Тагила
* вводил «Ленина, 1» и получал уверенную цену по одноимённой улице в ЕКБ).
* Правило из шапки `lib/city-registry.ts`: город считается известным, только
* если человек выбрал его сам ИЛИ `detectCityInText` нашёл его в тексте
* адреса; молча подставлять Екатеринбург нельзя. Ответ про город вне покрытия
* тот же и дословно тот же, что в v1. Выпадашка города в шапке
* (`CityPicker`) сюда НЕ пробрасывается: она презентационная и своим выбором
* не владеет (см. её JSDoc), а гейт обязан читать то, что человек указал
* рядом с адресом.
*
* СОСТОЯНИЕ ТЕПЕРЬ ДОЕЗЖАЕТ ДО ЧЕРНОВИКА. Поле было обязательным, а его
* значение никуда не клалось человек выбирал состояние квартиры, и мы его
* выбрасывали на переходе. Бесплатная проба покрытия состояние действительно
* не использует (см. `CoverageProbeInput` на бэке), но выбросить уже
* полученный ответ и спросить его второй раз на платном шаге хуже, чем
* донести его в `sessionStorage` до того шага.
*
* ПОДПИСЬ ПОД КНОПКОЙ. В ней стояло «30 СЕКУНД» замера времени заполнения
* формы нет и не было. Его место занял `STEP1_FIELDS_LABEL`: число полей
* проверяемый факт, и оно приезжает из `landing-facts.ts`, а не вписано сюда
* второй раз (иначе с бейджем шага 1 они разъехались бы молча).
*/
import { useId, useRef, useState } from "react";
import type { FormEvent } from "react";
import { PRIMARY_CITY, PUBLIC_ROUTES } from "../../content";
import { detectCityInText } from "@/lib/city-registry";
import {
COVERED_CITIES,
PRIMARY_CITY,
PUBLIC_ROUTES,
REGION_NAME,
SECONDARY_CITIES,
} from "../../content";
import { saveDraft } from "../../estimate-draft";
import { STEP1_FIELDS_LABEL } from "../../landing-facts";
import styles from "../../landing-v3.module.css";
/** Значение <option> «моего города нет в списке» (то же, что в v1-форме). */
const OTHER_CITY = "__other__";
/** Три состояния квартиры — дословно из макета (строка 734 исходника). */
const CONDITIONS = ["Требует ремонта", "Хорошее", "Отличное"] as const;
type Condition = (typeof CONDITIONS)[number];
type FieldName = "address" | "area" | "rooms" | "floor" | "condition";
type FieldName = "address" | "city" | "area" | "rooms" | "floor" | "condition";
type Feedback = { kind: "none" } | { kind: "error"; field: FieldName; text: string };
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 cityId = useId();
const areaId = useId();
const roomsId = useId();
const floorId = useId();
@ -48,6 +88,7 @@ export function FreeCheckCard() {
const feedbackId = useId();
const [address, setAddress] = useState("");
const [city, setCity] = useState("");
const [area, setArea] = useState("");
const [rooms, setRooms] = useState("");
const [floor, setFloor] = useState("");
@ -55,6 +96,7 @@ export function FreeCheckCard() {
const [feedback, setFeedback] = useState<Feedback>(NONE);
const addressRef = useRef<HTMLInputElement>(null);
const cityRef = useRef<HTMLSelectElement>(null);
const areaRef = useRef<HTMLInputElement>(null);
const roomsRef = useRef<HTMLInputElement>(null);
const floorRef = useRef<HTMLInputElement>(null);
@ -82,6 +124,33 @@ export function FreeCheckCard() {
return;
}
// Город известен, только если его выбрали руками или назвали в тексте
// адреса. Ничего не додумываем — см. шапку файла.
const detected = detectCityInText(trimmedAddress);
const resolvedCity =
city === OTHER_CITY ? OTHER_CITY : city || detected || "";
if (!resolvedCity) {
setFeedback({
kind: "error",
field: "city",
text: "Выберите город: одинаковые названия улиц есть в разных городах области, и без города цена будет не про вашу квартиру.",
});
cityRef.current?.focus();
return;
}
if (resolvedCity === OTHER_CITY) {
setFeedback({
kind: "info",
title: `Пока мы считаем только по одному региону — ${REGION_NAME}`,
lines: [
`Данные мы собираем сами, город за городом: полностью — ${PRIMARY_CITY}, частично — ${SECONDARY_CITIES.join(", ")}. По остальным адресам оценка была бы догадкой, поэтому мы её не показываем.`,
],
});
return;
}
if (!trimmedArea) {
setFeedback({
kind: "error",
@ -122,15 +191,24 @@ export function FreeCheckCard() {
return;
}
if (city === "" && detected) {
// Город распознали в тексте — синхронизируем селект, чтобы человек видел,
// что именно мы поняли, и мог поправить.
setCity(detected);
}
// Уходим на отдельный экран проверки — там живёт автокомплит и результат.
// Черновиком отдаём только то, что использует бесплатная проба покрытия:
// адрес, комнаты, площадь. Этаж и состояние она не использует вовсе (см.
// CoverageProbeInput на бэке) — их спросит платный шаг, когда появится,
// и спросит на том же экране, а не тащит через переход.
// Черновик кладём в sessionStorage, а НЕ в query-параметры: адрес в URL
// попал бы в access-логи Caddy рядом с IP посетителя (см. estimate-draft.ts).
// Этаж не передаём — его не использует ни проба покрытия, ни экран
// /estimate, и повторно набирать одно короткое поле дешевле, чем хранить
// лишнее. Состояние передаём: его спросили обязательным полем здесь.
saveDraft({
address: trimmedAddress,
rooms: trimmedRooms,
area: trimmedArea,
city: resolvedCity,
condition,
});
window.location.assign(PUBLIC_ROUTES.estimate);
}
@ -146,6 +224,33 @@ export function FreeCheckCard() {
<div className={styles.heroCardBody}>
<form className={styles.heroForm} onSubmit={handleSubmit} noValidate>
{/* Город ПЕРВЫМ полем: одинаковые названия улиц есть в разных
городах области, и вопрос «какой город» должен стоять до того,
как человек наберёт улицу, а не после (#2576). */}
<label className={styles.heroField} htmlFor={cityId}>
<span className={styles.heroFieldLabel}>Город</span>
<select
id={cityId}
ref={cityRef}
className={`${styles.heroSelect} ${errorField === "city" ? styles.heroInputInvalid : ""}`}
value={city}
onChange={(event) => {
setCity(event.target.value);
clearFeedback();
}}
aria-invalid={errorField === "city" || undefined}
aria-describedby={errorField === "city" ? describedBy : undefined}
>
<option value="">Выберите город</option>
{COVERED_CITIES.map((label) => (
<option key={label} value={label}>
{label}
</option>
))}
<option value={OTHER_CITY}>Другой город</option>
</select>
</label>
<label className={styles.heroField} htmlFor={addressId}>
<span className={styles.heroFieldLabel}>Адрес</span>
<input
@ -260,7 +365,7 @@ export function FreeCheckCard() {
Показать данные по моему дому
</button>
<p className={styles.heroSubmitCaption}>
БЕЗ РЕГИСТРАЦИИ · БЕЗ ЗВОНКОВ · 30 СЕКУНД
{`БЕЗ РЕГИСТРАЦИИ · БЕЗ ЗВОНКОВ · ${STEP1_FIELDS_LABEL.toUpperCase()}`}
</p>
{/* Живая область держим в DOM постоянно регион, добавленный в
@ -274,6 +379,16 @@ export function FreeCheckCard() {
<p className={styles.heroFormFeedbackText}>{feedback.text}</p>
</div>
)}
{feedback.kind === "info" && (
<div className={styles.heroFormFeedback}>
<p className={styles.heroFormFeedbackTitle}>{feedback.title}</p>
{feedback.lines.map((line) => (
<p key={line} className={styles.heroFormFeedbackText}>
{line}
</p>
))}
</div>
)}
</div>
</form>
</div>

View file

@ -12,20 +12,19 @@
* `"result"` и рендерить этот компонент с РЕАЛЬНЫМИ `analogs`/`days` из ответа
* ручки не константами.
*
* Данные приходят пропсами, а не читаются из `marketing-v3.ts` внутри
* компонента, вызывающий код сегодня мог бы по ошибке подставить туда
* `FREE_SAMPLE_PLACEHOLDER` для любого адреса, и тогда «14 похожих объявлений»
* увидел бы каждый посетитель независимо от того, что он ввёл. Это уже не
* продающая формулировка, а обман конкретного человека см. шапку
* `marketing-v3.ts`. Пропсовый контракт держит эту ответственность на
* вызывающей стороне и делает её явной в диффе, когда придёт время подключать
* реальные данные.
* Данные приходят ПРОПСАМИ и только пропсами. Соблазн подставить сюда общую
* рыночную медиану (её отдаёт `/stats`) выглядит безобидно, но «12 похожих
* объявлений» увидел бы каждый посетитель независимо от того, какой адрес он
* ввёл, а это уже не продающая формулировка, а обман конкретного человека.
* Пропсовый контракт держит ответственность на вызывающей стороне и делает её
* явной в диффе, когда придёт время подключать реальные данные.
*
* Замыленная рублёвая цифра из макета («9,75 млн », `filter: blur`) сюда
* НЕ перенесена вообще вместо неё абстрактная плашка-скелет без единой
* цифры (см. `.heroResultSkeleton*` в `landing-v3.module.css`).
*/
import { SERVICE_PRICE_RUB } from "../../content";
import styles from "../../landing-v3.module.css";
export interface FreeResultV3Props {
@ -33,9 +32,14 @@ export interface FreeResultV3Props {
analogs: string;
/** Подпись под числом объявлений. */
analogsLabel: string;
/** Средний срок продажи — крупная цифра второй плитки, напр. "47 дн.". */
/**
* Экспозиция похожих объявлений крупная цифра второй плитки, напр.
* "47 дн.". НЕ срок продажи: считается по тем, кто ещё висит, и снятие
* объявления не означает сделку (см. `median_listing_age_days` в
* public-api.ts).
*/
days: string;
/** Подпись под сроком продажи. */
/** Подпись под экспозицией. */
daysLabel: string;
/** Клик по «Проверить другую квартиру» — вернуть карточку к форме. */
onBack: () => void;
@ -73,14 +77,14 @@ export function FreeResultV3({
</div>
<div className={styles.heroResultOverlay}>
<p className={styles.heroResultOverlayTitle}>
Ваша цена и срок продажи готовы
Ваша цена готова
</p>
<button
type="button"
className={styles.heroResultOverlayBtn}
disabled
>
Открыть за 150&nbsp;
{`Открыть за ${SERVICE_PRICE_RUB}\u00a0₽`}
</button>
<p className={styles.heroResultOverlayCaption}>
Оплата подключается вместе с открытием сервиса пока недоступна.

View file

@ -4,32 +4,35 @@
* GuessGameV3 мини-игра «Сыграйте против МЕРЫ на реальных сделках»
* (макет 29.08, секция `#game` «V3 игра "Угадай цену"»).
*
* Клиентский компонент: три раунда «слайдер ответ сверка», итоговый экран
* со средней ошибкой игрока против ошибки МЕРЫ. Раунды реквизит из
* `GAME_ROUNDS_PLACEHOLDER` (см. шапку marketing-v3.ts: контура сверки
* «прогноз факт» в продукте нет, «реальные сделки» здесь макетные, и
* страница закрыта noindex до появления вычисляемых).
* Раунды РЕАЛЬНЫЕ сделки из `/showcase` (родитель готовит их через
* `toGameRound`): характеристики объекта, прогноз МЕРЫ и цена ДКП. Раньше
* здесь стояли три придуманных квартиры с придуманными адресами, а фраза «на
* реальных сделках» была неправдой. Сделок мало игру не рендерит родитель:
* играть на одной строке под заголовком «три квартиры» нельзя.
*
* Ошибка игрока считается медианой |ответ факт| / факт по трём раундам
* та же метрика, которой описана ошибка МЕРЫ (GAME_MERA_ERROR_LABEL).
* Финальная CTA ведёт на `/estimate` (решение StickyCtaV3 вместо макетной
* модалки).
* ЧЕГО В КАРТОЧКЕ БОЛЬШЕ НЕТ: точки «ОБЪЕКТ НА КАРТЕ». Координат в данных
* витрины нет вовсе (улица известна у 2.7% сделок), а точка на карте это
* утверждение о конкретном месте. Фон-сетка осталась декорацией, объект
* подписан тем, что известно: район (или прямое «район не указан»), комнаты,
* площадь, этаж, квартал сделки.
*
* Ошибка игрока считается медианой |ответ факт| / факт по сыгранным раундам
* той же формулой, что и ошибка МЕРЫ на этих же сделках (`err_pct` витрины).
* Обе величины меряют одно и то же на одной выборке, поэтому их и можно
* ставить рядом.
*/
import { useState } from "react";
import {
GAME_MERA_ERROR_LABEL,
GAME_ROUNDS_PLACEHOLDER,
} from "../../marketing-v3";
import styles from "../../landing-v3.module.css";
import type { GameRound } from "./deal-view";
import { PublicLink } from "../PublicLink";
type Phase = "playing" | "revealed" | "finished";
const fmtMln = (v: number) =>
`${v.toFixed(2).replace(".", ",")} млн ₽`;
const fmtMln = (v: number) => `${v.toFixed(2).replace(".", ",")} млн ₽`;
const fmtPct = (share: number) => `${(share * 100).toFixed(1).replace(".", ",")}%`;
function median(xs: number[]): number {
const s = [...xs].sort((a, b) => a - b);
@ -37,8 +40,7 @@ function median(xs: number[]): number {
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
}
export function GuessGameV3() {
const rounds = GAME_ROUNDS_PLACEHOLDER;
export function GuessGameV3({ rounds }: { rounds: readonly GameRound[] }) {
const [idx, setIdx] = useState(0);
const [phase, setPhase] = useState<Phase>("playing");
const [guess, setGuess] = useState(rounds[0].start);
@ -68,12 +70,12 @@ export function GuessGameV3() {
setPhase("playing");
};
const userErrPct = errors.length
? `${(median(errors) * 100).toFixed(1).replace(".", ",")}%`
: "—";
const roundErrPct = errors.length
? `${(errors[errors.length - 1] * 100).toFixed(1).replace(".", ",")}%`
: "—";
const userErrPct = errors.length ? fmtPct(median(errors)) : "—";
const roundErrPct = errors.length ? fmtPct(errors[errors.length - 1]) : "—";
// Ошибка МЕРЫ — по ТЕМ ЖЕ раундам, что сыграл человек, а не общая цифра
// бэктеста: сравнивать медиану по трём сделкам с медианой по тремстам
// значило бы сопоставлять две разные величины.
const meraErrPct = fmtPct(median(rounds.map((round) => Math.abs(round.mera - round.fact) / round.fact)));
return (
<section id="game" className={styles.gameSection} aria-labelledby="game-v3-title">
@ -85,8 +87,8 @@ export function GuessGameV3() {
Сыграйте против МЕРЫ на реальных сделках
</h2>
<p className={styles.gameLead}>
Три квартиры, которые уже проданы. Назовите свою цифру потом
увидите, что сказала МЕРА и чем всё закончилось.
Квартиры, которые уже проданы: данные Росреестра. Назовите свою
цифру потом увидите, что сказала МЕРА и чем всё закончилось.
</p>
</div>
@ -96,26 +98,14 @@ export function GuessGameV3() {
<div className={styles.gameMapGrid} aria-hidden="true" />
<div className={styles.gameMapRoadH} aria-hidden="true" />
<div className={styles.gameMapRoadV} aria-hidden="true" />
<div
className={styles.gameMapDot}
style={{ left: `${r.dotX}%`, top: `${r.dotY}%` }}
aria-hidden="true"
>
<span className={styles.gameMapPing} />
<span className={styles.gameMapCore} />
</div>
<div className={styles.gameMapLabel}>
ОБЪЕКТ НА КАРТЕ · {r.district}
{`ОБЪЕКТ · ${r.districtLabel.toUpperCase()}`}
</div>
<div className={styles.gameMapBottom}>
<div className={styles.gameAddr}>{r.addr}</div>
<div className={styles.gameAddr}>{r.title}</div>
<div className={styles.gameMeta}>{r.meta}</div>
<div className={styles.gameTags}>
{r.tags.map((t) => (
<span key={t} className={styles.gameTag}>
{t}
</span>
))}
<span className={styles.gameTag}>{`СДЕЛКА · ${r.quarter}`}</span>
</div>
</div>
</div>
@ -123,8 +113,10 @@ export function GuessGameV3() {
<div className={styles.gamePanel}>
{phase === "playing" && (
<div className={styles.gameFade}>
<div className={styles.gameQuestion}>{r.question}</div>
<div className={styles.gameHint}>{r.hint}</div>
<div className={styles.gameQuestion}>За сколько её реально купили?</div>
<div className={styles.gameHint}>
{`Сделка зарегистрирована в ${r.quarter}. Ваш ответ — в миллионах рублей.`}
</div>
<div className={styles.gameGuess}>{fmtMln(guess)}</div>
<input
type="range"
@ -159,17 +151,12 @@ export function GuessGameV3() {
<span className={styles.gameRowValueAccent}>{fmtMln(r.mera)}</span>
</div>
<div className={styles.gameRowDark}>
<span className={styles.gameRowLabelMuted}>Факт сделки</span>
<span className={styles.gameRowLabelMuted}>Цена ДКП</span>
<span className={styles.gameRowValueLight}>{fmtMln(r.fact)}</span>
</div>
</div>
<div className={styles.gameVerdict}>
Вы разошлись с фактом на {roundErrPct}
{" · "}МЕРА на{" "}
{`${(Math.abs(r.mera - r.fact) / r.fact * 100)
.toFixed(1)
.replace(".", ",")}%`}
.
{`Вы разошлись с ценой ДКП на ${roundErrPct} · МЕРА — на ${r.errPctText}.`}
</div>
<button type="button" onClick={next} className={styles.gameBtnDark}>
{isLast ? "Показать итог" : "Следующая квартира →"}
@ -179,18 +166,16 @@ export function GuessGameV3() {
{phase === "finished" && (
<div className={`${styles.gameFade} ${styles.gameFinal}`}>
<div className={styles.gameResultEyebrow}>ИТОГ ТРЁХ РАУНДОВ</div>
<div className={styles.gameResultEyebrow}>ИТОГ</div>
<div className={styles.gameFinalGrid}>
<div className={styles.gameFinalTileLight}>
<div className={styles.gameFinalValue}>{userErrPct}</div>
<div className={styles.gameFinalLabel}>ваша ошибка по цене</div>
</div>
<div className={styles.gameFinalTileSoft}>
<div className={styles.gameFinalValueAccent}>
{GAME_MERA_ERROR_LABEL}
</div>
<div className={styles.gameFinalValueAccent}>{meraErrPct}</div>
<div className={styles.gameFinalLabelAccent}>
ошибка МЕРЫ по цене
ошибка МЕРЫ на этих же квартирах
</div>
</div>
</div>

View file

@ -70,7 +70,7 @@ export function HeaderV3() {
Для бизнеса
</a>
<a href="#check" className={styles.hdrCta}>
Проверить за 30 секунд
Проверить квартиру
</a>
</div>
</div>

View file

@ -5,21 +5,85 @@
* Серверный компонент: весь текст статичен, единственное состояние живёт в
* `FreeCheckCard` (клиентский дочерний компонент).
*
* Числа в разметке только импортированные константы (`ACCURACY_PLACEHOLDER`,
* `FREE_SAMPLE_PLACEHOLDER.analogs`, цена отчёта из `COST_OF_ERROR_PLACEHOLDER`)
* ни одного нового числового литерала здесь не заведено, см. шапку
* `../../marketing-v3.ts`.
* ЧИСЛА. Бейдж сверка прогноза с ценой ДКП из `landing-facts.ts` (значение
* едет вместе с размером выборки: «14,5 %» без «по 327 сделкам» это уже
* рекламная строка, а не замер; полные оговорки стоят в блоке «Точность»,
* куда бейдж и отсылает). Пример по рынку под перками `/stats`, и он ПРЯМО
* назван примером: анонимного расчёта по адресу посетителя сегодня нет, а
* одинаковые «14 аналогов» каждому, кто ввёл свой адрес, были бы обманом
* конкретного человека. Цена `SERVICE_PRICE_RUB` (content.ts), один
* источник с офертой и политикой возврата.
*
* ДВЕ СТРОКИ ПРИМЕРА, А НЕ ОДНА. Екатеринбургом ограничена только экспозиция
* (`_LISTING_AGE_SQL` фильтрует `listings.city`); медиана аналогов считается
* по всей таблице `trade_in_estimates` без фильтра по городу. Стоя под общей
* шапкой «пример по рынку Екатеринбурга», она была бы подписана городом, к
* которому не привязана, поэтому у каждой величины своя подпись со своей
* границей. Рядом с аналогами печатается их `note` бэкенда («только расчёты,
* где аналоги нашлись»): это отбор выживших, расчёты без аналогов в
* знаменатель не попали, и без оговорки медиана читается выше, чем есть.
*
* СРОК. В тексте нет «узнаете срок продажи»: платный отчёт отдаёт
* `est_days_on_market` медиану `days_on_market` аналогов, то есть сколько
* похожие объявления УЖЕ висят (`deals.days_on_market` пуст, сверять прогноз
* срока не с чем). Обещание приведено к этой величине.
*
* ЧЕГО В ТЕКСТЕ БОЛЬШЕ НЕТ: обещания «каждый такой прогноз мы потом сверяем с
* фактом сделки». Контура сверки прогноза клиента с его сделкой в продукте не
* существует (`trade_in_leads` 4 строки без полей исхода), и обещать его
* нельзя. Сверка, которая есть, бэктест по чужим сделкам Росреестра, она и
* показана в бейдже.
*
* ПОРТ ИЗ v1 (`_components/Hero.tsx`), два блока внизу левой колонки:
* - плашка покрытия: полное `PRIMARY_CITY`, частично `SECONDARY_CITIES`,
* по другим регионам не считаем вовсе. Города берутся из реестра, не из
* строки (см. COVERED_CITIES в content.ts: список городов карта
* покрытия). В v3 этой границы не было нигде, кроме подписи в выпадашке
* города в шапке то есть человек узнавал про неё, только открыв список.
* - строка «мы не покупаем квартиры и не берём их на продажу» единственное
* место, где сказано, чем сервис НЕ является; в v3 её не было.
* Оба стоят В КОНЦЕ левой колонки намеренно: на узком экране колонка целиком
* идёт до карточки проверки, значит человек прочитает ограничение раньше, чем
* начнёт вводить адрес то самое требование, ради которого плашка в v1 стояла
* перед формой. Текст перенесён дословно.
*/
import { ACCURACY_PLACEHOLDER, COST_OF_ERROR_PLACEHOLDER, FREE_SAMPLE_PLACEHOLDER } from "../../marketing-v3";
import {
PRIMARY_CITY,
REGION_NAME,
SECONDARY_CITIES,
SERVICE_PRICE_RUB,
} from "../../content";
import { BACKTEST } from "../../landing-facts";
import { formatStat, type LandingStats } from "../../public-api";
import styles from "../../landing-v3.module.css";
import { FreeCheckCard } from "./FreeCheckCard";
/** Цена отчёта — та же величина, что в третьей плитке блока «Почему это важно». */
const REPORT_PRICE = COST_OF_ERROR_PLACEHOLDER[2].value;
export function HeroV3({ stats }: { stats: LandingStats }) {
const analogs = formatStat(stats, "analogs_median", { digits: 1, sampleWord: "расчётам" });
const age = formatStat(stats, "listing_age_median_days", {
unit: "дн.",
digits: 0,
sampleWord: "объявлениям",
});
const marketLines = [
age && {
key: "age",
label: "Пример по рынку Екатеринбурга, а не расчёт по вашему адресу:",
text: [`активное объявление висит ${age.text}`, age.sample && `медиана ${age.sample}`]
.filter(Boolean)
.join(" · "),
},
analogs && {
key: "analogs",
label: "По всем расчётам МЕРЫ, а не только по Екатеринбургу:",
text: [`медиана аналогов на расчёт — ${analogs.text}`, analogs.sample, analogs.note]
.filter(Boolean)
.join(" · "),
},
].filter((line): line is { key: string; label: string; text: string } => Boolean(line));
export function HeroV3() {
return (
<section
id="check"
@ -31,7 +95,7 @@ export function HeroV3() {
<div className={styles.heroBadge}>
<span className={styles.heroBadgeDot} aria-hidden="true" />
<span className={styles.heroBadgeText}>
СРЕДНЯЯ ОШИБКА ПРОГНОЗА {ACCURACY_PLACEHOLDER.priceError}
{`МЕДИАННОЕ РАСХОЖДЕНИЕ С ЦЕНОЙ ДКП — ${BACKTEST.priceError.text} ПО ${BACKTEST.priceError.sampleN} СДЕЛКАМ`}
</span>
</div>
@ -59,8 +123,8 @@ export function HeroV3() {
</span>
<span className={styles.heroListText}>
<strong>Не зависнуть:</strong> узнаете, за сколько дней уйдёт
квартира по вашей цене
<strong>Не зависнуть:</strong> увидите, сколько времени уже
висят похожие квартиры рядом
</span>
</div>
<div className={styles.heroListItem}>
@ -68,8 +132,8 @@ export function HeroV3() {
</span>
<span className={styles.heroListText}>
<strong>Не уступать зря:</strong> будет чем ответить на торг {" "}
{FREE_SAMPLE_PLACEHOLDER.analogs} аналогов с ценами
<strong>Не уступать зря:</strong> будет чем ответить на торг
список аналогов с ценами, на которых построен расчёт
</span>
</div>
</div>
@ -81,7 +145,7 @@ export function HeroV3() {
</span>
<span className={styles.heroListText}>
<strong>Бесплатно:</strong> сколько похожих квартир продаётся
рядом и как быстро они уходят
рядом и как давно они висят
</span>
</div>
<div className={styles.heroListItem}>
@ -91,13 +155,41 @@ export function HeroV3() {
<span className={styles.heroListText}>
<strong>
За{" "}
<span className={styles.heroNoBreak}>{REPORT_PRICE}</span>:
<span className={styles.heroNoBreak}>{`${SERVICE_PRICE_RUB}`}</span>:
</strong>{" "}
ваша цена и срок продажи и каждый такой прогноз мы потом
сверяем с фактом сделки
ваша цена, экспозиция похожих объявлений и аналоги, на которых
построен расчёт
</span>
</div>
{marketLines.map((line) => (
<div key={line.key} className={styles.heroListItem}>
<span className={styles.heroListIcon} aria-hidden="true">
</span>
<span className={styles.heroListText}>
<strong>{line.label}</strong> {line.text}
</span>
</div>
))}
</div>
{/* Покрытие подано неравномерно намеренно см. COVERED_CITIES в
content.ts: список городов в реестре карта покрытия. */}
<div className={styles.heroRegion}>
<p className={styles.heroRegionTitle}>{REGION_NAME}</p>
<p className={styles.heroRegionText}>
Полное покрытие {PRIMARY_CITY}. По остальным городам области (
{SECONDARY_CITIES.join(", ")}) данных меньше, и оценка там может
быть ориентировочной. По другим регионам не считаем вовсе не
хотим гадать.
</p>
</div>
<p className={styles.heroNote}>
«Мера» сервис оценки вторичного жилья по рыночным данным. Мы не
покупаем квартиры и не берём их на продажу: наша работа показать
цифру и то, откуда она взялась.
</p>
</div>
<FreeCheckCard />

View file

@ -11,8 +11,43 @@
* сделки отвечаем тем, что в продукте ЕСТЬ (сделки Росреестра + снятие
* объявлений с публикации), а не макетной формулировкой про «отметки
* продавцов», механики которых не существует.
*
* ВЕСЬ `FAQ` ИЗ content.ts ИМПОРТИРУЕТСЯ, А НЕ ПЕРЕПИСЫВАЕТСЯ. Так было не
* сразу: при переносе брались только `how-do-you-know` и `why-region`, а
* остальные четыре вопроса v1 (`how-accurate`, `vs-marketplace`,
* `personal-data`, `bank-report`) не рендерились НИГДЕ старый компонент
* `Faq.tsx`, который выводил все шесть, удалён вместе с лэндингом v1.
* Тематически их закрывали макетные возражения, но это были ПЕРЕСКАЗЫ, а
* оригиналы проходили юр-ревью. Две потери были содержательными:
* - `personal-data` единственное место на сайте, где сказано «Имя, паспорт
* и документы на квартиру мы не спрашиваем», и единственная отсылка к
* странице про обработку персональных данных (152-ФЗ). Макетная версия
* оставляла только «отдельную галочку согласия», без ссылки;
* - `how-accurate` «показываем диапазон, а не одно число» как прямой ответ
* на вопрос о точности. После потери рамка диапазона осталась только в
* `AccuracyV3`, рядом с плейсхолдерными числами.
*
* ЧТО СДЕЛАНО. Список ниже один порядок чтения, в который вплетены и все
* шесть вопросов `FAQ` (по id, а не копией строк), и те макетные возражения,
* которым в `FAQ` пары нет вовсе: «откуда вы знаете факт сделки», «почему так
* дёшево», «это подписка». Три остальных макетных возражения про
* калькуляторы, звонки и отчёт для банка УДАЛЕНЫ как пересказы
* `vs-marketplace` / `personal-data` / `bank-report`: держать рядом два ответа
* на один вопрос хуже, чем один, а выбор между пересказом и выверенным
* оригиналом очевиден. По той же причине у «почему так дёшево» убран второй
* абзац он был почти дословным повтором второго абзаца `how-accurate`,
* который теперь стоит следующим вопросом и отвечает на «это точно работает?»
* целиком.
*
* Ни одной строки не написано здесь заново: тексты либо приходят из
* `content.ts`, либо это уже стоявшие в этом файле макетные возражения.
*
* Если id переименуют или вопрос выпадет из списка, он молча исчезнет со
* страницы гейтится `__tests__/v3-ported-copy.test.ts` двусторонне: тест
* перебирает `FAQ` и требует, чтобы КАЖДЫЙ его id был отрисован здесь.
*/
import { FAQ } from "../../content";
import styles from "../../landing-v3.module.css";
import { PublicLink } from "../PublicLink";
@ -22,7 +57,20 @@ interface Objection {
readonly a: readonly string[];
}
const OBJECTIONS: readonly Objection[] = [
/** Вопрос из `FAQ` по id — `[]`, если id переименовали (тест это ловит). */
function fromFaq(id: string): readonly Objection[] {
const item = FAQ.find((f) => f.id === id);
return item ? [{ q: item.q, a: item.a }] : [];
}
/**
* Порядок чтения: как получается цена откуда факт сколько стоит и почему
* столько насколько точно чем отличается от бесплатного как списывают
* деньги что с данными годится ли для банка почему одна область.
* Сомнения про деньги стоят посередине, границы продукта по краям.
*/
const ITEMS: readonly Objection[] = [
...fromFaq("how-do-you-know"),
{
q: "Откуда вы знаете факт сделки?",
a: [
@ -33,36 +81,50 @@ const OBJECTIONS: readonly Objection[] = [
q: "Почему так дёшево — это точно работает?",
a: [
"Дёшево, потому что расчёт автоматический: без выезда, без оценщика, без комиссии. Вы платите за данные, а не за часы специалиста.",
"Точность прямо зависит от того, сколько нашлось сопоставимых объектов, — поэтому мы всегда пишем, на скольких объектах и сделках построен расчёт, и честно сообщаем, если данных по дому мало.",
],
},
{
q: "Чем это лучше бесплатных калькуляторов?",
a: [
"Калькулятор площадки считает по объявлениям этой же площадки — то есть по ценам, которые продавцы просят, а не получают.",
"Мы смотрим сразу несколько площадок и добавляем зарегистрированные сделки, чтобы была видна разница между запрашиваемой и реальной ценой.",
],
},
...fromFaq("how-accurate"),
...fromFaq("vs-marketplace"),
{
q: "Это подписка? Спишут ли ещё раз?",
a: [
"Нет. Это разовый платёж за один отчёт — без подписки, автопродлений и скрытых списаний. Отчёт остаётся у вас по ссылке.",
],
},
{
q: "Мне начнут звонить после проверки?",
a: [
"Нет. Для расчёта нужны адрес и параметры квартиры — телефон мы не спрашиваем. Он появляется, только если вы сами решите оставить заявку, и всегда с отдельной галочкой согласия.",
],
},
{
q: "Это официальная оценка для банка?",
a: [
"Нет. Для ипотеки, суда, опеки и нотариуса нужен отчёт аккредитованного оценщика по 135-ФЗ — это отдельная процедура с выездом. МЕРА отвечает на другой вопрос: за сколько квартира реально продаётся на рынке сегодня.",
],
},
...fromFaq("personal-data"),
...fromFaq("bank-report"),
...fromFaq("why-region"),
];
/**
* Отсылка к странице про обработку персональных данных внутри ответа
* `personal-data`. В `content.ts` она лежит обычным текстом (там нет разметки),
* и в старом `Faq.tsx` так текстом и рендерилась то есть 152-ФЗ-документ
* назывался, но открыть его отсюда было нельзя.
*
* Фраза вынесена константой, а не зашита в разметку: это КОПИЯ куска текста из
* `content.ts`, и разъехаться она может молча переформулируют предложение, и
* `indexOf` не найдёт ничего, а ссылка тихо станет обычным текстом. Ровно это
* и проверяет `v3-ported-copy.test.ts`: фраза обязана дословно встречаться в
* ответе `personal-data`.
*/
const PRIVACY_PHRASE = "странице про обработку персональных данных";
/** Абзац ответа: фраза выше отдаётся ссылкой, остальное — как есть. */
function renderAnswer(text: string) {
const at = text.indexOf(PRIVACY_PHRASE);
if (at < 0) return text;
return (
<>
{text.slice(0, at)}
<PublicLink to="privacy" className={styles.objAnswerLink}>
{PRIVACY_PHRASE}
</PublicLink>
{text.slice(at + PRIVACY_PHRASE.length)}
</>
);
}
export function ObjectionsV3() {
return (
<section className={styles.objSection} aria-labelledby="obj-v3-title">
@ -73,11 +135,11 @@ export function ObjectionsV3() {
Что обычно спрашивают перед оплатой
</h2>
<PublicLink to="estimate" className={styles.objCtaBtn}>
Проверить за 30 секунд
Проверить квартиру
</PublicLink>
</div>
<div className={styles.objList}>
{OBJECTIONS.map((o) => (
{ITEMS.map((o) => (
<details key={o.q} className={styles.objItem}>
<summary className={styles.objSummary}>
<span className={styles.objQuestion}>{o.q}</span>
@ -85,7 +147,7 @@ export function ObjectionsV3() {
</summary>
<div className={styles.objAnswer}>
{o.a.map((p) => (
<p key={p.slice(0, 24)}>{p}</p>
<p key={p.slice(0, 24)}>{renderAnswer(p)}</p>
))}
</div>
</details>

View file

@ -0,0 +1,66 @@
/**
* SourcesV3 реестр источников данных в дизайне v3. Серверный компонент.
*
* ОТКУДА ВЗЯТО. Это порт `_components/DataSources.tsx` (лэндинг v1): в v3
* реестра источников не было вовсе единственное упоминание жило одной фразой
* внутри ответа ObjectionsV3. Заголовок, лид и содержимое групп перенесены
* ДОСЛОВНО: текст проходил юр-ревью, а группы (`SOURCE_GROUPS` в content.ts)
* по-прежнему выводятся из `lib/source-registry.ts` названия площадок здесь
* не вбиты строками и не могут разъехаться с тем, что реально участвует в
* расчёте (контракт честности #2211).
*
* ГДЕ СТОИТ И ПОЧЕМУ. Сразу после `AccuracyV3`. «Точность» утверждает, что мы
* сверяем прогноз с фактом сделки; первый вопрос к такому утверждению «на
* каких данных». Держать ответ через две секции (в возражениях) значит дать
* человеку усомниться раньше, чем он дочитает до ответа. Секция без своей
* подложки чередование фонов v3 (accSection surface эта page-bg
* wygSection surface) сохраняется.
*
* Логотипов площадок нет по той же причине, что и в v1: чужие товарные знаки
* это отдельный юридический вопрос, а картинки пришлось бы тянуть с чужих
* доменов. Текстовые чипы решают ту же задачу.
*/
import { SOURCE_GROUPS } from "../../content";
import styles from "../../landing-v3.module.css";
export function SourcesV3() {
return (
<section className={styles.srcSection} aria-labelledby="sources-v3-title">
<div className={styles.srcHead}>
<div className={styles.accEyebrow} aria-hidden="true">
ОТКУДА ЦИФРЫ
</div>
<h2 id="sources-v3-title" className={styles.srcTitle}>
Откуда мы берём данные
</h2>
{/* Без числа групп в тексте: группы выводятся из реестра источников,
и «два типа данных» уже однажды разъехалось с кодом оценочные
модели площадок участвуют в расчёте (estimator.py, IMV/Yandex
blend), но в тексте их не было. */}
<p className={styles.srcLead}>
Мы не опрашиваем экспертов и не берём цифры из головы. Данные разного
происхождения отвечают на разные вопросы поэтому мы держим их
раздельно и показываем, что откуда.
</p>
</div>
<ul className={styles.srcGrid} role="list">
{SOURCE_GROUPS.map((group) => (
<li key={group.title} className={styles.srcCard}>
<h3 className={styles.srcCardTitle}>{group.title}</h3>
<ul className={styles.srcChips} role="list">
{group.items.map((item) => (
<li key={item} className={styles.srcChip}>
<span className={styles.srcChipDot} aria-hidden="true" />
{item}
</li>
))}
</ul>
<p className={styles.srcNote}>{group.note}</p>
</li>
))}
</ul>
</section>
);
}

View file

@ -14,7 +14,7 @@
*/
import { SERVICE_PRICE_RUB } from "../../content";
import { STEP1_DURATION_LABEL } from "../../marketing-v3";
import { STEP1_FIELDS_LABEL } from "../../landing-facts";
import styles from "../../landing-v3.module.css";
interface StepV3 {
@ -30,24 +30,27 @@ const STEPS_V3: readonly StepV3[] = [
},
{
title: "Мы собираем рынок вокруг",
text: "Похожие квартиры рядом: сколько их продаётся сейчас и за сколько дней они уходят.",
text: "Похожие квартиры рядом: сколько их продаётся сейчас и как давно они висят.",
},
{
title: "Вы получаете цену и срок",
text: "Конкретная цифра для объявления, прогноз срока продажи и все аналоги, на которых построен расчёт.",
title: "Вы получаете цену и картину рынка",
text: "Конкретная цифра для объявления, сколько уже висят похожие объявления и все аналоги, на которых построен расчёт.",
accent: true,
},
] as const;
/**
* Бейджи «ШАГ N · » по одному на шаг. Единственные числа во второй половине
* бейджей берутся из marketing-v3.ts (`STEP1_DURATION_LABEL`) и content.ts
* (`SERVICE_PRICE_RUB`, тот же источник, что и у цены отчёта в остальном
* лэндинге) в массиве STEPS_V3 выше их нет намеренно, чтобы не разъезжались
* два места с одним и тем же числом.
* Бейджи «ШАГ N · » по одному на шаг. Числа во второй половине бейджей
* `STEP1_FIELDS_LABEL` (landing-facts.ts) и `SERVICE_PRICE_RUB` (content.ts,
* тот же источник, что и у цены отчёта в остальном лэндинге); в массиве
* STEPS_V3 выше их нет намеренно, чтобы не разъезжались два места с одним и
* тем же числом.
*
* В первом бейдже стояло «30 СЕКУНД» замера времени заполнения формы нет.
* Число полей, наоборот, видно в `FreeCheckCard.tsx` и проверяется глазами.
*/
const STEP_BADGES: readonly string[] = [
STEP1_DURATION_LABEL.toUpperCase(),
STEP1_FIELDS_LABEL.toUpperCase(),
"БЕСПЛАТНО",
`${SERVICE_PRICE_RUB}`,
];

View file

@ -14,6 +14,14 @@
* компоненте так `StickyCtaV3` остаётся самодостаточным независимо от
* того, куда именно в дерево страницы его вставят (ожидаемое место сразу
* после подвала, как Header/Footer в текущем `layout.tsx`).
*
* НА КНОПКЕ НЕТ СРОКА. Здесь стояло «Проверить за 30 секунд» замера времени
* заполнения формы нет и не было (та же причина, по которой «30 СЕКУНД» ушло
* из бейджа первого шага, см. `landing-facts.ts`). Панель висит на экране
* ВСЕГДА, поэтому именно с неё обещание, которого никто не мерил, читалось
* чаще всего. В тексте панели по той же причине нет и «срока продажи»:
* платный отчёт отдаёт медиану экспозиции похожих объявлений, а не прогноз
* срока (`_estimate_days_on_market`, см. HeroV3).
*/
import styles from "../../landing-v3.module.css";
@ -26,10 +34,10 @@ export function StickyCtaV3() {
<div className={styles.barSpacer} aria-hidden="true" />
<div className={styles.barRoot}>
<p className={styles.barText}>
Узнайте цену и срок продажи своей квартиры
Узнайте цену своей квартиры и что происходит с похожими рядом
</p>
<PublicLink to="estimate" className={styles.barBtn}>
Проверить за 30 секунд
Проверить квартиру
</PublicLink>
</div>
</>

View file

@ -9,16 +9,23 @@
* партнёрской заявки и договора «продажи под ключ» в продукте НЕТ до их
* появления честный путь заявки один, живой канал. Текст пути 2 из макета
* сохранён дословно: он аккуратно не обещает ничего сверх «узнайте условия».
*
* Цена `SERVICE_PRICE_RUB` (content.ts): один источник с офертой и политикой
* возврата. Раньше она приезжала сюда из плейсхолдерной плитки «цена ошибки»,
* и разъехаться с офертой могла молча.
*
* СОСТАВ ОТЧЁТА НАЗВАН ПО ФАКТУ. В перечне пути 1 стоял «срок»: платный отчёт
* действительно отдаёт `est_days_on_market`, но это медиана `days_on_market`
* аналогов сколько похожие объявления УЖЕ висят, а не срок до сделки
* (`deals.days_on_market` заполнен 0 раз из 108 623, сверять прогноз срока не
* с чем). Слово заменено на измеренную величину.
*/
import { SUPPORT_TELEGRAM_URL } from "../../content";
import { COST_OF_ERROR_PLACEHOLDER } from "../../marketing-v3";
import { SERVICE_PRICE_RUB, SUPPORT_TELEGRAM_URL } from "../../content";
import styles from "../../landing-v3.module.css";
import { PublicLink } from "../PublicLink";
const REPORT_PRICE = COST_OF_ERROR_PLACEHOLDER[2].value;
export function TwoPathsV3() {
return (
<section
@ -35,9 +42,10 @@ export function TwoPathsV3() {
<div className={styles.pathsGrid}>
<div className={styles.pathCard}>
<div className={styles.pathEyebrow}>ПУТЬ 1 · САМОСТОЯТЕЛЬНО</div>
<div className={styles.pathTitle}>Отчёт за {REPORT_PRICE}</div>
<div className={styles.pathTitle}>{`Отчёт за ${SERVICE_PRICE_RUB}`}</div>
<p className={styles.pathBody}>
Цена, срок, аналоги, факторы. Дальше вы ведёте продажу сами на
Цена, экспозиция похожих объявлений, аналоги, факторы. Дальше вы
ведёте продажу сами на
руках все цифры, чтобы уверенно держать свою цену в переговорах.
</p>
<PublicLink to="estimate" className={styles.pathBtnLight}>

View file

@ -0,0 +1,60 @@
/**
* WhatYouGetV3 «что вы получите» + дисклеймер 135-ФЗ в дизайне v3.
* Серверный компонент.
*
* ОТКУДА ВЗЯТО. Порт `_components/WhatYouGet.tsx` (лэндинг v1). Заголовок, лид,
* пункты (`DELIVERABLES`) и дисклеймер (`DELIVERABLES_DISCLAIMER`) перенесены
* дословно из content.ts там же разобрано, почему в списке нет PDF-отчёта.
*
* ЗАЧЕМ ЭТО ОБЯЗАТЕЛЬНО. В v3 не было ни списка того, что человек получает, ни
* дисклеймера про 135-ФЗ. Второе не украшение: «Мера» даёт рыночную оценку по
* сопоставимым объектам, а не отчёт аккредитованного оценщика, и сказать об
* этом нужно ровно там, где человек читает, что именно он купит. В v3 ближайшее
* по смыслу было в ответе «Это официальная оценка для банка?» то есть только
* тому, кто сам догадался спросить.
*
* ГДЕ СТОИТ И ПОЧЕМУ. После `SourcesV3` и до «Цены ошибки»: «сверяем с фактом»
* «вот на каких данных» «вот что вы получаете на руки» доказательная часть
* целиком, и всё это ДО первого разговора про деньги. Подложка `--b2c-surface`
* с рамками чередование фонов v3 (Sources page-bg эта surface игра
* page-bg).
*/
import { DELIVERABLES, DELIVERABLES_DISCLAIMER } from "../../content";
import styles from "../../landing-v3.module.css";
export function WhatYouGetV3() {
return (
<section className={styles.wygSection} aria-labelledby="get-v3-title">
<div className={styles.wygContainer}>
<div className={styles.wygHead}>
<div className={styles.accEyebrow} aria-hidden="true">
ЧТО НА РУКАХ
</div>
<h2 id="get-v3-title" className={styles.wygTitle}>
Что вы получите
</h2>
<p className={styles.wygLead}>
Не «примерную стоимость», а расчёт, который видно насквозь.
</p>
</div>
<ul className={styles.wygGrid} role="list">
{DELIVERABLES.map((item) => (
<li key={item.title} className={styles.wygItem}>
<span className={styles.wygIcon} aria-hidden="true">
</span>
<div>
<h3 className={styles.wygItemTitle}>{item.title}</h3>
<p className={styles.wygItemText}>{item.text}</p>
</div>
</li>
))}
</ul>
<p className={styles.wygDisclaimer}>{DELIVERABLES_DISCLAIMER}</p>
</div>
</section>
);
}

View file

@ -0,0 +1,98 @@
/**
* Форматирование строк витрины `/showcase` общее для ленты, таблицы сверок и
* игры. Держится в одном месте, потому что «объект» в этих данных описывается
* ТОЛЬКО так: комнаты, площадь, этаж, район (может отсутствовать). Улицы и
* дома в данных нет вовсе номер дома известен у 2.7% сделок, и три
* компонента, каждый со своей склейкой, рано или поздно разъехались бы в том,
* что именно они выдают за адрес.
*/
import type { ShowcaseDeal } from "../../public-api";
const RUB = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 });
const PCT = new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 1,
signDisplay: "exceptZero",
});
const AREA = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 1 });
export const rub = (value: number): string => `${RUB.format(value)}`;
/** Счётчик прогона витрины — тем же разделителем тысяч, что и суммы. */
export const count = (value: number): string => RUB.format(value);
/** Отклонение прогноза от факта — со знаком: плюс = МЕРА назвала дороже. */
export const errPct = (value: number): string => `${PCT.format(value)} %`;
/** «2-к, 52 м²» — всё, что про объект известно наверняка. */
export const dealTitle = (deal: ShowcaseDeal): string =>
`${deal.rooms}-к, ${AREA.format(deal.area_m2)} м²`;
/**
* Район / этаж / число аналогов через точку. Отсутствующие части ВЫПАДАЮТ, а
* не заменяются прочерком или правдоподобной подстановкой: `district` и
* `floor` в ДКП-данных бывают пустыми.
*/
export function dealMeta(deal: ShowcaseDeal): string {
const parts: string[] = [];
if (deal.district) parts.push(deal.district);
if (deal.floor !== null) {
parts.push(deal.total_floors !== null ? `${deal.floor}/${deal.total_floors} этаж` : `${deal.floor} этаж`);
}
parts.push(`аналогов: ${deal.n_analogs}`);
return parts.join(" · ");
}
/** Раунд игры «Угадай цену» — та же сделка витрины, подготовленная к слайдеру. */
export interface GameRound {
readonly key: string;
/** «2-к, 52 м²». */
readonly title: string;
readonly meta: string;
/** Район или прямое «район не указан» — подстановки нет. */
readonly districtLabel: string;
readonly quarter: string;
/** Границы слайдера, млн ₽. */
readonly min: number;
readonly max: number;
readonly step: number;
readonly start: number;
/** Прогноз МЕРЫ и факт ДКП, млн ₽. */
readonly mera: number;
readonly fact: number;
/** Отклонение прогноза от факта ИМЕННО этой сделки. */
readonly errPctText: string;
}
const MLN = 1_000_000;
const STEP_MLN = 0.05;
/** Полуширина коридора слайдера вокруг сделки — чтобы ответ не лежал на краю. */
const SPAN = 0.35;
const toHalf = (v: number, up: boolean): number => (up ? Math.ceil(v * 2) / 2 : Math.floor(v * 2) / 2);
/**
* Границы слайдера считаются от самой сделки другого масштаба у раунда нет.
* Старт кладётся в СЕРЕДИНУ коридора, а не в прогноз МЕРЫ: иначе первое же
* положение ручки выдавало бы ответ, который игра предлагает угадать.
*/
export function toGameRound(deal: ShowcaseDeal, index: number): GameRound {
const mera = deal.predicted_rub / MLN;
const fact = deal.fact_rub / MLN;
const min = Math.max(toHalf(Math.min(mera, fact) * (1 - SPAN), false), STEP_MLN);
const max = toHalf(Math.max(mera, fact) * (1 + SPAN), true);
return {
key: `${deal.deal_quarter}-${index}`,
title: dealTitle(deal),
meta: dealMeta(deal),
districtLabel: deal.district ?? "район не указан",
quarter: deal.deal_quarter,
min,
max,
step: STEP_MLN,
start: Math.round((min + max) / 2 / STEP_MLN) * STEP_MLN,
mera,
fact,
errPctText: errPct(deal.err_pct),
};
}

View file

@ -20,9 +20,11 @@
*
* Поэтому §1 и §4 стоят на СВОЁМ замере (см. поля `source` там основание и
* его границы), кадастровое число снято, а поправки §3 честно помечены как
* рыночная практика, а не наш замер. Плейсхолдеров marketing-v3 в статье
* больше нет но строка `articles/articles-content.ts` в `V3_SOURCES` гейта
* #2904 оставлена намеренно: вернётся плейсхолдер гейт его увидит.
* рыночная практика, а не наш замер. Модуля `marketing-v3.ts` с числами из
* макета больше не существует вовсе (29.08.2026): величины лэндинга уехали в
* `/stats`, `/showcase` и `landing-facts.ts`. Гейт `landing-numbers-gate` этот
* файл намеренно НЕ сканирует здесь числа живут в связном тексте, и у
* каждого блока стоит своё поле `source`/`footnote` с датой и выборкой.
*
* ЗАМЕР, НА КОТОРОМ СТОИТ ТЕКСТ. 6202 объявления квартир Домклик в
* Екатеринбурге, срез 18.07.2026, парно «цена публикации цена на дату
@ -147,8 +149,8 @@ export const HOWTO = {
},
cta: {
eyebrow: "ЕСЛИ СЧИТАТЬ ВРУЧНУЮ НЕКОГДА",
title: "МЕРА делает всё это за 30 секунд",
body: `Мы находим аналоги, считаем медиану, применяем поправки и проверяем цену сроком продажи. Бесплатно покажем рынок вокруг, за ${SERVICE_PRICE_RUB} ₽ — вашу цену и прогноз.`,
title: "МЕРА делает всё это за вас",
body: `Мы находим аналоги, считаем медиану, применяем поправки и смотрим, сколько похожие объявления уже висят. Бесплатно покажем рынок вокруг, за ${SERVICE_PRICE_RUB} ₽ — вашу цену и аналоги, на которых она построена.`,
},
s5: {
id: "s5",

View file

@ -124,7 +124,7 @@ export default function HowToArticlePage() {
<div className={styles.artCtaTitle}>{HOWTO.cta.title}</div>
<p className={styles.artCtaBody}>{HOWTO.cta.body}</p>
<PublicLink to="estimate" className={styles.gameBtnAccent}>
Проверить за 30 секунд
Проверить квартиру
</PublicLink>
</div>

View file

@ -88,7 +88,7 @@ export default function ArticlesPage() {
</p>
</div>
<PublicLink to="estimate" className={styles.gameBtnAccent}>
Проверить за 30 секунд
Проверить квартиру
</PublicLink>
</div>
</main>

View file

@ -11,8 +11,11 @@
* Referer соседнего сайта. Живёт до закрытия вкладки ровно столько, сколько
* нужно для перехода между двумя страницами.
*
* Не заводить здесь ничего сверх этих трёх полей: чем больше сюда положить,
* тем сильнее соблазн начать хранить черновики «на подольше».
* Не заводить здесь ничего сверх того, что человек УЖЕ ввёл на лэндинге: чем
* больше сюда положить, тем сильнее соблазн начать хранить черновики «на
* подольше». `condition` появился по этому же правилу, а не вопреки ему
* карточка v3 спрашивает состояние обязательным полем, и до появления этого
* поля ответ просто выбрасывался на переходе.
*/
const KEY = "mera:estimate-draft";
@ -23,6 +26,12 @@ export interface EstimateDraft {
rooms?: string;
area?: string;
city?: string;
/**
* Состояние квартиры, как его назвал человек в карточке v3. Бесплатная проба
* покрытия его не использует (`CoverageProbeInput` на бэке о нём не знает)
* он ждёт платного шага; хранится ровно один переход, как и всё остальное.
*/
condition?: string;
}
/**
@ -58,6 +67,8 @@ export function takeDraft(): EstimateDraft | null {
rooms: typeof value.rooms === "string" ? value.rooms : undefined,
area: typeof value.area === "string" ? value.area : undefined,
city: typeof value.city === "string" ? value.city : undefined,
condition:
typeof value.condition === "string" ? value.condition : undefined,
};
} catch {
return null;

View file

@ -0,0 +1,107 @@
/**
* landing-facts величины лэндинга, которые НЕ приходят из `/stats`, но
* измерены. Пришло на смену `marketing-v3.ts` (числа из дизайн-макета).
*
* ЧЕМ ЭТОТ ФАЙЛ ОТЛИЧАЕТСЯ ОТ ПРЕДЫДУЩЕГО. В `marketing-v3.ts` лежали
* продающие формулировки, и шапка честно признавала, что ни одна из них не
* подтверждена замером. Здесь наоборот: каждая запись результат конкретного
* запроса к боевой базе, у неё есть размер выборки и оговорки, без которых
* число врёт. Записи без `source`/`sampleN` завести нельзя тип не даст, а
* гейт `__tests__/landing-numbers-gate.test.ts` проверяет, что поля не пустые.
*
* ПОЧЕМУ ЭТО НЕ В `/stats`. Всё, что считается ночной задачей
* (`app/tasks/landing_stats.py`), фронт берёт через `fetchLandingStats()`
* так число не может отстать от базы. Сверка прогноза с ценой ДКП такой
* задачи пока не имеет: это разовый замер владельца на проде, и до появления
* регулярного пересчёта честнее держать его здесь, вместе с датой замера,
* чем показывать «свежим» на витрине.
*
* ЧЕГО ЗДЕСЬ НЕТ. Точности прогноза по СРОКУ продажи: `deals.days_on_market`
* заполнен 0 раз из 108 623, сверять прогноз срока не с чем. На витрине её
* место занимает `listing_age_median_days` из `/stats` экспозиция активного
* объявления, и она названа своим именем, без слова «точность».
* Счётчика «сверено с фактом сделки клиента» тоже нет: контура такой сверки в
* продукте не существует (`trade_in_leads` 4 строки без полей исхода).
*/
/**
* Замеренная величина витрины.
*
* `caveats` не украшение: у обоих чисел бэктеста есть границы, без которых
* они читаются лучше, чем есть. Рендерятся рядом со значением (AccuracyV3),
* а не в комментарии.
*/
export interface MeasuredValue {
/** Значение с единицей, готовое к выводу. */
readonly text: string;
/** Размер выборки, по которой значение посчитано. */
readonly sampleN: number;
/** Откуда взято: запрос, база, дата замера. */
readonly source: string;
/** Оговорки, без которых величина вводит в заблуждение. */
readonly caveats: readonly string[];
}
/** Общий источник трёх величин ниже — один и тот же прогон сверки. */
const BACKTEST_SOURCE =
"Ручная сверка на проде (poincare, 29.08.2026): прогноз МЕРЫ против цены ДКП " +
"Росреестра по Екатеринбургу, сделки с 06.2025";
/**
* Сверка «прогноз цена ДКП». Три величины идут КОМПЛЕКТОМ и показываются
* вместе: попадание в коридор без ширины коридора и без уровня уверенности
* читается как точность, которой нет.
*/
export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLow", MeasuredValue>> = {
priceError: {
text: "14,5 %",
sampleN: 327,
source: BACKTEST_SOURCE,
caveats: [
"Прогноз считается по объявлениям, активным НА МОМЕНТ РАСЧЁТА, а сделка — прошлая: " +
"это не point-in-time замер. Честный будет не раньше конца 2026 года по календарю данных.",
"Цена ДКП бывает занижена ради налога — она нижняя граница правды, а не сама правда.",
],
},
coverage: {
text: "88 %",
sampleN: 327,
source: BACKTEST_SOURCE,
caveats: [
"Попадание достигнуто шириной коридора ±37 %, а не точностью точки: " +
"коридор такой ширины накрывает почти любую сделку.",
],
},
confidenceLow: {
text: "325 из 327",
sampleN: 327,
source: BACKTEST_SOURCE,
caveats: ["Оценки «уверенность высокая» в выборке не встретилось ни разу."],
},
};
/** Ширина коридора — та же сверка; стоит в подписи к `coverage`, не отдельной плиткой. */
export const BACKTEST_CORRIDOR_LABEL = "±37 %";
/** Окно сделок бэктеста. Окно РАСЧЁТОВ — другое, оно приходит из `/stats`. */
export const BACKTEST_PERIOD_LABEL = "сделки с июня 2025 года";
/**
* Бейдж первого шага И подпись под кнопкой формы (`FreeCheckCard.tsx`).
* Раньше в обоих местах стояло «30 секунд» замера времени заполнения формы
* нет и не было. Число полей формы, наоборот, проверяется глазами там же:
* город, адрес, площадь, комнаты, этаж, состояние.
*
* ШЕСТЬ, А НЕ ПЯТЬ. Пока эта подпись делалась, соседняя ветка вернула в форму
* ВЫБОР ГОРОДА (#2576: без него «Ленина, 1» давало жителю Нижнего Тагила цену
* по одноимённой улице в ЕКБ). Число полей не константа продукта, а счёт
* того, что стоит в разметке рядом; после слияния двух веток «5 полей» было бы
* ровно тем враньём на витрине, ради снятия которого подпись и заводилась.
* Меняется форма пересчитать здесь; проверяется по `heroFieldLabel` в
* `FreeCheckCard.tsx`.
*
* Оба места берут строку ОТСЮДА, а не пишут её у себя: «30 секунд» пережило
* снятие из бейджа ровно потому, что было вписано в четыре компонента
* независимо, и правка одного не касалась остальных.
*/
export const STEP1_FIELDS_LABEL = "6 полей";

View file

@ -2127,6 +2127,20 @@
margin-bottom: 0;
}
/* Ссылка ВНУТРИ предложения (отсылка к странице про обработку персональных
данных в ответе `personal-data`) то же inline-исключение из правила зоны
нажатия, что у .ftrInlineLink: раздувать строку текста было бы хуже. */
.objAnswerLink {
color: var(--b2c-accent-text);
font-weight: 600;
text-decoration: underline;
text-underline-offset: 3px;
}
.objAnswerLink:hover {
color: var(--b2c-ink);
}
/* --- Статьи --------------------------------------------------------------- */
.artSection {
max-width: 1280px;
@ -2832,3 +2846,236 @@
margin-top: 18px;
}
}
/* ==========================================================================
* Порт v1-блоков в дизайн v3 (волна «ничего не потерять при переключении
* корня»): SourcesV3 (префикс src), WhatYouGetV3 (префикс wyg) и добавки к
* герою/карточке проверки (префикс hero дописаны сюда, а не в блок героя
* выше, по правилу из шапки файла: «дописывай в конец, не переписывай чужие
* блоки»).
* ========================================================================== */
/* -- Герой: плашка покрытия и строка «мы не покупаем квартиры» ------------- */
/* Акцентная полоса слева, а не заливка: плашка должна читаться как оговорка
рядом с обещанием, а не как ещё один CTA. Тот же приём, что у
.stepsItemAccent (граница акцентом на светлой подложке) сплошной заливкой
accent красить нельзя, текст на ней не проходит AA (см. b2c-tokens.ts). */
.heroRegion {
margin-top: 24px;
padding: 14px 16px;
background: var(--b2c-surface);
border: 1px solid var(--b2c-line);
border-left: 3px solid var(--b2c-accent);
border-radius: 8px;
}
.heroRegionTitle {
margin: 0 0 6px;
font-family: var(--b2c-font-mono);
font-size: 10.5px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--b2c-accent-text);
}
.heroRegionText {
margin: 0;
font-size: 13.5px;
line-height: 1.6;
color: var(--b2c-muted);
text-wrap: pretty;
}
.heroNote {
margin: 16px 0 0;
font-size: 13px;
line-height: 1.6;
color: var(--b2c-muted);
text-wrap: pretty;
}
/* Селект города в карточке проверки. Наследует поле ввода целиком и НЕ снимает
appearance: без нативной стрелки селект неотличим от input, а рисовать свою
пришлось бы фоновой картинкой ради одного поля не стоит. */
.heroSelect {
composes: heroInput;
cursor: pointer;
}
/* -- SourcesV3 — «Откуда мы берём данные» ---------------------------------- */
.srcSection {
max-width: 1280px;
margin: 0 auto;
padding: clamp(48px, 6vw, 80px) clamp(20px, 5vw, 56px);
}
.srcHead {
max-width: 700px;
margin-bottom: 32px;
}
.srcTitle {
margin: 0 0 14px;
font-size: clamp(26px, 3.4vw, 38px);
line-height: 1.12;
letter-spacing: -0.015em;
font-weight: 800;
}
.srcLead {
margin: 0;
font-size: 16.5px;
line-height: 1.6;
color: var(--b2c-muted);
text-wrap: pretty;
}
.srcGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
gap: 20px;
margin: 0;
padding: 0;
list-style: none;
}
.srcCard {
padding: 26px;
background: var(--b2c-surface);
border: 1px solid var(--b2c-line);
border-radius: 10px;
}
.srcCardTitle {
margin: 0 0 14px;
font-weight: 800;
font-size: 17.5px;
line-height: 1.3;
}
.srcChips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 14px;
padding: 0;
list-style: none;
}
.srcChip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 11px;
background: var(--b2c-page-bg);
border: 1px solid var(--b2c-line);
border-radius: 999px;
font-family: var(--b2c-font-mono);
font-size: 11.5px;
letter-spacing: 0.04em;
}
.srcChipDot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--b2c-accent);
}
.srcNote {
margin: 0;
font-size: 14px;
line-height: 1.6;
color: var(--b2c-muted);
}
/* -- WhatYouGetV3 — «Что вы получите» + дисклеймер 135-ФЗ ------------------ */
.wygSection {
background: var(--b2c-surface);
border-top: 1px solid var(--b2c-line);
border-bottom: 1px solid var(--b2c-line);
}
.wygContainer {
max-width: 1280px;
margin: 0 auto;
padding: clamp(48px, 6vw, 80px) clamp(20px, 5vw, 56px);
}
.wygHead {
max-width: 700px;
margin-bottom: 32px;
}
.wygTitle {
margin: 0 0 14px;
font-size: clamp(26px, 3.4vw, 38px);
line-height: 1.12;
letter-spacing: -0.015em;
font-weight: 800;
}
.wygLead {
margin: 0;
font-size: 16.5px;
line-height: 1.6;
color: var(--b2c-muted);
text-wrap: pretty;
}
.wygGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
gap: 20px;
margin: 0 0 24px;
padding: 0;
list-style: none;
}
.wygItem {
display: flex;
gap: 12px;
padding: 24px;
background: var(--b2c-page-bg);
border: 1px solid var(--b2c-line);
border-radius: 10px;
}
.wygIcon {
flex: 0 0 auto;
font-weight: 800;
line-height: 1.4;
color: var(--b2c-accent-text);
}
.wygItemTitle {
margin: 0 0 6px;
font-weight: 800;
font-size: 16.5px;
line-height: 1.3;
}
.wygItemText {
margin: 0;
font-size: 14px;
line-height: 1.6;
color: var(--b2c-muted);
}
/* Дисклеймер красится ink, а не muted: это ограничение услуги, а не сноска
приглушать его значило бы прятать. Полоса акцентом как у .heroRegion. */
.wygDisclaimer {
margin: 0;
padding: 16px 18px;
background: var(--b2c-page-bg);
border: 1px solid var(--b2c-line);
border-left: 3px solid var(--b2c-accent);
border-radius: 8px;
font-size: 14px;
line-height: 1.6;
color: var(--b2c-ink);
text-wrap: pretty;
}

View file

@ -7,13 +7,13 @@ import { b2cVars, landingVars } from "./theme";
/**
* ШАПКА И ПОДВАЛ ЖИВУТ В СТРАНИЦАХ, А НЕ ЗДЕСЬ (15.08.2026). Раньше layout
* рендерил SiteHeader/SiteFooter для всего поддерева. С появлением второй
* версии лэндинга (`v3/`, макет владельца) это стало блокером: вложенный
* layout в Next добавляется К родительскому, а не заменяет его, поэтому
* страница с собственной шапкой получала бы ДВЕ шапки сразу. Теперь layout
* отвечает только за обёртку шрифты, токены, skip-link, а каждая страница
* сама выбирает свой хром: v1 берёт SiteHeader/SiteFooter, v3 HeaderV3/
* FooterV3/StickyCtaV3.
* рендерил общий хром для всего поддерева. С появлением второй версии
* лэндинга (макет владельца) это стало блокером: вложенный layout в Next
* добавляется К родительскому, а не заменяет его, поэтому страница с
* собственной шапкой получала бы ДВЕ шапки сразу. Теперь layout отвечает
* только за обёртку шрифты, токены, skip-link, а каждая страница сама
* выбирает свой хром: лэндинг берёт HeaderV3/FooterV3/StickyCtaV3, внутренние
* страницы (проверка, статьи, документы, юр-документы) InnerHeader/FooterV3.
*/
/**

View file

@ -1,249 +0,0 @@
/**
* 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;
// ---------------------------------------------------------------------------
// Дополнение по макету 29.08 («B2C модуль для МЕРА», Макс) — секции «лента
// сделок», «игра "Угадай цену"», «возражения», «статьи». Тот же контракт, что
// у величин выше: ничего из этого НЕ подтверждено замером, контура сверки
// «прогноз → факт» в продукте не существует (см. шапку модуля и гейт-тест) —
// поэтому данные живут здесь, под noindex, до появления вычисляемых.
// ---------------------------------------------------------------------------
/** Раунд мини-игры «Угадай цену»: реквизит из макета, НЕ реальная сделка. */
export interface GameRound {
readonly district: string;
readonly addr: string;
readonly meta: string;
readonly tags: readonly string[];
readonly question: string;
readonly hint: string;
/** Слайдер: млн ₽. */
readonly min: number;
readonly max: number;
readonly step: number;
readonly start: number;
/** «МЕРА сказала» и «факт сделки», млн ₽ — числа из макета. */
readonly mera: number;
readonly fact: number;
/** Позиция точки на декоративной карте, в процентах. */
readonly dotX: number;
readonly dotY: number;
}
export const GAME_ROUNDS_PLACEHOLDER: readonly GameRound[] = [
{
district: "ЦЕНТРАЛЬНЫЙ Р-Н",
addr: "1-комнатная, 34 м²",
meta: "Центральный р-н · 4/9 этаж · панель",
tags: ["ХОРОШЕЕ СОСТОЯНИЕ", "9 АНАЛОГОВ", "БЛИЗКО К МЕТРО"],
question: "Какую цену поставить, чтобы продать за 30 дней?",
hint: "Продано в марте 2026. Ваш ответ — в миллионах рублей.",
min: 4.5,
max: 7.5,
step: 0.05,
start: 5.4,
mera: 5.55,
fact: 5.5,
dotX: 32,
dotY: 42,
},
{
district: "ЮГО-ЗАПАД",
addr: "2-комнатная, 52 м²",
meta: "Юго-Западный р-н · 7/16 этаж · монолит",
tags: ["КОСМЕТИКА", "14 АНАЛОГОВ", "ДВОР БЕЗ МАШИН"],
question: "За сколько её реально купили?",
hint: "Продано в мае 2026. Ваш ответ — в миллионах рублей.",
min: 5.5,
max: 9.5,
step: 0.05,
start: 7.2,
mera: 7.4,
fact: 7.55,
dotX: 58,
dotY: 61,
},
{
district: "УРАЛМАШ",
addr: "3-комнатная, 74 м²",
meta: "Орджоникидзевский р-н · 2/5 этаж · кирпич",
tags: ["ТРЕБУЕТ РЕМОНТА", "6 АНАЛОГОВ", "ТИХИЙ ДВОР"],
question: "Какая цена ушла бы за 45 дней?",
hint: "Продано в июне 2026. Ваш ответ — в миллионах рублей.",
min: 4.0,
max: 8.0,
step: 0.05,
start: 6.0,
mera: 5.85,
fact: 5.95,
dotX: 44,
dotY: 28,
},
];
/** Ошибка МЕРЫ в финале игры — та же величина, что в бейдже героя. */
export const GAME_MERA_ERROR_LABEL = "1,8%";

View file

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

View file

@ -1,37 +1,106 @@
import { AccuracyV3 } from "./_components/v3/AccuracyV3";
import { ArticlesV3 } from "./_components/v3/ArticlesV3";
import { CostOfErrorV3 } from "./_components/v3/CostOfErrorV3";
import { DealsTickerV3 } from "./_components/v3/DealsTickerV3";
import { FooterV3 } from "./_components/v3/FooterV3";
import { GuessGameV3 } from "./_components/v3/GuessGameV3";
import { HeaderV3 } from "./_components/v3/HeaderV3";
import { HeroV3 } from "./_components/v3/HeroV3";
import { ObjectionsV3 } from "./_components/v3/ObjectionsV3";
import { SourcesV3 } from "./_components/v3/SourcesV3";
import { StepsV3 } from "./_components/v3/StepsV3";
import { StickyCtaV3 } from "./_components/v3/StickyCtaV3";
import { TwoPathsV3 } from "./_components/v3/TwoPathsV3";
import { WhatYouGetV3 } from "./_components/v3/WhatYouGetV3";
import { toGameRound } from "./_components/v3/deal-view";
import { fetchLandingStats, fetchShowcase } from "./public-api";
import styles from "./landing.module.css";
/**
* Публичный лэндинг «МЕРА» первое публичное лицо продукта.
*
* Серверный компонент: никаких хуков, никакого клиентского JS, кроме одного
* острова `AddressForm` (её "use client" оправдан состоянием поля и ответом
* на сабмит). FAQ-аккордеон клиентским НЕ является: он на нативных <details>.
* V3 СТАЛ КОРНЕМ (29.08.2026). До этого дня здесь жила сборка v1 (Hero /
* HowItWorks / WhatYouGet / DataSources / Faq), а дизайн по макету владельца
* лежал рядом, на `/mera-public/v3`, как превью для сравнения. Вариант
* выбран превью переехало сюда целиком, каталог `v3/` и компоненты v1
* удалены, а `/v3` на публичном домене оставлен постоянным редиректом на
* корень (`caddy/sites/apps.caddy`), чтобы закладки владельца не отдавали 404.
*
* Порядок блоков продиктован тем, в каком порядке у собственника квартиры
* возникают вопросы: что это и работает ли у меня как это устроено что я
* получу можно ли этому верить а если у меня возражение как связаться.
* Порядок секций тот же, что был отлажен на превью: лента «прогноз факт»
* герой с формой как это устроено точность на каких данных что вы
* получите игра «Угадай цену» цена ошибки два пути возражения
* статьи. Доказательная часть (точность, источники, состав результата) идёт
* подряд и целиком ДО первого разговора про деньги.
*
* ОТКУДА ЧИСЛА (29.08.2026). Страница серверно-асинхронная: витринные величины
* приходят из `/api/public/mera/stats` и `/showcase`, а не из литералов
* плейсхолдерного `marketing-v3.ts` больше нет. Обе ручки НЕ бросают:
* недоступный бэкенд даёт пустой набор и `null`, и тогда блок, которому нечего
* показать, просто не рендерится ни нуля, ни прочерка, ни прошлого значения.
* Единственные числа, живущие в коде, разовая сверка прогноза с ценой ДКП
* (`landing-facts.ts`), у неё там записаны выборка, источник и оговорки.
*
* ЛЕНТА И ИГРА СТОЯТ НА ТЕХ ЖЕ СДЕЛКАХ, что и таблица сверок: это один набор
* `/showcase`, а не три. Игра не рендерится, пока сделок меньше `MIN_ROUNDS`
* «сыграйте три раунда» на одной строке было бы обещанием, которого данные не
* покрывают.
*
* СЕКЦИИ БЕЗ ДАННЫХ (`SourcesV3`, `WhatYouGetV3`) РЕНДЕРЯТСЯ ВСЕГДА: их
* содержимое реестр источников и состав результата из `content.ts`, ручек
* они не спрашивают. Гасить их вместе с витринными числами значило бы прятать
* дисклеймер 135-ФЗ ровно тогда, когда бэкенд лежит.
*
* МЕТАДАННЫЕ ЖИВУТ В `layout.tsx`, а не здесь. Заголовок и `robots: { index:
* false }` там уже ровно те же, что стояли в `v3/page.tsx`; второй экземпляр
* на уровне страницы ничего бы не менял, зато мог бы разъехаться с первым.
* NOINDEX НЕ СНИМАТЬ вместе с переездом витрины на замеренные числа: маршрут
* наружу открывается отдельным решением владельца (гейт
* `landing-numbers-gate.test.ts` держит флаг в layout), а не побочным
* эффектом того, что плейсхолдеры кончились.
*
* ЧЕГО ЗДЕСЬ ПО-ПРЕЖНЕМУ НЕТ:
* - модалка проверки сознательно заменена экраном /estimate (см.
* StickyCtaV3), все CTA макетного `openModal` ведут туда;
* - партнёрская заявка «продажи под ключ» кнопка ведёт в Telegram
* поддержки до появления формы и договора.
*/
import { DataSources } from "./_components/DataSources";
import { Faq } from "./_components/Faq";
import { Hero } from "./_components/Hero";
import { HowItWorks } from "./_components/HowItWorks";
import { WhatYouGet } from "./_components/WhatYouGet";
import styles from "./landing.module.css";
/**
* Окно перегенерации страницы. Нужно отдельно от кэша самих запросов
* (`revalidate: 3600` в public-api.ts): сборка идёт в докере, где бэкенда нет,
* поэтому в билд страница уезжает БЕЗ витринных блоков. С часовым окном она
* такой и стояла бы час после каждого деплоя. Минута это задержка появления
* чисел после выката; нагрузки на бэкенд она не добавляет, перегенерация
* переиспользует тот же часовой кэш ответов.
*/
export const revalidate = 60;
import { SiteFooter } from "./_components/SiteFooter";
import { SiteHeader } from "./_components/SiteHeader";
/** Раундов в игре. Меньше — секция не показывается вовсе. */
const MIN_ROUNDS = 3;
export default async function MeraPublicPage() {
const [stats, showcase] = await Promise.all([fetchLandingStats(), fetchShowcase()]);
const deals = showcase?.deals ?? [];
const rounds = deals.slice(0, MIN_ROUNDS).map(toGameRound);
export default function MeraPublicPage() {
return (
<>
<SiteHeader />
<HeaderV3 />
<main id="content" className={styles.main} tabIndex={-1}>
<Hero />
<HowItWorks />
<WhatYouGet />
<DataSources />
<Faq />
{deals.length > 0 && <DealsTickerV3 deals={deals} />}
<HeroV3 stats={stats} />
<StepsV3 />
<AccuracyV3 stats={stats} showcase={showcase} />
<SourcesV3 />
<WhatYouGetV3 />
{rounds.length >= MIN_ROUNDS && <GuessGameV3 rounds={rounds} />}
<CostOfErrorV3 stats={stats} />
<TwoPathsV3 />
<ObjectionsV3 />
<ArticlesV3 />
</main>
<SiteFooter />
<FooterV3 />
<StickyCtaV3 />
</>
);
}

View file

@ -15,8 +15,8 @@ import styles from "../landing.module.css";
import { PublicLink } from "../_components/PublicLink";
import { safeUrl } from "@/lib/safeUrl";
import { SiteFooter } from "../_components/SiteFooter";
import { SiteHeader } from "../_components/SiteHeader";
import { InnerHeader } from "../_components/InnerHeader";
import { FooterV3 } from "../_components/v3/FooterV3";
/**
* Политика обработки персональных данных по ст. 18.1 152-ФЗ.
@ -83,7 +83,7 @@ export default function MeraPublicPrivacyPage() {
return (
<>
<SiteHeader />
<InnerHeader />
<main id="content" className={styles.main} tabIndex={-1}>
<div className={`${styles.container} ${styles.doc}`}>
<PublicLink
@ -316,7 +316,7 @@ export default function MeraPublicPrivacyPage() {
) : null}
</div>
</main>
<SiteFooter />
<FooterV3 />
</>
);
}

View file

@ -148,3 +148,180 @@ export async function fetchCoverage(
signal: options.signal,
});
}
/* ------------------------------------------------------------------ *
* СЕРВЕРНАЯ ЧАСТЬ: витринные числа лэндинга.
*
* Всё выше браузерное (форма оценки). Всё ниже вызывается ТОЛЬКО из
* серверных компонентов, поэтому здесь нет "use client" и нет пользовательских
* AbortSignal'ов.
*
* ПОЧЕМУ АДРЕС ДРУГОЙ, ЧЕМ У ФОРМЫ. `fetch` на сервере не знает origin'а
* относительный `/trade-in/api/...` там просто не резолвится. В дереве это уже
* решено ровно одним способом: `BACKEND_URL` (next.config.ts rewrites,
* `tradein-mvp/docker-compose.prod.yml:471` комментарий там дословно
* «internal SSR»). Тот же env, тот же адрес контейнера: Next ходит в backend
* напрямую, минуя Caddy, поэтому префикса `/trade-in` здесь нет его
* добавляет Caddy для браузера, а не бэкенд (роутер примонтирован на
* `/api/public/mera`, backend/app/main.py:298).
*
* КЭШ `revalidate: 3600`. Числа пересчитывает ночная задача раз в сутки
* (app/tasks/landing_stats.py), поэтому `no-store` (запрос на каждый заход
* анонима) слал бы бэкенду одинаковые вопросы и упирался бы в его же лимит
* 30/окно на всех посетителей сразу, включая тех, кому нужна форма.
* Кэш без срока другая крайность: пересчёт прошёл, а витрина показывает
* вчерашнее до следующего деплоя. Час это и потолок отставания от ночного
* пересчёта, и, что важнее, потолок времени, на который залипает НЕУДАЧА:
* Data Cache Next'а статусы не разбирает, так что промах тоже живёт час, но
* не дольше.
* ------------------------------------------------------------------ */
const SSR_API_BASE = `${process.env.BACKEND_URL ?? "http://localhost:8000"}/api/public/mera`;
/** Одна витринная величина. Зеркалит `LandingStat` бэкенда. */
export interface LandingStat {
value: number | string | null;
sample_n: number | null;
note: string | null;
computed_at: string;
}
/**
* Ответ `/stats`. Отсутствие ключа это отсутствие ВЕЛИЧИНЫ: бэкенд не пишет
* строку, когда мерить нечего. Ноль на её месте был бы утверждением, которого
* никто не измерял, поэтому тип и допускает `undefined` читать значения
* напрямую нельзя, только через `formatStat`.
*/
export type LandingStats = Readonly<Record<string, LandingStat | undefined>>;
export interface ShowcaseDeal {
district: string | null;
rooms: number;
area_m2: number;
floor: number | null;
total_floors: number | null;
deal_quarter: string;
predicted_rub: number;
fact_rub: number;
err_pct: number;
n_analogs: number;
note: string;
}
/** Подпись под витриной: из чего отобраны показанные строки. */
export interface ShowcaseStats {
considered: number;
priced: number;
no_prediction: number;
incomplete: number;
eligible: number;
written: number;
with_district: number;
rejection_rule: string;
}
export interface ShowcaseResponse {
computed_at: string | null;
deals: ShowcaseDeal[];
stats: ShowcaseStats | null;
}
/**
* GET, который НЕ бросает. Любой сбой недоступный сервис, 500, не-JSON
* это `null`. Альтернатива (проброс исключения) уронила бы серверный рендер
* целиком: посетитель получил бы 500 вместо страницы без одного блока.
*/
async function fetchOrNull<T>(path: string): Promise<T | null> {
try {
const res = await fetch(`${SSR_API_BASE}${path}`, {
// Таймаут, а не бесконечное ожидание: висящий бэкенд иначе держит рендер
// страницы, и «блок не показался» превращается в «сайт не открывается».
signal: AbortSignal.timeout(5_000),
next: { revalidate: 3600 },
});
if (!res.ok) return null;
return (await res.json()) as T;
} catch {
return null;
}
}
/**
* Витринные метрики. Недоступная ручка = пустой набор, а не набор нулей:
* дальше каждый ключ одинаково честно отвечает «величины нет».
*/
export async function fetchLandingStats(): Promise<LandingStats> {
const data = await fetchOrNull<LandingStats>("/stats");
return data !== null && typeof data === "object" ? data : {};
}
/**
* Витрина сделок. `null` = показывать нечего (ручка молчит, пересчёт ещё не
* отработал, строк нет) секция не рендерится. Пустой массив отдельным
* состоянием не является: «витрина из нуля строк» на странице не отличима от
* поломки, а `stats` без строк подписывать нечего.
*/
export async function fetchShowcase(): Promise<ShowcaseResponse | null> {
const data = await fetchOrNull<ShowcaseResponse>("/showcase");
if (data === null || !Array.isArray(data.deals) || data.deals.length === 0) return null;
return data;
}
/**
* Величина, готовая к выводу. Число и размер выборки едут ОДНИМ объектом
* намеренно: другого способа получить отформатированное число нет, поэтому
* потерять n по дороге в JSX нельзя можно только осознанно не отрисовать
* поле, и это будет видно в разметке.
*/
export interface FormattedStat {
/** Значение с единицей, если её передали: «1 123», «48,1 %». */
text: string;
/** Подпись выборки — «по 1 123 оценкам». null, если бэкенд не прислал n. */
sample: string | null;
/** Что именно измерено — формулировкой бэкенда, не нашей. */
note: string | null;
computedAt: string;
}
const ru = (digits: number) => new Intl.NumberFormat("ru-RU", { maximumFractionDigits: digits });
/**
* Достаёт метрику по ключу. `null` во ВСЕХ случаях, когда числа нет: ключа
* не прислали, `value` пустой, ручка не ответила. Ни ноля, ни прочерка, ни
* последнего известного значения блок вызывающего просто не рендерится.
*
* @param unit единица, приклеиваемая к числу («%», «дн.») через неразрывный
* пробел, чтобы не отрывалась переносом строки.
* @param sampleWord слово подписи выборки («оценкам», «объявлениям»); без него
* подпись всё равно будет, но безымянной «n = 1 123».
*/
export function formatStat(
stats: LandingStats,
key: string,
{ unit, digits = 2, sampleWord }: { unit?: string; digits?: number; sampleWord?: string } = {},
): FormattedStat | null {
const stat = stats[key];
if (!stat || stat.value === null || stat.value === undefined) return null;
let text: string;
if (typeof stat.value === "number") {
if (!Number.isFinite(stat.value)) return null;
text = ru(digits).format(stat.value);
} else if (typeof stat.value === "string") {
if (stat.value.trim() === "") return null;
text = stat.value;
} else {
return null;
}
if (unit) text = `${text} ${unit}`;
const n = stat.sample_n;
const sample =
typeof n === "number" && Number.isFinite(n)
? sampleWord
? `по ${ru(0).format(n)} ${sampleWord}`
: `n = ${ru(0).format(n)}`
: null;
return { text, sample, note: stat.note ?? null, computedAt: stat.computed_at };
}

View file

@ -12,8 +12,8 @@ import styles from "../landing.module.css";
import { PublicLink } from "../_components/PublicLink";
import { SiteFooter } from "../_components/SiteFooter";
import { SiteHeader } from "../_components/SiteHeader";
import { InnerHeader } from "../_components/InnerHeader";
import { FooterV3 } from "../_components/v3/FooterV3";
/**
* Политика возврата денежных средств.
@ -50,7 +50,7 @@ export default function MeraPublicRefundPage() {
return (
<>
<SiteHeader />
<InnerHeader />
<main id="content" className={styles.main} tabIndex={-1}>
<div className={`${styles.container} ${styles.doc}`}>
<PublicLink
@ -215,7 +215,7 @@ export default function MeraPublicRefundPage() {
) : null}
</div>
</main>
<SiteFooter />
<FooterV3 />
</>
);
}

View file

@ -1,69 +0,0 @@
import type { Metadata } from "next";
import { AccuracyV3 } from "../_components/v3/AccuracyV3";
import { ArticlesV3 } from "../_components/v3/ArticlesV3";
import { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3";
import { DealsTickerV3 } from "../_components/v3/DealsTickerV3";
import { FooterV3 } from "../_components/v3/FooterV3";
import { HeaderV3 } from "../_components/v3/HeaderV3";
import { GuessGameV3 } from "../_components/v3/GuessGameV3";
import { HeroV3 } from "../_components/v3/HeroV3";
import { ObjectionsV3 } from "../_components/v3/ObjectionsV3";
import { StepsV3 } from "../_components/v3/StepsV3";
import { StickyCtaV3 } from "../_components/v3/StickyCtaV3";
import { TwoPathsV3 } from "../_components/v3/TwoPathsV3";
import styles from "../landing.module.css";
/**
* Лэндинг v3 сборка по дизайн-макету владельца (15.08.2026).
*
* ЭТО ПРЕВЬЮ, А НЕ ЗАМЕНА. Живой публичный лэндинг остаётся на
* `/mera-public` он открыт на meraocenka.ru и его нельзя менять
* посреди согласования дизайна. Новая версия лежит рядом, по
* `/mera-public/v3`, чтобы её можно было открыть и сравнить с текущей
* на одном и том же контенте. Переключение отдельным решением, когда
* версия принята.
*
* ОБНОВЛЕНИЕ 29.08 (макет «B2C модуль», Макс): добавлены лента
* «прогноз факт», игра «Угадай цену», «Два пути», «Возражения» и тизер
* статей. Данные ленты/игры реквизит из marketing-v3.ts под тем же гейтом
* #2904 (контура сверки прогноза с фактом в продукте по-прежнему нет).
*
* ЧЕГО ЗДЕСЬ ПО-ПРЕЖНЕМУ НЕТ:
* - страницы статей («МЕРА статьи»/«МЕРА статья») карточки тизера
* рендерятся без ссылок до появления раздела;
* - модалка проверки сознательно заменена экраном /estimate (см.
* StickyCtaV3), все CTA макетного `openModal` ведут туда;
* - партнёрская заявка «продажи под ключ» кнопка ведёт в Telegram
* поддержки до появления формы и договора.
*
* ЧИСЛА в секции «Точность» и «Цена ошибки» плейсхолдеры из макета,
* см. `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}>
<DealsTickerV3 />
<HeroV3 />
<StepsV3 />
<AccuracyV3 />
<GuessGameV3 />
<CostOfErrorV3 />
<TwoPathsV3 />
<ObjectionsV3 />
<ArticlesV3 />
</main>
<FooterV3 />
<StickyCtaV3 />
</>
);
}

View file

@ -81,6 +81,18 @@ const PUBLIC_PATHS = ["/mera-public"];
*
* Совпадение теперь запинено тестом (`__tests__/RouteGuard.publicPaths.test.ts`
* читает Caddyfile), потому что комментарий этот инцидент уже не предотвратил.
*
* `/v3` УБРАН 29.08.2026 сознательно, а не заодно с уборкой. Превью стало
* корнем, страницы по этому адресу больше нет: на meraocenka.ru `/v3` теперь
* 301 на `/` (матчер `@meraV3Gone`), и редирект отрабатывает в Caddy до
* приложения такой запрос не доходит вовсе, значит и гварду решать нечего. На
* gendsgn.ru короткие адреса не раздаются, там `/v3` и раньше был 404. То
* есть строка стала недостижимой, а недостижимая строка в списке публичных
* путей не «безобидный остаток»: она сообщает следующему читателю, что по
* `/v3` есть публичная страница, и переживёт появление НОВОЙ страницы с тем
* же адресом, автоматически сделав её публичной без отдельного решения.
* Гейт ниже односторонний (Caddy гвард), поэтому сам он лишнюю строку не
* ловит убирать приходится руками.
*/
const PUBLIC_SHORT_PATHS = [
"/",
@ -88,7 +100,6 @@ const PUBLIC_SHORT_PATHS = [
"/oferta",
"/refund",
"/privacy",
"/v3",
"/articles",
"/articles/kak-ocenit-kvartiru",
"/docs",

View file

@ -42,7 +42,7 @@ describe("isPublicPath", () => {
it("пропускает поддерево лэндинга (путь без basePath)", () => {
expect(isPublicPath("/mera-public")).toBe(true);
expect(isPublicPath("/mera-public/privacy")).toBe(true);
expect(isPublicPath("/mera-public/v3")).toBe(true);
expect(isPublicPath("/mera-public/estimate")).toBe(true);
});
it("пропускает короткие адреса публичного домена", () => {

View file

@ -728,6 +728,8 @@ export default function ParamsPanel({
const [fieldErrors, setFieldErrors] = useState<{
address?: string;
area?: string;
floor?: string;
totalFloors?: string;
}>({});
// ── Address autocomplete (geocode suggest, ЕКБ viewbox) ──
@ -756,6 +758,17 @@ export default function ParamsPanel({
initialValues?.target_fias_id ?? null,
);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Debounce-таймер адреса переживал размонтирование панели: набранный текст
// ставит таймер на 200 мс, и если панель закрыть раньше, он дёргает setState
// уже мёртвого компонента. В тестах это падает как unhandled
// `ReferenceError: window is not defined` после teardown окружения (#3226).
useEffect(
() => () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
},
[],
);
// БАЛКОН radiogroup focus targets (roving tabindex).
const balNoRef = useRef<HTMLButtonElement>(null);
const balYesRef = useRef<HTMLButtonElement>(null);
@ -928,7 +941,12 @@ export default function ParamsPanel({
const areaNum = Number(area.replace(",", "."));
// Collect ALL blockers (don't early-return) so every offending field shows
// its own message at once.
const errs: { address?: string; area?: string } = {};
const errs: {
address?: string;
area?: string;
floor?: string;
totalFloors?: string;
} = {};
if (trimmedAddress.length < 3) {
errs.address = "Укажите адрес квартиры — минимум 3 символа";
}
@ -937,6 +955,29 @@ export default function ParamsPanel({
} else if (!Number.isFinite(areaNum) || areaNum <= 10) {
errs.area = "Площадь должна быть больше 10 м²";
}
// Floor / total-floors are both optional, but if filled must be
// mutually consistent (prod incident 6d0c268e: floor=17, total_floors=9
// was accepted silently — form + backend both let it through).
const floorNum = floor.trim() ? Number(floor) : null;
const totalFloorsNum = totalFloors.trim() ? Number(totalFloors) : null;
if (floorNum === 0) {
errs.floor = "Этаж не может быть нулевым";
} else if (floorNum != null && floorNum > 100) {
errs.floor = "Проверьте число этажей";
}
if (totalFloorsNum === 0) {
errs.totalFloors = "Этажей в доме не может быть 0";
} else if (totalFloorsNum != null && totalFloorsNum > 100) {
errs.totalFloors = "Проверьте число этажей";
}
if (
floorNum != null &&
totalFloorsNum != null &&
floorNum > totalFloorsNum &&
!errs.floor
) {
errs.floor = "Этаж не может быть больше числа этажей в доме";
}
if (Object.keys(errs).length > 0) {
setFieldErrors(errs);
return;
@ -1801,13 +1842,36 @@ export default function ParamsPanel({
type="text"
inputMode="numeric"
value={floor}
onChange={(e) => setFloor(e.target.value.replace(/\D/g, ""))}
aria-invalid={fieldErrors.floor ? true : undefined}
aria-describedby={fieldErrors.floor ? "pp-floor-err" : undefined}
onChange={(e) => {
setFloor(e.target.value.replace(/\D/g, ""));
if (fieldErrors.floor || fieldErrors.totalFloors)
setFieldErrors((prev) => ({
...prev,
floor: undefined,
totalFloors: undefined,
}));
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit();
}}
placeholder="—"
style={inputField}
style={
fieldErrors.floor
? { ...inputField, border: `1px solid ${tokens.danger}` }
: inputField
}
/>
{fieldErrors.floor && (
<div
id="pp-floor-err"
style={{ ...errorText, marginTop: 4 }}
role="alert"
>
{fieldErrors.floor}
</div>
)}
</div>
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
@ -1822,15 +1886,38 @@ export default function ParamsPanel({
type="text"
inputMode="numeric"
value={totalFloors}
onChange={(e) =>
setTotalFloors(e.target.value.replace(/\D/g, ""))
aria-invalid={fieldErrors.totalFloors ? true : undefined}
aria-describedby={
fieldErrors.totalFloors ? "pp-total-floors-err" : undefined
}
onChange={(e) => {
setTotalFloors(e.target.value.replace(/\D/g, ""));
if (fieldErrors.floor || fieldErrors.totalFloors)
setFieldErrors((prev) => ({
...prev,
floor: undefined,
totalFloors: undefined,
}));
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit();
}}
placeholder="—"
style={inputField}
style={
fieldErrors.totalFloors
? { ...inputField, border: `1px solid ${tokens.danger}` }
: inputField
}
/>
{fieldErrors.totalFloors && (
<div
id="pp-total-floors-err"
style={{ ...errorText, marginTop: 4 }}
role="alert"
>
{fieldErrors.totalFloors}
</div>
)}
</div>
</div>

View file

@ -0,0 +1,92 @@
// Клиентская валидация этажности — прод-инцидент 6d0c268e: оценка была принята
// с floor=17 при total_floors=9 (пр-кт Академика Сахарова 81, 2023 г.п.), форма
// пропустила взаимно противоречивый ввод, бэкенд тоже не отбил. Здесь закреплена
// блокирующая проверка во фронте: сабмит не уходит в onSubmit и показывает
// текст ошибки под полем «ЭТАЖ», пока floor > total_floors.
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import ParamsPanel from "../ParamsPanel";
function renderPanel(onSubmit: (input: unknown) => void) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<ParamsPanel onSubmit={onSubmit} />
</QueryClientProvider>,
);
}
// Валидные адрес/площадь заполняются в каждом кейсе, чтобы изолировать проверку
// именно этажности — иначе сабмит блокировался бы этими полями первым.
function fillRequiredFields(container: HTMLElement) {
const address = container.querySelector("#pp-address") as HTMLInputElement;
const area = container.querySelector("#pp-area") as HTMLInputElement;
fireEvent.change(address, {
target: { value: "Екатеринбург, пр-кт Академика Сахарова 81" },
});
fireEvent.change(area, { target: { value: "54" } });
}
describe("ParamsPanel — валидация этаж / всего этажей", () => {
it("floor=17, total_floors=9 — блокирует сабмит и показывает ошибку на поле ЭТАЖ", () => {
const onSubmit = vi.fn();
const { container } = renderPanel(onSubmit);
fillRequiredFields(container);
fireEvent.change(container.querySelector("#pp-floor") as HTMLInputElement, {
target: { value: "17" },
});
fireEvent.change(
container.querySelector("#pp-total-floors") as HTMLInputElement,
{ target: { value: "9" } },
);
fireEvent.click(screen.getByText("ОЦЕНИТЬ КВАРТИРУ"));
expect(onSubmit).not.toHaveBeenCalled();
expect(
screen.getByText("Этаж не может быть больше числа этажей в доме"),
).toBeInTheDocument();
});
it("floor=9, total_floors=17 — проходит валидацию и вызывает onSubmit", () => {
const onSubmit = vi.fn();
const { container } = renderPanel(onSubmit);
fillRequiredFields(container);
fireEvent.change(container.querySelector("#pp-floor") as HTMLInputElement, {
target: { value: "9" },
});
fireEvent.change(
container.querySelector("#pp-total-floors") as HTMLInputElement,
{ target: { value: "17" } },
);
fireEvent.click(screen.getByText("ОЦЕНИТЬ КВАРТИРУ"));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(
screen.queryByText("Этаж не может быть больше числа этажей в доме"),
).not.toBeInTheDocument();
});
it("оба поля этажности пустые — не ошибка, сабмит проходит", () => {
const onSubmit = vi.fn();
const { container } = renderPanel(onSubmit);
fillRequiredFields(container);
fireEvent.click(screen.getByText("ОЦЕНИТЬ КВАРТИРУ"));
expect(onSubmit).toHaveBeenCalledTimes(1);
const [payload] = onSubmit.mock.calls[0] as [
{ floor: unknown; total_floors: unknown },
];
expect(payload.floor).toBeNull();
expect(payload.total_floors).toBeNull();
});
});

View file

@ -22,7 +22,7 @@
* В репозитории уже есть соседние, но НЕ идентичные дисклеймеры не путать и
* не плодить пятый/шестой литерал с той же сутью:
* - `src/app/mera-public/content.ts` DELIVERABLES_DISCLAIMER (135-ФЗ)
* - `src/app/mera-public/_components/SiteFooter.tsx`
* - `src/app/mera-public/_components/v3/FooterV3.tsx`
* - `src/app/mera-public/oferta/page.tsx`
* - `backend/app/services/exporters/trade_in_pdf.py` (версия для PDF-отчёта)
*/

View file

@ -71,7 +71,3 @@ class AvitoWarmupCookiesMissingError(AvitoBlockedError):
proxy-ротация, consecutive-block breaker в pipeline/backfill) отработали как на обычном
блоке площадки, без отдельного except-блока на call-сайтах.
"""
class AvitoParseError(AvitoError):
"""Cannot parse Avito HTML structure (selector changes)."""

View file

@ -157,12 +157,6 @@ class ScraperConfig(Protocol):
avito_serp_ok_not_banned: bool
# changeip settle-пауза (секунды) после ротации IP.
avito_proxy_rotate_settle_s: float
# Ретраи changeip-GET: N попыток по proxy_rotate_attempt_timeout_s каждая (#1950).
# changeip-ссылка снята (#2616 шаг 2) — эти два поля сейчас не читаются
# ban-rotation кодом (см. avito_proxy_max_rotations выше), оставлены как
# budget-верхняя-граница для app.tasks.avito_detail_backfill wait_for.
proxy_rotate_attempts: int
proxy_rotate_attempt_timeout_s: float
# Cian лимит ротаций (rotate_url снят #2616 шаг 2, см. avito_proxy_max_rotations).
cian_proxy_max_rotations: int
# Yandex лимит ротаций (аналогично).

View file

@ -92,10 +92,6 @@ class IMVAddressNotFoundError(Exception):
"""Raised when /coords/by_address returns no geoHash for given address."""
class IMVCityMismatchError(Exception):
"""Raised when locationId не входит в ожидаемый город (sanity check)."""
class IMVAuthError(Exception):
"""Avito API rejected auth / quota exceeded (HTTP 401 / 403)."""