Compare commits
19 commits
eb494e76fb
...
2e82b0810f
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e82b0810f | |||
| b17a2a310c | |||
| 2c9b4ab547 | |||
| f612401202 | |||
| 5d93f13030 | |||
| 3b029cc0c7 | |||
| ac2633011b | |||
| 6ab13649f6 | |||
| b241e0145a | |||
| 195f9f3697 | |||
| 7563f849db | |||
| a48070dd89 | |||
| c5315539fa | |||
| 28e13d5841 | |||
| 49652f4413 | |||
|
|
1141035899 | ||
| 6976ca8915 | |||
| d4e106882c | |||
|
|
4902d1e29e |
73 changed files with 4442 additions and 1579 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
797
tradein-mvp/backend/app/api/v1/payments.py
Normal file
797
tradein-mvp/backend/app/api/v1/payments.py
Normal 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
|
||||
)
|
||||
|
|
@ -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). Пробуем
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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, освобождая память.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
800
tradein-mvp/backend/tests/test_payments_router.py
Normal file
800
tradein-mvp/backend/tests/test_payments_router.py
Normal 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 == []
|
||||
|
|
@ -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, "схема чтения утекла при выключенном флаге"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Комментарии — не витрина: в них величины ЦИТИРУЮТСЯ, чтобы объяснить снятое.
|
||||
* ` ` заодно превращается в пробел: «150 ₽» на экране — то же самое
|
||||
* число с единицей, что и «150 ₽», и именно в таком виде цена однажды уже
|
||||
* проехала мимо сканера.
|
||||
*/
|
||||
function stripComments(src: string): string {
|
||||
return src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
||||
.replace(/(^|[^:"'`])\/\/[^\n]*/g, "$1")
|
||||
.replace(/ |\\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 ₽</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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 не протекает в публичные ссылки", () => {
|
||||
|
|
|
|||
|
|
@ -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"');
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} ₽
|
||||
</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 ₽: диапазон цены, цена за квадратный
|
||||
метр и список квартир, на которых он построен. Один платёж, без
|
||||
подписки. Приём оплаты сейчас подключается — как только заработает,
|
||||
кнопка появится здесь.
|
||||
Полный расчёт стоит {SERVICE_PRICE_RUB} ₽: диапазон цены, цена
|
||||
за квадратный метр и список квартир, на которых он построен. Один
|
||||
платёж, без подписки. Приём оплаты сейчас подключается — как только
|
||||
заработает, кнопка появится здесь.
|
||||
</p>
|
||||
<p className={styles.repPaidFine}>
|
||||
Если надёжный расчёт не получится — вернём 150 ₽:{" "}
|
||||
<PublicLink to="refund">условия возврата</PublicLink>.
|
||||
Если надёжный расчёт не получится — вернём {SERVICE_PRICE_RUB}
|
||||
₽: <PublicLink to="refund">условия возврата</PublicLink>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* FreeCheckCard — карточка «Бесплатная проверка» на первом экране (макет v3,
|
||||
* `МЕРА для себя v3.dc.html` строки ~129-170).
|
||||
*
|
||||
* ЧТО ОНА ДЕЛАЕТ СЕГОДНЯ. Проверяет пять полей и уводит на `/estimate`, где
|
||||
* ЧТО ОНА ДЕЛАЕТ СЕГОДНЯ. Проверяет шесть полей и уводит на `/estimate`, где
|
||||
* живёт сама проверка. Макетный сценарий «спиннер 1100 мс → готовый
|
||||
* результат прямо в карточке» не воспроизводится и не будет: результату нужно
|
||||
* место рядом с полями, а автокомплиту — место под список; в карточке первого
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ₽
|
||||
{`Открыть за ${SERVICE_PRICE_RUB}\u00a0₽`}
|
||||
</button>
|
||||
<p className={styles.heroResultOverlayCaption}>
|
||||
Оплата подключается вместе с открытием сервиса — пока недоступна.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export function HeaderV3() {
|
|||
Для бизнеса
|
||||
</a>
|
||||
<a href="#check" className={styles.hdrCta}>
|
||||
Проверить за 30 секунд
|
||||
Проверить квартиру
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} ₽`,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ export default function ArticlesPage() {
|
|||
</p>
|
||||
</div>
|
||||
<PublicLink to="estimate" className={styles.gameBtnAccent}>
|
||||
Проверить за 30 секунд
|
||||
Проверить квартиру
|
||||
</PublicLink>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
107
tradein-mvp/frontend/src/app/mera-public/landing-facts.ts
Normal file
107
tradein-mvp/frontend/src/app/mera-public/landing-facts.ts
Normal 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 полей";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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%";
|
||||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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("пропускает короткие адреса публичного домена", () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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-отчёта)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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 лимит ротаций (аналогично).
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue