feat(mera/b2c): отдельный экран оценки на meraocenka.ru и короткие адреса
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 44s
CI Trade-In / frontend-checks (pull_request) Successful in 1m2s
CI Trade-In / backend-tests (pull_request) Successful in 4m41s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 44s
CI Trade-In / frontend-checks (pull_request) Successful in 1m2s
CI Trade-In / backend-tests (pull_request) Successful in 4m41s
## Экран проверки — /estimate Проверка квартиры вынесена на собственный адрес: там у автокомплита есть место под список подсказок, а у результата — место рядом с полями. Форма в герое лэндинга осталась входной точкой и уводит сюда, донося набранное через sessionStorage (НЕ через query — адрес в URL попал бы в access-лог Caddy рядом с IP посетителя, а мы на той же странице обещаем ничего не хранить). Показывает живую пробу покрытия: сколько похожих квартир продаётся рядом и сколько в среднем висят их объявления. Ни одной рублёвой цифры — цену продаёт платный шаг. Тексты вердикта вынесены чистой функцией (coverage-copy.ts) и покрыты тестами: подпись под возрастом обязана говорить «объявление», а не «продаётся» (выборка цензурирована), при неизвестном возрасте плитки нет вообще, а пустая когорта объясняется как факт о рынке с подсказкой, что поменять, — директива «никогда не блокировать вывод». ## Короткие адреса Человек больше не видит /trade-in/mera-public/... — только /, /estimate, /oferta, /refund, /privacy. Длинные адреса отдают 301 на короткие: у страницы один канонический адрес, старые ссылки живы. Цена решения: ссылки работают только на meraocenka.ru (короткие пути раздаёт этот хост). Открывать лэндинг для проверки нужно там же, а не с gendsgn.ru. Ссылки эмитятся обычным <a> (PublicLink) — next/link подставляет basePath, и href="/estimate" уехал бы на несуществующий /trade-in/estimate. ## Три дефекта, найденных на живом сайте 1. Палитра v3 никуда не доезжала. b2c-tokens.ts не импортировал НИКТО, ни одна --b2c-* переменная не объявлялась, каскад молча пропускал такие декларации — лэндинг отдавал 200 бесцветным. Добавлен мост b2cVars, гейтом стал тест: каждая использованная в CSS переменная обязана быть объявлена. 2. Голый /trade-in/mera-public падал в 404 — матчер был со слэшем и звёздочкой. Ровно туда вела «Главная» в подвале. 3. «Для бизнеса» вела на «/» — то есть на сам лэндинг. Теперь абсолютный адрес B2B-контура. Пункты «Проверьте себя» и «Продажа под ключ» вели на якоря, которых нет нигде: приведены к виду «Статьи» — видны, но не кликабельны. ## Периметр и приватность Подсказки переведены на POST: access-лог публичного домена пишет URI целиком, то есть GET с ?q= сохранял бы адрес квартиры в файл. Тело в лог не попадает. Метод запинен тестом — это часть обещания, а не стиль. П. 5.4 политики ПДн переписан ВМЕСТЕ с кодом: прежний текст утверждал, что адрес не покидает браузер, и это перестало быть правдой. Новый говорит точно — передаётся, используется однократно, в базах не сохраняется. Последнее проверено по коду: suggest() работает без кэша, проба — один SELECT, аудит пишет строку только при наличии username. PUBLIC_ESTIMATE_ENABLED сузился до платного шага (бесплатная проба не хранит ничего, платный расчёт хранит). ## Проверено vitest 47 passed (9 файлов), tsc, next lint, next build, isolation guard 40 файлов, backend 75 passed, caddy validate = Valid configuration. Мутации структурных гейтов: убрать --b2c-accent-text из b2cVars → падает тест палитры убрать /estimate из @meraPages → падает тест маршрутов добавить импорт next/link → падает тест basePath откат → 14 passed Caddyfile добавлен в paths-filter фронтового лэйна: его читает тест маршрутов, и без этой строки правка одного лишь Caddyfile не запускала бы ни один гейт. Refs #2894, #2895
This commit is contained in:
parent
d046eaada2
commit
7424c283d5
27 changed files with 1630 additions and 245 deletions
|
|
@ -54,6 +54,15 @@ jobs:
|
|||
- '.forgejo/workflows/ci-tradein.yml'
|
||||
frontend:
|
||||
- 'tradein-mvp/frontend/**'
|
||||
# Caddyfile — по той же причине, что auth/** у бэкенда: он лежит в
|
||||
# КОРНЕ репы, но его читает фронтовый тест
|
||||
# (mera-public/__tests__/public-perimeter.test.ts) — тот сверяет,
|
||||
# что каждый маршрут публичного сайта действительно раздаётся на
|
||||
# meraocenka.ru. Без этой строки правка одного лишь Caddyfile не
|
||||
# запускала бы НИ ОДИН гейт, и удаление короткого адреса из
|
||||
# allowlist уехало бы на main зелёным — а на сайте кнопка «Проверить»
|
||||
# стала бы ссылкой в 404.
|
||||
- 'Caddyfile'
|
||||
- '.forgejo/workflows/ci-tradein.yml'
|
||||
browser:
|
||||
# Сайдкар — сервис ВНЕ uv-воркспейса (tradein-mvp/pyproject.toml
|
||||
|
|
|
|||
73
Caddyfile
73
Caddyfile
|
|
@ -251,45 +251,68 @@ meraocenka.ru {
|
|||
}
|
||||
}
|
||||
|
||||
# Короткие адреса юридических документов. Именно они напечатаны ВНУТРИ
|
||||
# самих документов (оферта ссылается на meraocenka.ru/refund, политика
|
||||
# возврата — на meraocenka.ru/oferta) и уходят в заявку эквайеру, поэтому
|
||||
# обязаны резолвиться сами по себе, а не только длинным
|
||||
# /trade-in/mera-public/<doc>. Обратное направление тоже рабочее: длинный
|
||||
# путь ловит handle ниже — навигация внутри сайта ходит по нему, потому что
|
||||
# то же поддерево открывается и с gendsgn.ru/trade-in/mera-public, где
|
||||
# короткого /oferta нет.
|
||||
# Короткие адреса страниц публичного сайта. Именно они напечатаны ВНУТРИ
|
||||
# юридических документов (оферта ссылается на meraocenka.ru/refund,
|
||||
# политика возврата — на meraocenka.ru/oferta) и уходят в заявку эквайеру,
|
||||
# поэтому обязаны резолвиться сами по себе.
|
||||
#
|
||||
# ЭТО ЕДИНСТВЕННЫЙ ВИД АДРЕСА, КОТОРЫЙ ВИДИТ ЧЕЛОВЕК (решение владельца,
|
||||
# 15.08.2026). Раньше навигация внутри сайта ходила по длинным
|
||||
# /trade-in/mera-public/... — так короткие адреса и длинные существовали
|
||||
# параллельно. Теперь длинные отдают 301 на короткие (см. handle ниже), а
|
||||
# ссылки на страницах эмитятся сразу короткими (см. `PublicLink` во
|
||||
# фронте — обычный <a>, потому что next/link подставляет basePath).
|
||||
#
|
||||
# `rewrite`, а не `redir`: адрес в строке браузера должен остаться коротким
|
||||
# — модератор эквайера открывает ссылку из заявки и видит ровно тот URL,
|
||||
# который в ней указан. Каноничность для поисковиков задана отдельно, через
|
||||
# `alternates.canonical` на каждой из трёх страниц.
|
||||
# `alternates.canonical` на каждой странице.
|
||||
#
|
||||
# Пути перечислены поимённо, а не шаблоном: allowlist-by-default этого
|
||||
# site-блока — часть периметра (#2545), и превращать его в «любой корневой
|
||||
# путь проксируется» ради трёх страниц нельзя.
|
||||
@meraLegalDocs path /oferta /refund /privacy
|
||||
handle @meraLegalDocs {
|
||||
# путь проксируется» нельзя. Новая публичная страница = новая строка здесь
|
||||
# (и проверка в scripts/smoke-mera-perimeter.sh).
|
||||
#
|
||||
# NB: корень «/» СЮДА НЕ ВХОДИТ — он выше, отдельным handle. Причина
|
||||
# техническая: здесь цель собирается как `/trade-in/mera-public{path}`, а
|
||||
# для «/» это дало бы `/trade-in/mera-public/` со слэшем на конце. Next при
|
||||
# `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант
|
||||
# без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на
|
||||
# «/», и запрос закольцуется.
|
||||
@meraPages path /estimate /oferta /refund /privacy /v3
|
||||
handle @meraPages {
|
||||
rewrite * /trade-in/mera-public{path}
|
||||
reverse_proxy tradein-frontend:3000 {
|
||||
header_up -X-Authenticated-User
|
||||
}
|
||||
}
|
||||
|
||||
# Подстраницы САМОГО лэндинга. Нужны с момента мержа #2615: футер ссылается
|
||||
# на политику обработки ПДн через next/link (`PRIVACY_PATH`), а Next с
|
||||
# basePath эмитит её как /trade-in/mera-public/privacy. Без этого handle
|
||||
# ссылка уходила бы в catch-all 404 ниже — то есть обязательный по 152-ФЗ
|
||||
# документ был бы недоступен с публичной страницы.
|
||||
# Длинные адреса поддерева → 301 на короткие. Один канонический адрес у
|
||||
# страницы, а не два работающих.
|
||||
#
|
||||
# Matcher намеренно узкий — ровно поддерево лэндинга, НЕ /trade-in/*.
|
||||
# B2B-дерево (/trade-in/v2, /trade-in/api/*, /trade-in/admin/*, /history)
|
||||
# под него не подпадает и по-прежнему отдаёт 404. Регресс-тест на это —
|
||||
# в scripts/smoke-mera-perimeter.sh.
|
||||
handle /trade-in/mera-public/* {
|
||||
reverse_proxy tradein-frontend:3000 {
|
||||
header_up -X-Authenticated-User
|
||||
}
|
||||
# Зачем вообще оставлять длинные: они уже разошлись — ими ссылались подвал
|
||||
# и шапка до 15.08.2026, они могли попасть в закладки и в переписку. 301
|
||||
# (а не 404) сохраняет эти ссылки живыми и заодно передаёт поисковикам, что
|
||||
# канонический адрес один.
|
||||
#
|
||||
# ЗДЕСЬ ЖЕ ЧИНИТСЯ БАГ: прежний матчер был `/trade-in/mera-public/*` — со
|
||||
# слэшем и звёздочкой, поэтому ГОЛЫЙ `/trade-in/mera-public` (без хвоста)
|
||||
# под него не подпадал и падал в catch-all 404. Ровно на этот адрес вела
|
||||
# ссылка «Главная» в подвале v3, то есть она была мёртвой (замер на проде
|
||||
# 15.08.2026). Первый матчер ниже ловит обе формы — со слэшем и без.
|
||||
#
|
||||
# Query-строка при редиректе не переносится: ни одна из этих страниц
|
||||
# параметров не принимает (форма проверки шлёт данные телом POST, а
|
||||
# черновик с лэндинга едет через sessionStorage — специально чтобы адрес
|
||||
# квартиры не попал в access-лог).
|
||||
@meraLongRoot path /trade-in/mera-public /trade-in/mera-public/
|
||||
handle @meraLongRoot {
|
||||
redir / permanent
|
||||
}
|
||||
|
||||
@meraLongSub path_regexp meralong ^/trade-in/mera-public/(.+)$
|
||||
handle @meraLongSub {
|
||||
redir /{re.meralong.1} permanent
|
||||
}
|
||||
|
||||
# Next.js уже эмитит ссылки на статику с /trade-in-префиксом (тот же
|
||||
|
|
|
|||
|
|
@ -3,10 +3,16 @@
|
|||
#
|
||||
# Проверяет инварианты периметра (см. корневой Caddyfile):
|
||||
# 1. meraocenka.ru отдаёт 200 анонимно (публичный лэндинг).
|
||||
# 1b. Подстраница лэндинга /trade-in/mera-public/privacy отдаёт 200 —
|
||||
# политика ПДн, на которую ссылается футер.
|
||||
# 1b. Длинный адрес /trade-in/mera-public/privacy отдаёт 301 на короткий
|
||||
# (у страницы один канонический адрес, старые ссылки не ломаются).
|
||||
# 1c. Короткие адреса /oferta, /refund, /privacy отдают 200 — эти URL
|
||||
# напечатаны внутри самих юридических документов и уходят эквайеру.
|
||||
# 1d. /estimate отдаёт 200 — экран проверки, куда ведут все кнопки лэндинга.
|
||||
# 1e. Длинные адреса поддерева отдают 301 на короткие (включая ГОЛЫЙ
|
||||
# /trade-in/mera-public — прежний матчер его не ловил, «Главная» в подвале
|
||||
# вела в 404).
|
||||
# 2d. Публичный API /api/public/mera/* доступен анонимно, а /api/v1/* на
|
||||
# публичном домене по-прежнему 404.
|
||||
# 2. meraocenka.ru/v2 и /trade-in/v2, /trade-in/api/* (B2B-пути) отдают 404 —
|
||||
# allowlist-by-default, НЕ были случайно проброшены на B2B-дерево
|
||||
# tradein-frontend. Проверяются обе формы — с basePath-префиксом и без.
|
||||
|
|
@ -61,11 +67,13 @@ echo "== МЕРА B2C perimeter smoke (ЭТАП 1) =="
|
|||
# 1. Публичный домен отдаёт 200 анонимно.
|
||||
check "meraocenka.ru root — public 200" "$BASE_MERA/" 200
|
||||
|
||||
# 1b. Подстраница лэндинга (политика ПДн) доступна — на неё ссылается футер.
|
||||
# Путь приезжает с basePath: next/link + basePath=/trade-in эмитит именно
|
||||
# /trade-in/mera-public/privacy. Если этот handle выпадет из Caddyfile,
|
||||
# обязательный по 152-ФЗ документ станет недоступен с публичной страницы.
|
||||
check "meraocenka.ru privacy — public 200" "$BASE_MERA/trade-in/mera-public/privacy" 200
|
||||
# 1b. Подстраница лэндинга по ДЛИННОМУ адресу теперь отдаёт 301 на короткий, а
|
||||
# не 200: с 15.08.2026 у публичной страницы один канонический адрес.
|
||||
# Проверка осталась именно здесь, потому что раньше она сторожила
|
||||
# доступность обязательного по 152-ФЗ документа — теперь сторожит, что при
|
||||
# переходе на короткие адреса длинные не превратились в 404 (тогда бы
|
||||
# сломались уже разосланные ссылки).
|
||||
check "meraocenka.ru длинная privacy — 301 на короткую" "$BASE_MERA/trade-in/mera-public/privacy" 301
|
||||
|
||||
# 1c. Короткие адреса юридических документов. Это НЕ дубль проверки 1b: именно
|
||||
# эти три URL напечатаны внутри самих документов и уходят в заявку
|
||||
|
|
@ -76,6 +84,18 @@ check "meraocenka.ru/oferta — public 200" "$BASE_MERA/oferta" 200
|
|||
check "meraocenka.ru/refund — public 200" "$BASE_MERA/refund" 200
|
||||
check "meraocenka.ru/privacy — public 200" "$BASE_MERA/privacy" 200
|
||||
|
||||
# 1d. Экран проверки квартиры — короткий адрес, на который ведут все кнопки
|
||||
# лэндинга. Отвалится handle — кнопки «Проверить» станут ссылками в 404.
|
||||
check "meraocenka.ru/estimate — public 200" "$BASE_MERA/estimate" 200
|
||||
|
||||
# 1e. Длинные адреса поддерева отдают 301 на короткие: у страницы один
|
||||
# канонический адрес, а старые ссылки и закладки продолжают работать.
|
||||
# ГОЛЫЙ /trade-in/mera-public — регресс на баг 15.08.2026: прежний матчер
|
||||
# `/trade-in/mera-public/*` эту форму не ловил, и ссылка «Главная» в
|
||||
# подвале v3 вела в 404.
|
||||
check "meraocenka.ru длинный корень — 301 на /" "$BASE_MERA/trade-in/mera-public" 301
|
||||
check "meraocenka.ru длинная оферта — 301 на /oferta" "$BASE_MERA/trade-in/mera-public/oferta" 301
|
||||
|
||||
# 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401.
|
||||
check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404
|
||||
|
||||
|
|
@ -101,16 +121,16 @@ check "meraocenka.ru/_next/image — must 404 (не открываем опти
|
|||
# `/trade-in/api/*`. Зелёная только первая = API открыт целиком и тест это
|
||||
# пропустил (ровно та ошибка, ради которой в Caddyfile выбран отдельный
|
||||
# префикс, а не поимённый проброс v1-путей).
|
||||
check "meraocenka.ru public suggest — 200 anonymous" "$BASE_MERA/trade-in/api/public/mera/suggest?q=%D0%9C%D0%B0%D0%BB%D1%8B%D1%88%D0%B5%D0%B2%D0%B0" 200
|
||||
check_post "meraocenka.ru public suggest — 200 anonymous" "$BASE_MERA/trade-in/api/public/mera/suggest" '{"q":"Малышева"}' 200
|
||||
check_post "meraocenka.ru public coverage — 200 anonymous" "$BASE_MERA/trade-in/api/public/mera/coverage" '{"lat":56.838,"lon":60.597,"rooms":2,"area_m2":54}' 200
|
||||
check "meraocenka.ru v1 geocode — must stay 404" "$BASE_MERA/trade-in/api/v1/geocode/suggest?q=%D0%9C%D0%B0%D0%BB%D1%8B%D1%88%D0%B5%D0%B2%D0%B0" 404
|
||||
check "meraocenka.ru v1 geocode — must stay 404" "$BASE_MERA/trade-in/api/v1/geocode/suggest?q=test" 404
|
||||
check "meraocenka.ru v1 coverage — must stay 404" "$BASE_MERA/trade-in/api/v1/trade-in/coverage" 404
|
||||
|
||||
# Тот же публичный путь на gendsgn.ru: страницу лэндинга открывают и оттуда
|
||||
# (QA за basic_auth), поэтому URL у формы один на оба домена. Здесь он
|
||||
# проходит через уже существующий `handle /trade-in/api/*` — проверка ловит
|
||||
# регресс в rbac._PUBLIC_PATHS (стало бы 401), а не в Caddyfile.
|
||||
check "gendsgn.ru public suggest — 200 anonymous" "$BASE_MAIN/trade-in/api/public/mera/suggest?q=%D0%9C%D0%B0%D0%BB%D1%8B%D1%88%D0%B5%D0%B2%D0%B0" 200
|
||||
check_post "gendsgn.ru public suggest — 200 anonymous" "$BASE_MAIN/trade-in/api/public/mera/suggest" '{"q":"Малышева"}' 200
|
||||
|
||||
# 3. B2B-данные trade-in по-прежнему закрыты анониму.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -60,7 +60,8 @@ from __future__ import annotations
|
|||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.v1.geocode import SuggestResponse, suggest_addresses
|
||||
|
|
@ -106,16 +107,37 @@ def _enforce(limiter: SlidingWindowLimiter, request: Request, what: str) -> None
|
|||
limiter.record(ip)
|
||||
|
||||
|
||||
@router.get("/suggest", response_model=SuggestResponse)
|
||||
class PublicSuggestInput(BaseModel):
|
||||
"""Вход публичного автокомплита.
|
||||
|
||||
Телом, а не query-параметрами — см. `public_suggest`.
|
||||
"""
|
||||
|
||||
q: str = Field(min_length=2, max_length=200)
|
||||
limit: int = Field(default=8, ge=1, le=10)
|
||||
city_hint: str | None = Field(default=None, max_length=100)
|
||||
|
||||
|
||||
@router.post("/suggest", response_model=SuggestResponse)
|
||||
async def public_suggest(
|
||||
request: Request,
|
||||
q: Annotated[str, Query(min_length=2, max_length=200)],
|
||||
payload: PublicSuggestInput,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
limit: Annotated[int, Query(ge=1, le=10)] = 8,
|
||||
city_hint: Annotated[str | None, Query(max_length=100)] = None,
|
||||
) -> SuggestResponse:
|
||||
"""Автокомплит адреса для публичной формы (Свердловская область).
|
||||
|
||||
ПОЧЕМУ POST У ЧИТАЮЩЕЙ РУЧКИ. Каноничнее был бы GET с `?q=`. Но на
|
||||
публичном домене включён access-лог (`/var/log/caddy/meraocenka.ru.log`), а
|
||||
он пишет URI целиком — то есть адрес квартиры лёг бы в файл рядом с IP
|
||||
посетителя. Мы публично обещаем на этой же странице, что введённый адрес
|
||||
нигде не сохраняем; лог — это сохранение. Тело запроса в лог не попадает,
|
||||
поэтому обещание остаётся правдой без правки конфигурации логирования (её
|
||||
легко потерять при следующем рефакторинге Caddyfile — а тип запроса
|
||||
потерять нельзя, сломается сразу и заметно).
|
||||
|
||||
Тот же довод, что у черновика с лэндинга: он едет через sessionStorage, а
|
||||
не через query-параметры (frontend `estimate-draft.ts`).
|
||||
|
||||
Делегирует В ТУ ЖЕ функцию, что обслуживает B2B-экран
|
||||
(`app.api.v1.geocode.suggest_addresses`), а не повторяет её логику:
|
||||
публичная форма обязана резолвить адрес ровно так же, как платный расчёт,
|
||||
|
|
@ -126,7 +148,9 @@ async def public_suggest(
|
|||
стоить внешнего вызова.
|
||||
"""
|
||||
_enforce(_suggest_limiter, request, "suggest")
|
||||
return await suggest_addresses(q=q, limit=limit, db=db, city_hint=city_hint)
|
||||
return await suggest_addresses(
|
||||
q=payload.q, limit=payload.limit, db=db, city_hint=payload.city_hint
|
||||
)
|
||||
|
||||
|
||||
@router.post("/coverage", response_model=CoverageProbeResponse)
|
||||
|
|
|
|||
|
|
@ -150,11 +150,24 @@ def test_anonymous_gets_suggest(client: TestClient) -> None:
|
|||
"suggest_addresses",
|
||||
AsyncMock(return_value=SuggestResponse(items=[])),
|
||||
):
|
||||
resp = client.get(f"{PREFIX}/suggest", params={"q": "Малышева"})
|
||||
resp = client.post(f"{PREFIX}/suggest", json={"q": "Малышева"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json() == {"items": []}
|
||||
|
||||
|
||||
def test_suggest_is_post_so_address_never_lands_in_access_log() -> None:
|
||||
"""Адрес едет ТЕЛОМ, а не в query.
|
||||
|
||||
На публичном домене включён access-лог Caddy, и он пишет URI целиком —
|
||||
`GET /suggest?q=Малышева+51` сохранил бы адрес квартиры в файл рядом с IP
|
||||
посетителя. На той же странице мы обещаем, что введённый адрес нигде не
|
||||
сохраняем. Метод — часть этого обещания, поэтому запинен: вернуть GET
|
||||
«для каноничности» нельзя, не сломав обещание.
|
||||
"""
|
||||
route = next(r for r in public_mera.router.routes if r.path == "/suggest")
|
||||
assert route.methods == {"POST"}
|
||||
|
||||
|
||||
def test_closed_twin_still_401_for_anonymous(client: TestClient) -> None:
|
||||
"""Исключение точечное, а не «auth-гейт снят».
|
||||
|
||||
|
|
@ -198,7 +211,7 @@ def test_suggest_and_coverage_budgets_are_independent(client: TestClient) -> Non
|
|||
AsyncMock(return_value=SuggestResponse(items=[])),
|
||||
):
|
||||
for _ in range(public_mera._SUGGEST_LIMIT):
|
||||
client.get(f"{PREFIX}/suggest", params={"q": "Малышева"})
|
||||
client.post(f"{PREFIX}/suggest", json={"q": "Малышева"})
|
||||
|
||||
with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE):
|
||||
resp = client.post(f"{PREFIX}/coverage", json=_COVERAGE_BODY)
|
||||
|
|
@ -229,7 +242,7 @@ def test_suggest_limit_ceiling_is_lower_than_v1(client: TestClient) -> None:
|
|||
"suggest_addresses",
|
||||
AsyncMock(return_value=SuggestResponse(items=[])),
|
||||
):
|
||||
at_ceiling = client.get(f"{PREFIX}/suggest", params={"q": "Малышева", "limit": 10})
|
||||
above = client.get(f"{PREFIX}/suggest", params={"q": "Малышева", "limit": 15})
|
||||
at_ceiling = client.post(f"{PREFIX}/suggest", json={"q": "Малышева", "limit": 10})
|
||||
above = client.post(f"{PREFIX}/suggest", json={"q": "Малышева", "limit": 15})
|
||||
assert at_ceiling.status_code == 200, at_ceiling.text
|
||||
assert above.status_code == 422, above.text
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { describeCoverage } from "../coverage-copy";
|
||||
import type { CoverageProbe } from "../public-api";
|
||||
|
||||
/**
|
||||
* Что именно мы утверждаем о своих данных перед человеком, который пришёл
|
||||
* узнать цену квартиры. Здесь не форматирование — здесь честность, поэтому
|
||||
* тесты сформулированы через утверждения, а не через строки разметки.
|
||||
*/
|
||||
|
||||
function probe(over: Partial<CoverageProbe> = {}): CoverageProbe {
|
||||
return {
|
||||
status: "ok",
|
||||
n_listings: 34,
|
||||
median_listing_age_days: 44,
|
||||
n_with_age: 6,
|
||||
radius_m: 1000,
|
||||
city: "Екатеринбург",
|
||||
threshold: 10,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("describeCoverage", () => {
|
||||
it("не молчит ни при одном статусе — директива «никогда не блокировать вывод»", () => {
|
||||
for (const status of ["ok", "thin", "not_covered"] as const) {
|
||||
const verdict = describeCoverage(probe({ status, n_listings: status === "not_covered" ? 0 : 34 }));
|
||||
expect(verdict.title.length, `пустой заголовок при ${status}`).toBeGreaterThan(10);
|
||||
expect(verdict.lines.length, `нет объяснения при ${status}`).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("при неизвестном возрасте объявлений плитки нет — ноль вместо «неизвестно» хуже всего", () => {
|
||||
const verdict = describeCoverage(probe({ median_listing_age_days: null, n_with_age: 2 }));
|
||||
expect(verdict.tiles).toHaveLength(1);
|
||||
expect(JSON.stringify(verdict.tiles)).not.toContain("дн.");
|
||||
});
|
||||
|
||||
it("подпись под возрастом говорит про объявление, а не про срок продажи", () => {
|
||||
// Выборка цензурирована: эти квартиры ЕЩЁ продаются, поэтому «за столько
|
||||
// продаются» — прямая ложь. Формулировка запинена намеренно.
|
||||
const [, ageTile] = describeCoverage(probe()).tiles;
|
||||
expect(ageTile.label).toContain("объявление");
|
||||
expect(ageTile.label).not.toMatch(/продаётся|продаются|срок продажи|уходят/i);
|
||||
});
|
||||
|
||||
it("город вне покрытия — говорим прямо и не предлагаем платный шаг", () => {
|
||||
const verdict = describeCoverage(probe({ status: "not_covered", city: "", n_listings: 0 }));
|
||||
expect(verdict.offerPaidStep).toBe(false);
|
||||
expect(verdict.title).toMatch(/вне области/i);
|
||||
});
|
||||
|
||||
it("пустая когорта в НАШЕМ городе — это про рынок, а не «сервис не работает»", () => {
|
||||
const verdict = describeCoverage(
|
||||
probe({ status: "not_covered", city: "Берёзовский", n_listings: 0 }),
|
||||
);
|
||||
expect(verdict.offerPaidStep).toBe(false);
|
||||
expect(verdict.lines.join(" ")).toContain("Берёзовский");
|
||||
// Подсказка, что делать дальше, обязана быть — иначе это тупик.
|
||||
expect(verdict.lines.join(" ")).toMatch(/площадь|комнат/i);
|
||||
});
|
||||
|
||||
it("тонкая выборка не скрывается: называем и найденное, и порог", () => {
|
||||
const verdict = describeCoverage(probe({ status: "thin", n_listings: 4, threshold: 10 }));
|
||||
expect(verdict.offerPaidStep).toBe(true);
|
||||
expect(verdict.lines.join(" ")).toContain("4");
|
||||
expect(verdict.lines.join(" ")).toContain("10");
|
||||
});
|
||||
|
||||
it("склоняет существительное по числу — «1 похожая квартира», не «1 похожих квартир»", () => {
|
||||
const one = describeCoverage(probe({ n_listings: 1 })).tiles[0].label;
|
||||
const two = describeCoverage(probe({ n_listings: 2 })).tiles[0].label;
|
||||
const five = describeCoverage(probe({ n_listings: 5 })).tiles[0].label;
|
||||
const eleven = describeCoverage(probe({ n_listings: 11 })).tiles[0].label;
|
||||
|
||||
expect(one).toContain("похожая квартира");
|
||||
expect(two).toContain("похожие квартиры");
|
||||
expect(five).toContain("похожих квартир");
|
||||
// 11-14 — исключение из правила последней цифры.
|
||||
expect(eleven).toContain("похожих квартир");
|
||||
});
|
||||
|
||||
it("радиус показывается в километрах, когда он кратен тысяче метров", () => {
|
||||
expect(describeCoverage(probe()).tiles[0].label).toContain("1 км");
|
||||
expect(describeCoverage(probe({ radius_m: 750 })).tiles[0].label).toContain("750 м");
|
||||
});
|
||||
|
||||
it("ни в одном ответе нет рублей — цену продаёт платный шаг", () => {
|
||||
for (const status of ["ok", "thin", "not_covered"] as const) {
|
||||
const verdict = describeCoverage(probe({ status }));
|
||||
expect(JSON.stringify(verdict)).not.toMatch(/₽|руб/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PUBLIC_ROUTES } from "../content";
|
||||
import { b2cVars } from "../theme";
|
||||
|
||||
/**
|
||||
* Структурные гейты публичного B2C-периметра. Каждый заведён на РЕАЛЬНО
|
||||
* случившийся дефект — все три нашёл владелец на живом сайте, а не сборка.
|
||||
*
|
||||
* Проверки читают исходники с диска намеренно: то, что здесь ломается, — это
|
||||
* связь между файлами (CSS ↔ токены, роуты ↔ Caddyfile, компоненты ↔ basePath),
|
||||
* а не поведение одной функции. Такую связь юнит-тест на модуле не видит.
|
||||
*/
|
||||
|
||||
const MERA_PUBLIC = path.resolve(__dirname, "..");
|
||||
const REPO_ROOT = path.resolve(MERA_PUBLIC, "../../../../..");
|
||||
|
||||
function collectFiles(dir: string, exts: readonly string[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = path.join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
out.push(...collectFiles(full, exts));
|
||||
} else if (exts.includes(path.extname(full))) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("палитра v3 доезжает до CSS", () => {
|
||||
/**
|
||||
* Дефект 15.08.2026: `b2c-tokens.ts` завёл палитру макета, `landing-v3.module.css`
|
||||
* начал ссылаться на `var(--b2c-*)` — а моста между ними не появилось.
|
||||
* Переменные не объявлял никто, каскад молча пропускал такие декларации, и
|
||||
* лэндинг v3 на проде отдавал 200 бесцветным: карточки без фона, кнопки без
|
||||
* заливки. Ни сборка, ни линт, ни тайпчек этого не видят — заметил владелец.
|
||||
*/
|
||||
const css = readFileSync(path.join(MERA_PUBLIC, "landing-v3.module.css"), "utf8");
|
||||
const used = new Set(
|
||||
[...css.matchAll(/var\(\s*(--b2c-[a-z0-9-]+)/g)].map((m) => m[1]),
|
||||
);
|
||||
|
||||
it("в CSS вообще есть ссылки на переменные (иначе тест бесполезен)", () => {
|
||||
expect(used.size).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it("каждая использованная --b2c-* переменная объявлена в b2cVars", () => {
|
||||
const declared = new Set(Object.keys(b2cVars));
|
||||
const missing = [...used].filter((name) => !declared.has(name));
|
||||
expect(missing, `не объявлены — на странице будут пустые значения: ${missing}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("короткие адреса публичного домена", () => {
|
||||
/**
|
||||
* Дефект 15.08.2026: подвал v3 вёл «Главную» на `/mera-public`, а Caddy на
|
||||
* публичном домене раздавал только `/trade-in/mera-public/*` (со слэшем) —
|
||||
* голый путь падал в catch-all 404. Страница, которой нет в allowlist
|
||||
* Caddyfile, недоступна независимо от того, что она собралась.
|
||||
*/
|
||||
const caddyfile = readFileSync(path.join(REPO_ROOT, "Caddyfile"), "utf8");
|
||||
const meraBlock = caddyfile.slice(
|
||||
caddyfile.indexOf("meraocenka.ru {"),
|
||||
caddyfile.indexOf("merahome.ru {"),
|
||||
);
|
||||
|
||||
it("блок meraocenka.ru найден (иначе проверки ниже пустые)", () => {
|
||||
expect(meraBlock.length).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
it("каждый маршрут из PUBLIC_ROUTES раздаётся публичным доменом", () => {
|
||||
for (const route of Object.values(PUBLIC_ROUTES)) {
|
||||
// Корень — отдельным `handle /`, остальные перечислены в матчере @meraPages.
|
||||
const served =
|
||||
route === "/"
|
||||
? /handle\s+\/\s*\{/.test(meraBlock)
|
||||
: new RegExp(`@meraPages path[^\\n]*\\s${route}(\\s|$)`, "m").test(meraBlock);
|
||||
expect(served, `${route} не раздаётся на meraocenka.ru — ссылка на него будет 404`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("basePath не протекает в публичные ссылки", () => {
|
||||
/**
|
||||
* `next/link` подставляет `basePath=/trade-in` в каждый href, поэтому
|
||||
* `<Link href="/estimate">` уехал бы на несуществующий `/trade-in/estimate`.
|
||||
* Публичное дерево ходит обычным `<a>` (компонент `PublicLink`).
|
||||
*
|
||||
* Проверка на импорт, а не на строку `/trade-in` в разметке: именно импорт
|
||||
* `next/link` — источник подстановки, и именно он вернётся, если кто-то
|
||||
* добавит ссылку «как везде в проекте».
|
||||
*/
|
||||
it("ни один файл под mera-public не импортирует next/link", () => {
|
||||
const offenders = collectFiles(MERA_PUBLIC, [".ts", ".tsx"])
|
||||
.filter((file) => /from\s+["']next\/link["']/.test(readFileSync(file, "utf8")))
|
||||
.map((file) => path.relative(MERA_PUBLIC, file));
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
`next/link подставит basePath и ссылка уедет на /trade-in/...: ${offenders}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -29,10 +29,14 @@
|
|||
* (useMe/useQuota/useHistory и соседей) запрещено — публичный экран не должен
|
||||
* иметь к ним доступа даже теоретически.
|
||||
*
|
||||
* Когда бэкенд откроет анонимные ручки: переключить `PUBLIC_ESTIMATE_ENABLED`
|
||||
* в content.ts и заменить ветку `notLaunched` в `handleSubmit` на реальный
|
||||
* переход/запрос (комбобокс подсказок — по образцу ParamsPanel.tsx, вместе с
|
||||
* его клавиатурной моделью и sr-live-регионом).
|
||||
* КУДА ВЕДЁТ КНОПКА (15.08.2026). Раньше сабмит упирался в сообщение «расчёт
|
||||
* ещё не открыли» — анонимных ручек не существовало. Теперь они есть
|
||||
* (`/api/public/mera/*`), а сама проверка живёт на отдельном экране
|
||||
* `/estimate`: там у автокомплита есть место под список подсказок, а у
|
||||
* результата — место рядом с полями. Эта форма осталась входной точкой
|
||||
* лэндинга и делает ровно две полезные вещи — не пускает дальше без адреса и
|
||||
* требует явно назвать город (#2576: «Ленина, 1» без города давало уверенную
|
||||
* цену по одноимённой улице в ЕКБ жителю Нижнего Тагила).
|
||||
*/
|
||||
|
||||
import { useId, useRef, useState } from "react";
|
||||
|
|
@ -43,10 +47,11 @@ import { detectCityInText } from "@/lib/city-registry";
|
|||
import {
|
||||
COVERED_CITIES,
|
||||
PRIMARY_CITY,
|
||||
PUBLIC_ESTIMATE_ENABLED,
|
||||
PUBLIC_ROUTES,
|
||||
REGION_NAME,
|
||||
SECONDARY_CITIES,
|
||||
} from "../content";
|
||||
import { saveDraft } from "../estimate-draft";
|
||||
import styles from "../landing.module.css";
|
||||
|
||||
/** Значение <option> «моего города нет в списке». */
|
||||
|
|
@ -117,24 +122,12 @@ export function AddressForm() {
|
|||
setCity(detected);
|
||||
}
|
||||
|
||||
if (PUBLIC_ESTIMATE_ENABLED) {
|
||||
// TODO(backend-периметр): здесь появится реальный расчёт. Отдельный PR.
|
||||
return;
|
||||
}
|
||||
|
||||
// Если город уже назван в самом тексте адреса — не дублируем его в эхо
|
||||
// («Серов, Серов, Ленина 3»).
|
||||
const echo =
|
||||
detected === resolvedCity ? trimmed : `${resolvedCity}, ${trimmed}`;
|
||||
|
||||
setFeedback({
|
||||
kind: "info",
|
||||
title: "Расчёт по адресу мы ещё не открыли для всех",
|
||||
lines: [
|
||||
`Адрес выглядит как наш: ${echo}. Но публичная оценка пока выключена — сервис работает по доступу для партнёров, и мы не хотим показывать заглушку вместо цены.`,
|
||||
"Оценка появится на этой же странице. Если вопрос срочный — напишите нам в поддержку, ссылка в подвале.",
|
||||
],
|
||||
});
|
||||
// Уходим на отдельный экран проверки. Черновик кладём в sessionStorage, а
|
||||
// НЕ в query-параметры: адрес в URL попал бы в access-логи Caddy рядом с
|
||||
// IP посетителя — то есть мы бы хранили персональные данные там, где
|
||||
// публично обещали ничего не хранить (см. estimate-draft.ts).
|
||||
saveDraft({ address: trimmed, city: resolvedCity });
|
||||
window.location.assign(PUBLIC_ROUTES.estimate);
|
||||
}
|
||||
|
||||
const addressInvalid =
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import type { AnchorHTMLAttributes } from "react";
|
||||
|
||||
import { PUBLIC_ROUTES } from "../content";
|
||||
|
||||
/**
|
||||
* Ссылка между страницами публичного сайта — обычный `<a>`, не `next/link`.
|
||||
*
|
||||
* ПОЧЕМУ ТАК. В образ запечён `basePath=/trade-in`, и `next/link` подставляет
|
||||
* его в КАЖДЫЙ href: `<Link href="/estimate">` уедет на `/trade-in/estimate`,
|
||||
* которого не существует. Отключить подстановку для отдельной ссылки нельзя —
|
||||
* это свойство сборки, а не пропс. Значит короткие адреса публичного домена
|
||||
* (`/estimate`, `/oferta`, …) выразимы только обычным якорем.
|
||||
*
|
||||
* Что теряем: клиентскую навигацию и prefetch. На пяти статических страницах
|
||||
* это незаметно, а prefetch публичному дереву прямо противопоказан — он
|
||||
* подтягивал бы чанки страниц, которых анонимному посетителю видеть незачем.
|
||||
*
|
||||
* ЗАЧЕМ ОТДЕЛЬНЫЙ КОМПОНЕНТ, А НЕ ПРОСТО `<a>`. Ради типа `to`: он принимает
|
||||
* только ключи `PUBLIC_ROUTES`, поэтому опечатка в адресе или ссылка на
|
||||
* страницу, которой нет в Caddy-allowlist публичного домена, не компилируется.
|
||||
* Именно такие ссылки и ломались раньше — подвал v3 вёл на `/mera-public`,
|
||||
* который на публичном домене отдавал 404, и заметил это владелец, а не тест.
|
||||
*/
|
||||
|
||||
type PublicRoute = keyof typeof PUBLIC_ROUTES;
|
||||
|
||||
interface PublicLinkProps
|
||||
extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
|
||||
to: PublicRoute;
|
||||
}
|
||||
|
||||
export function PublicLink({ to, children, ...rest }: PublicLinkProps) {
|
||||
return (
|
||||
<a href={PUBLIC_ROUTES[to]} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,24 +8,23 @@
|
|||
* оператора по 152-ФЗ. Значения импортируются, а не пишутся строками: тот же
|
||||
* набор рендерят оферта и политика ПДн, и разъехаться они не должны.
|
||||
*
|
||||
* Ссылки на документы ведут по ВНУТРЕННИМ путям (`OFFER_PATH` и соседи), а не
|
||||
* по коротким meraocenka.ru/oferta: короткие адреса существуют только на
|
||||
* публичном хосте (rewrite в Caddy), а это же поддерево открывается и с
|
||||
* gendsgn.ru/trade-in/mera-public — там короткая ссылка ушла бы в 404.
|
||||
* ССЫЛКИ НА ДОКУМЕНТЫ — КОРОТКИЕ (изменено 15.08.2026 по решению владельца).
|
||||
* Раньше здесь стояли внутренние пути `/mera-public/oferta` и соседи, чтобы
|
||||
* подвал работал и при открытии поддерева с gendsgn.ru. Теперь наоборот:
|
||||
* человек на публичном сервисе не должен видеть `/trade-in/mera-public/...` в
|
||||
* адресной строке, и именно короткие адреса напечатаны внутри самих
|
||||
* документов. Цена решения — переходы в подвале работают только на
|
||||
* meraocenka.ru; разбор и что с этим делать — в шапке `PUBLIC_ROUTES`
|
||||
* (content.ts).
|
||||
*
|
||||
* Внешняя ссылка проверяется `safeUrl` (правило frontend.md: ничего в href без
|
||||
* валидации схемы) и открывается в новой вкладке с rel="noreferrer".
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { safeUrl } from "@/lib/safeUrl";
|
||||
|
||||
import {
|
||||
LEGAL_ENTITY,
|
||||
OFFER_PATH,
|
||||
PRIVACY_PATH,
|
||||
REFUND_PATH,
|
||||
REGION_NAME,
|
||||
SUPPORT_EMAIL,
|
||||
SUPPORT_TELEGRAM_LABEL,
|
||||
|
|
@ -33,6 +32,8 @@ import {
|
|||
} from "../content";
|
||||
import styles from "../landing.module.css";
|
||||
|
||||
import { PublicLink } from "./PublicLink";
|
||||
|
||||
export function SiteFooter() {
|
||||
const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL);
|
||||
const year = new Date().getFullYear();
|
||||
|
|
@ -79,19 +80,19 @@ export function SiteFooter() {
|
|||
<p className={styles.footerTitle}>Документы</p>
|
||||
<ul className={styles.footerLinks} role="list">
|
||||
<li>
|
||||
<Link className={styles.link} href={OFFER_PATH}>
|
||||
<PublicLink className={styles.link} to="oferta">
|
||||
Публичная оферта
|
||||
</Link>
|
||||
</PublicLink>
|
||||
</li>
|
||||
<li>
|
||||
<Link className={styles.link} href={REFUND_PATH}>
|
||||
<PublicLink className={styles.link} to="refund">
|
||||
Политика возврата
|
||||
</Link>
|
||||
</PublicLink>
|
||||
</li>
|
||||
<li>
|
||||
<Link className={styles.link} href={PRIVACY_PATH}>
|
||||
<PublicLink className={styles.link} to="privacy">
|
||||
Обработка персональных данных
|
||||
</Link>
|
||||
</PublicLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,403 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* EstimateFlow — публичная форма проверки квартиры на `meraocenka.ru/estimate`.
|
||||
*
|
||||
* ПОЧЕМУ ОТДЕЛЬНАЯ СТРАНИЦА, А НЕ КАРТОЧКА В ГЕРОЕ. Карточка на первом экране
|
||||
* (`v3/FreeCheckCard.tsx`) продаёт: показывает, что сервис умеет, и уводит
|
||||
* читать дальше. Здесь человек уже пришёл считать — экран должен принадлежать
|
||||
* задаче целиком: автокомплит с местом под список подсказок, поля рядом,
|
||||
* результат на том же месте, без соседних секций, конкурирующих за внимание.
|
||||
*
|
||||
* ЧТО ЭТА ФОРМА ДЕЛАЕТ СЕГОДНЯ. Бесплатную пробу покрытия: сколько похожих
|
||||
* квартир продаётся рядом и как долго висят их объявления. Ни одной рублёвой
|
||||
* цифры — их отдаёт платный шаг, которого пока нет (нужен платёжный контур,
|
||||
* #2896). Это не заглушка: проба ходит в живой SQL по активным объявлениям и
|
||||
* отвечает по конкретному дому.
|
||||
*
|
||||
* НИЧЕГО НЕ СОХРАНЯЕТСЯ. Обе ручки только читают (см.
|
||||
* `backend/app/api/public/mera.py`) — адрес, который человек ввёл, никуда не
|
||||
* пишется. Это и позволяет форме работать ДО контура согласия 152-ФЗ (#2895).
|
||||
* Появится сохранение — обязано появиться и согласие, раньше запроса, а не
|
||||
* после.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import type { FormEvent, KeyboardEvent } from "react";
|
||||
|
||||
import { COVERED_CITIES, PRIMARY_CITY } from "../../content";
|
||||
import { describeCoverage } from "../../coverage-copy";
|
||||
import type { CoverageVerdict } from "../../coverage-copy";
|
||||
import { takeDraft } from "../../estimate-draft";
|
||||
import {
|
||||
PublicApiError,
|
||||
fetchAddressSuggestions,
|
||||
fetchCoverage,
|
||||
} from "../../public-api";
|
||||
import type { AddressSuggestion } from "../../public-api";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
/** Задержка перед запросом подсказок. Каждый вызов платный (DaData-тир). */
|
||||
const SUGGEST_DEBOUNCE_MS = 300;
|
||||
const SUGGEST_MIN_CHARS = 3;
|
||||
|
||||
const ROOM_OPTIONS = [
|
||||
{ value: 0, label: "Студия" },
|
||||
{ value: 1, label: "1 комната" },
|
||||
{ value: 2, label: "2 комнаты" },
|
||||
{ value: 3, label: "3 комнаты" },
|
||||
{ value: 4, label: "4 комнаты" },
|
||||
{ value: 5, label: "5 и больше" },
|
||||
] as const;
|
||||
|
||||
type Phase =
|
||||
| { kind: "form" }
|
||||
| { kind: "loading" }
|
||||
| { kind: "result"; verdict: CoverageVerdict }
|
||||
| { kind: "failed"; title: string; text: string };
|
||||
|
||||
const FORM: Phase = { kind: "form" };
|
||||
|
||||
function failureCopy(error: unknown): { title: string; text: string } {
|
||||
if (error instanceof PublicApiError && error.kind === "rate-limited") {
|
||||
const wait = error.retryAfterS ? `${error.retryAfterS} сек.` : "минуту";
|
||||
return {
|
||||
title: "Слишком много проверок подряд",
|
||||
// Инструкция, которую человек может выполнить, — а не «сервис сломался».
|
||||
text: `Подождите ${wait} и попробуйте снова. Ограничение стоит, чтобы форму не выгребали роботами.`,
|
||||
};
|
||||
}
|
||||
if (error instanceof PublicApiError && error.kind === "offline") {
|
||||
return {
|
||||
title: "Не получилось связаться с сервисом",
|
||||
text: "Похоже, пропала связь. Проверьте интернет и попробуйте ещё раз — введённое сохранится.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "Проверка не прошла",
|
||||
text: "Что-то сломалось на нашей стороне. Попробуйте ещё раз через пару минут; если повторится — напишите нам, ссылка в подвале.",
|
||||
};
|
||||
}
|
||||
|
||||
export function EstimateFlow() {
|
||||
const addressId = useId();
|
||||
const listboxId = useId();
|
||||
const cityId = useId();
|
||||
const roomsId = useId();
|
||||
const areaId = useId();
|
||||
const statusId = useId();
|
||||
|
||||
const [city, setCity] = useState<string>(PRIMARY_CITY);
|
||||
const [query, setQuery] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([]);
|
||||
const [highlighted, setHighlighted] = useState(-1);
|
||||
const [picked, setPicked] = useState<AddressSuggestion | null>(null);
|
||||
const [rooms, setRooms] = useState<string>("2");
|
||||
const [area, setArea] = useState("");
|
||||
const [phase, setPhase] = useState<Phase>(FORM);
|
||||
const [fieldError, setFieldError] = useState<"address" | "area" | null>(null);
|
||||
|
||||
const addressRef = useRef<HTMLInputElement>(null);
|
||||
const areaRef = useRef<HTMLInputElement>(null);
|
||||
const suggestAbort = useRef<AbortController | null>(null);
|
||||
|
||||
// Черновик с лэндинга — то, что человек уже набрал там. Забираем ОДИН раз
|
||||
// на монтировании; координат в нём нет (и быть не может — на лэндинге нет
|
||||
// автокомплита), поэтому дом всё равно придётся выбрать из подсказок.
|
||||
useEffect(() => {
|
||||
const draft = takeDraft();
|
||||
if (!draft) return;
|
||||
setQuery(draft.address);
|
||||
if (draft.rooms) setRooms(draft.rooms);
|
||||
if (draft.area) setArea(draft.area);
|
||||
if (draft.city && COVERED_CITIES.includes(draft.city)) setCity(draft.city);
|
||||
}, []);
|
||||
|
||||
// Подсказки: debounce + отмена предыдущего запроса. Без отмены ответы
|
||||
// приходят в произвольном порядке, и в списке может осесть выдача по
|
||||
// ПРЕДЫДУЩЕЙ строке — человек видит подсказки не к тому, что набрал.
|
||||
useEffect(() => {
|
||||
if (picked || query.trim().length < SUGGEST_MIN_CHARS) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
suggestAbort.current?.abort();
|
||||
const controller = new AbortController();
|
||||
suggestAbort.current = controller;
|
||||
|
||||
fetchAddressSuggestions(query.trim(), {
|
||||
cityHint: city,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((items) => {
|
||||
setSuggestions(items);
|
||||
setHighlighted(-1);
|
||||
})
|
||||
.catch(() => {
|
||||
// Молча: подсказки — удобство. Отвалились (в т.ч. по лимиту) —
|
||||
// человек всё ещё может выбрать дом, набрав адрес точнее. Ронять
|
||||
// на этом весь экран было бы хуже, чем остаться без списка.
|
||||
setSuggestions([]);
|
||||
});
|
||||
}, SUGGEST_DEBOUNCE_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [query, city, picked]);
|
||||
|
||||
useEffect(() => () => suggestAbort.current?.abort(), []);
|
||||
|
||||
const choose = useCallback((item: AddressSuggestion) => {
|
||||
setPicked(item);
|
||||
setQuery(item.full_address);
|
||||
setSuggestions([]);
|
||||
setHighlighted(-1);
|
||||
setFieldError(null);
|
||||
}, []);
|
||||
|
||||
function handleAddressKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (!suggestions.length) return;
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setHighlighted((i) => (i + 1) % suggestions.length);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setHighlighted((i) => (i <= 0 ? suggestions.length - 1 : i - 1));
|
||||
} else if (event.key === "Enter" && highlighted >= 0) {
|
||||
event.preventDefault();
|
||||
choose(suggestions[highlighted]);
|
||||
} else if (event.key === "Escape") {
|
||||
setSuggestions([]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
// Координаты берём ТОЛЬКО из выбранной подсказки: сама проба ничего не
|
||||
// геокодирует (см. CoverageProbeInput на бэке). Набранная руками строка
|
||||
// без выбора из списка — это адрес без координат, считать по нему нечего.
|
||||
if (!picked) {
|
||||
setFieldError("address");
|
||||
setPhase(FORM);
|
||||
addressRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const areaValue = Number.parseFloat(area.replace(",", "."));
|
||||
if (!Number.isFinite(areaValue) || areaValue <= 10 || areaValue >= 500) {
|
||||
setFieldError("area");
|
||||
setPhase(FORM);
|
||||
areaRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
setFieldError(null);
|
||||
setPhase({ kind: "loading" });
|
||||
|
||||
fetchCoverage({
|
||||
lat: picked.lat,
|
||||
lon: picked.lon,
|
||||
rooms: Number.parseInt(rooms, 10),
|
||||
area_m2: areaValue,
|
||||
})
|
||||
.then((probe) => setPhase({ kind: "result", verdict: describeCoverage(probe) }))
|
||||
.catch((error) => setPhase({ kind: "failed", ...failureCopy(error) }));
|
||||
}
|
||||
|
||||
const showList = suggestions.length > 0 && !picked;
|
||||
|
||||
return (
|
||||
<div className={styles.estCard}>
|
||||
<form className={styles.heroForm} onSubmit={handleSubmit} noValidate>
|
||||
<label className={styles.heroField} htmlFor={cityId}>
|
||||
<span className={styles.heroFieldLabel}>Город</span>
|
||||
<select
|
||||
id={cityId}
|
||||
className={styles.heroInput}
|
||||
value={city}
|
||||
onChange={(event) => {
|
||||
setCity(event.target.value);
|
||||
setPicked(null);
|
||||
}}
|
||||
>
|
||||
{COVERED_CITIES.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className={styles.estAutocomplete}>
|
||||
<label className={styles.heroField} htmlFor={addressId}>
|
||||
<span className={styles.heroFieldLabel}>Улица и дом</span>
|
||||
<input
|
||||
id={addressId}
|
||||
ref={addressRef}
|
||||
className={`${styles.heroInput} ${fieldError === "address" ? styles.heroInputInvalid : ""}`}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
placeholder="Например, Малышева 51"
|
||||
value={query}
|
||||
role="combobox"
|
||||
aria-expanded={showList}
|
||||
aria-controls={listboxId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={
|
||||
highlighted >= 0 ? `${listboxId}-${highlighted}` : undefined
|
||||
}
|
||||
aria-invalid={fieldError === "address" || undefined}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
// Правка строки после выбора аннулирует координаты — иначе
|
||||
// человек поправит номер дома, а посчитается прежний.
|
||||
setPicked(null);
|
||||
setFieldError(null);
|
||||
}}
|
||||
onKeyDown={handleAddressKeyDown}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{showList && (
|
||||
<ul className={styles.estSuggestList} id={listboxId} role="listbox">
|
||||
{suggestions.map((item, index) => (
|
||||
<li key={`${item.full_address}-${index}`} role="presentation">
|
||||
<button
|
||||
type="button"
|
||||
id={`${listboxId}-${index}`}
|
||||
role="option"
|
||||
aria-selected={index === highlighted}
|
||||
className={`${styles.estSuggestItem} ${index === highlighted ? styles.estSuggestItemActive : ""}`}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => choose(item)}
|
||||
>
|
||||
<span className={styles.estSuggestMain}>{item.label}</span>
|
||||
<span className={styles.estSuggestSub}>{item.full_address}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.heroFieldRow}>
|
||||
<label className={styles.heroField} htmlFor={roomsId}>
|
||||
<span className={styles.heroFieldLabel}>Комнат</span>
|
||||
<select
|
||||
id={roomsId}
|
||||
className={styles.heroInput}
|
||||
value={rooms}
|
||||
onChange={(event) => setRooms(event.target.value)}
|
||||
>
|
||||
{ROOM_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={String(option.value)}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className={styles.heroField} htmlFor={areaId}>
|
||||
<span className={styles.heroFieldLabel}>Площадь, м²</span>
|
||||
<input
|
||||
id={areaId}
|
||||
ref={areaRef}
|
||||
className={`${styles.heroInput} ${fieldError === "area" ? styles.heroInputInvalid : ""}`}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="54"
|
||||
value={area}
|
||||
aria-invalid={fieldError === "area" || undefined}
|
||||
onChange={(event) => {
|
||||
setArea(event.target.value);
|
||||
setFieldError(null);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.heroSubmitBtn}
|
||||
disabled={phase.kind === "loading"}
|
||||
>
|
||||
{phase.kind === "loading" ? "Смотрим данные…" : "Проверить мой дом"}
|
||||
</button>
|
||||
<p className={styles.heroSubmitCaption}>
|
||||
БЕСПЛАТНО · БЕЗ РЕГИСТРАЦИИ · НИЧЕГО НЕ СОХРАНЯЕМ
|
||||
</p>
|
||||
</form>
|
||||
|
||||
{/* Живая область постоянно в DOM: регион, добавленный в момент ответа,
|
||||
часть скринридеров не озвучивает. */}
|
||||
<div id={statusId} role="status" aria-live="polite">
|
||||
{fieldError === "address" && (
|
||||
<div className={`${styles.heroFormFeedback} ${styles.heroFormFeedbackError}`}>
|
||||
<p className={styles.heroFormFeedbackText}>
|
||||
Выберите дом из подсказок — по ним мы знаем точные координаты.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{fieldError === "area" && (
|
||||
<div className={`${styles.heroFormFeedback} ${styles.heroFormFeedbackError}`}>
|
||||
<p className={styles.heroFormFeedbackText}>
|
||||
Укажите площадь квартиры — от 11 до 499 м².
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase.kind === "failed" && (
|
||||
<div className={`${styles.heroFormFeedback} ${styles.heroFormFeedbackError}`}>
|
||||
<p className={styles.heroFormFeedbackTitle}>{phase.title}</p>
|
||||
<p className={styles.heroFormFeedbackText}>{phase.text}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase.kind === "result" && <CoverageResult verdict={phase.verdict} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CoverageResult({ verdict }: { verdict: CoverageVerdict }) {
|
||||
return (
|
||||
<div className={styles.estResult}>
|
||||
<p className={styles.heroResultEyebrow}>
|
||||
{verdict.tone === "none" ? "ПРОВЕРИЛИ" : "ГОТОВО · БЕСПЛАТНАЯ ЧАСТЬ"}
|
||||
</p>
|
||||
<p className={styles.heroFormFeedbackTitle}>{verdict.title}</p>
|
||||
|
||||
{verdict.tiles.length > 0 && (
|
||||
<div className={styles.heroResultGrid}>
|
||||
{verdict.tiles.map((tile) => (
|
||||
<div key={tile.label} className={styles.heroResultTile}>
|
||||
<div className={styles.heroResultTileValue}>{tile.value}</div>
|
||||
<div className={styles.heroResultTileLabel}>{tile.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verdict.lines.map((line) => (
|
||||
<p key={line} className={styles.heroFormFeedbackText}>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{verdict.offerPaidStep && (
|
||||
<div className={styles.estPaidStep}>
|
||||
<p className={styles.heroResultOverlayTitle}>
|
||||
Цена и срок продажи — следующий шаг
|
||||
</p>
|
||||
<p className={styles.heroFormFeedbackText}>
|
||||
Полный расчёт стоит 150 ₽: диапазон цены, цена за квадратный
|
||||
метр и список квартир, на которых он построен. Приём оплаты сейчас
|
||||
подключается — как только заработает, кнопка появится здесь.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { B2B_URL } from "../../content";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
import { PublicLink } from "../PublicLink";
|
||||
|
||||
/**
|
||||
* Шапка страницы проверки — намеренно короче лэндинговой (`v3/HeaderV3.tsx`).
|
||||
*
|
||||
* Из неё убраны навигация по секциям лэндинга, селектор города и CTA
|
||||
* «Проверить за 30 секунд». Причина у всех трёх одна: человек уже на экране
|
||||
* проверки. Якоря вроде `#accuracy` тут просто не на что навести (секций нет
|
||||
* на этой странице), город выбирается прямо в форме — а второй селектор
|
||||
* города в шапке означал бы два места, задающих одно значение, — а CTA вёл бы
|
||||
* на страницу, где посетитель уже стоит.
|
||||
*
|
||||
* Логотип отсюда — ссылка на главную: это единственный обратный путь на
|
||||
* лэндинг, и он же то, что человек инстинктивно нажимает.
|
||||
*/
|
||||
export function EstimateHeader() {
|
||||
return (
|
||||
<header className={styles.hdrRoot}>
|
||||
<div className={styles.hdrInner}>
|
||||
<PublicLink to="home" className={styles.hdrLogo}>
|
||||
<span className={styles.hdrLogoIcon} aria-hidden="true">
|
||||
<span className={styles.hdrLogoDot} />
|
||||
</span>
|
||||
<span className={styles.hdrLogoText}>
|
||||
<span className={styles.hdrLogoName}>МЕРА</span>
|
||||
<span className={styles.hdrLogoSub}>ДЛЯ СЕБЯ</span>
|
||||
</span>
|
||||
</PublicLink>
|
||||
|
||||
<div className={styles.hdrRight}>
|
||||
<a href={B2B_URL} className={styles.hdrBizLink}>
|
||||
Для бизнеса
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
*
|
||||
* РЕКВИЗИТЫ И ДОКУМЕНТЫ — НЕ из макета (там маска «ООО «МЕРА» · ИНН
|
||||
* 66700XXXXX», это не настоящие данные), а из content.ts: `LEGAL_ENTITY`,
|
||||
* `SUPPORT_EMAIL`, `OFFER_PATH`, `REFUND_PATH`, `PRIVACY_PATH` — тот же набор,
|
||||
* `SUPPORT_EMAIL`, короткие адреса документов (`PUBLIC_ROUTES`) — тот же набор,
|
||||
* что уже рендерит `_components/SiteFooter.tsx` (подвал v1/v2), только в
|
||||
* другой сетке. Документы включают Политику возврата и почту поддержки:
|
||||
* макет их не показывал, а это обязательные элементы платного контура
|
||||
|
|
@ -15,27 +15,27 @@
|
|||
* оплаты, которых нет, подпадает под ст. 5 ФЗ «О рекламе».
|
||||
*
|
||||
* «Статьи» в «Разделах» неактивны тем же образом, что в шапке (HeaderV3):
|
||||
* раздела нет в коде. «Проверить квартиру» ведёт на #check (форма в Hero на
|
||||
* этой же странице) — отдельного маршрута отчёта не существует. Оба
|
||||
* внутренних якоря — через `/mera-public#...`: подвал рендерится и на
|
||||
* оферте/возврате/ПДн, где голый `#check` никуда не долистал бы.
|
||||
* раздела нет в коде.
|
||||
*
|
||||
* ССЫЛКИ — КОРОТКИЕ (15.08.2026). «Проверить квартиру» ведёт на `/estimate` —
|
||||
* отдельный экран проверки, а не на якорь формы в герое. «МЕРА для бизнеса»
|
||||
* стала абсолютной: относительный `/` на публичном домене вёл на сам лэндинг,
|
||||
* то есть пункт не работал. Все переходы — обычным `<a>` через `PublicLink`,
|
||||
* потому что `next/link` подставляет basePath (см. его шапку).
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { safeUrl } from "@/lib/safeUrl";
|
||||
|
||||
import {
|
||||
B2B_URL,
|
||||
LEGAL_ENTITY,
|
||||
OFFER_PATH,
|
||||
PRIVACY_PATH,
|
||||
REFUND_PATH,
|
||||
REGION_NAME,
|
||||
SUPPORT_EMAIL,
|
||||
SUPPORT_TELEGRAM_LABEL,
|
||||
SUPPORT_TELEGRAM_URL,
|
||||
} from "../../content";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
import { PublicLink } from "../PublicLink";
|
||||
|
||||
export function FooterV3() {
|
||||
const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL);
|
||||
|
|
@ -58,12 +58,12 @@ export function FooterV3() {
|
|||
<div>
|
||||
<p className={styles.ftrColTitle}>Разделы</p>
|
||||
<div className={styles.ftrLinks}>
|
||||
<Link href="/mera-public" className={styles.ftrLink}>
|
||||
<PublicLink to="home" className={styles.ftrLink}>
|
||||
Главная
|
||||
</Link>
|
||||
<Link href="/mera-public#check" className={styles.ftrLink}>
|
||||
</PublicLink>
|
||||
<PublicLink to="estimate" className={styles.ftrLink}>
|
||||
Проверить квартиру
|
||||
</Link>
|
||||
</PublicLink>
|
||||
<span
|
||||
className={styles.ftrLinkDisabled}
|
||||
aria-disabled="true"
|
||||
|
|
@ -71,9 +71,9 @@ export function FooterV3() {
|
|||
>
|
||||
Статьи
|
||||
</span>
|
||||
<Link href="/" className={styles.ftrLink}>
|
||||
<a href={B2B_URL} className={styles.ftrLink}>
|
||||
МЕРА для бизнеса
|
||||
</Link>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -106,15 +106,15 @@ export function FooterV3() {
|
|||
<div>
|
||||
<p className={styles.ftrColTitle}>Документы</p>
|
||||
<div className={styles.ftrLinks}>
|
||||
<Link href={OFFER_PATH} className={styles.ftrLink}>
|
||||
<PublicLink to="oferta" className={styles.ftrLink}>
|
||||
Публичная оферта
|
||||
</Link>
|
||||
<Link href={REFUND_PATH} className={styles.ftrLink}>
|
||||
</PublicLink>
|
||||
<PublicLink to="refund" className={styles.ftrLink}>
|
||||
Политика возврата
|
||||
</Link>
|
||||
<Link href={PRIVACY_PATH} className={styles.ftrLink}>
|
||||
</PublicLink>
|
||||
<PublicLink to="privacy" className={styles.ftrLink}>
|
||||
Обработка персональных данных
|
||||
</Link>
|
||||
</PublicLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,17 +4,13 @@
|
|||
* FreeCheckCard — карточка «Бесплатная проверка» на первом экране (макет v3,
|
||||
* `МЕРА для себя v3.dc.html` строки ~129-170).
|
||||
*
|
||||
* ЧТО ОНА ДЕЛАЕТ СЕГОДНЯ И ПОЧЕМУ. Ровно то же, чем ограничена
|
||||
* `../AddressForm.tsx` в закрытом v2-контуре, и по той же причине:
|
||||
* `rbac_guard` (backend/app/core/rbac.py) не пускает анонима на
|
||||
* `/api/v1/geocode/suggest` и `/api/v1/trade-in/estimate` — их нет в
|
||||
* `_PUBLIC_PATHS`. Открытие анонимного периметра — отдельный backend-PR, вне
|
||||
* границ этой задачи. Поэтому здесь: честная валидация 5 обязательных полей
|
||||
* на клиенте + прямое сообщение «проверка ещё не открыта» вместо макетного
|
||||
* сценария «спиннер 1100 мс → готовый результат». Тот сценарий на живом
|
||||
* сайте отдавал бы одинаковые «14 объявлений / 47 дней» на любой введённый
|
||||
* адрес — не продающая формулировка, а обман конкретного человека (см. шапку
|
||||
* `../../marketing-v3.ts`).
|
||||
* ЧТО ОНА ДЕЛАЕТ СЕГОДНЯ. Проверяет пять полей и уводит на `/estimate`, где
|
||||
* живёт сама проверка. Макетный сценарий «спиннер 1100 мс → готовый
|
||||
* результат прямо в карточке» не воспроизводится и не будет: результату нужно
|
||||
* место рядом с полями, а автокомплиту — место под список; в карточке первого
|
||||
* экрана нет ни того, ни другого. Раньше здесь стояло сообщение «проверка ещё
|
||||
* не открыта» — анонимных ручек не существовало; теперь они есть
|
||||
* (`/api/public/mera/*`).
|
||||
*
|
||||
* Раздел «шаг 1 из 2» / полоска прогресса / ярлык рынка (`{{ cityMarket }}`,
|
||||
* `{{ stepLabel }}`) из макета сюда сознательно НЕ перенесены: они требуют
|
||||
|
|
@ -29,7 +25,8 @@
|
|||
import { useId, useRef, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
|
||||
import { PRIMARY_CITY, PUBLIC_ESTIMATE_ENABLED } from "../../content";
|
||||
import { PRIMARY_CITY, PUBLIC_ROUTES } from "../../content";
|
||||
import { saveDraft } from "../../estimate-draft";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
/** Три состояния квартиры — дословно из макета (строка 734 исходника). */
|
||||
|
|
@ -38,10 +35,7 @@ type Condition = (typeof CONDITIONS)[number];
|
|||
|
||||
type FieldName = "address" | "area" | "rooms" | "floor" | "condition";
|
||||
|
||||
type Feedback =
|
||||
| { kind: "none" }
|
||||
| { kind: "error"; field: FieldName; text: string }
|
||||
| { kind: "info"; title: string; lines: readonly string[] };
|
||||
type Feedback = { kind: "none" } | { kind: "error"; field: FieldName; text: string };
|
||||
|
||||
const NONE: Feedback = { kind: "none" };
|
||||
|
||||
|
|
@ -128,22 +122,17 @@ export function FreeCheckCard() {
|
|||
return;
|
||||
}
|
||||
|
||||
if (PUBLIC_ESTIMATE_ENABLED) {
|
||||
// TODO(backend-периметр): здесь появится реальный запрос к
|
||||
// POST /trade-in/coverage. По успешному ответу — переключить карточку в
|
||||
// ветку результата и отрендерить `FreeResultV3` с полученными
|
||||
// analogs/days (НЕ константами из marketing-v3.ts). Отдельный PR.
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback({
|
||||
kind: "info",
|
||||
title: "Бесплатную проверку по адресу мы пока не открыли",
|
||||
lines: [
|
||||
`Дом принят: ${trimmedArea} м², ${trimmedRooms}-комн., этаж ${trimmedFloor}, состояние «${condition}». Но онлайн-расчёт ещё не запущен — сервис работает по доступу для партнёров.`,
|
||||
"Проверка появится на этой же странице. Если вопрос срочный — напишите нам в поддержку, ссылка в подвале.",
|
||||
],
|
||||
// Уходим на отдельный экран проверки — там живёт автокомплит и результат.
|
||||
// Черновиком отдаём только то, что использует бесплатная проба покрытия:
|
||||
// адрес, комнаты, площадь. Этаж и состояние она не использует вовсе (см.
|
||||
// CoverageProbeInput на бэке) — их спросит платный шаг, когда появится,
|
||||
// и спросит на том же экране, а не тащит через переход.
|
||||
saveDraft({
|
||||
address: trimmedAddress,
|
||||
rooms: trimmedRooms,
|
||||
area: trimmedArea,
|
||||
});
|
||||
window.location.assign(PUBLIC_ROUTES.estimate);
|
||||
}
|
||||
|
||||
const errorField = feedback.kind === "error" ? feedback.field : null;
|
||||
|
|
@ -285,16 +274,6 @@ export function FreeCheckCard() {
|
|||
<p className={styles.heroFormFeedbackText}>{feedback.text}</p>
|
||||
</div>
|
||||
)}
|
||||
{feedback.kind === "info" && (
|
||||
<div className={styles.heroFormFeedback}>
|
||||
<p className={styles.heroFormFeedbackTitle}>{feedback.title}</p>
|
||||
{feedback.lines.map((line) => (
|
||||
<p key={line} className={styles.heroFormFeedbackText}>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,28 +2,29 @@
|
|||
* HeaderV3 — шапка лэндинга v3 (макет ~ строки 32-78): sticky, blur, лого +
|
||||
* навигация + селектор города + CTA.
|
||||
*
|
||||
* Логотип НЕ ссылка на "/": next.config.ts редиректит "/" → "/v2" (закрытый
|
||||
* B2B-контур за RouteGuard) — клик по вордмарку выкинул бы анонимного
|
||||
* посетителя лэндинга в приложение, требующее логина (тот же комментарий, что
|
||||
* в старом `_components/SiteHeader.tsx`). «Для бизнеса» ниже — НАОБОРОТ,
|
||||
* намеренно ведёт на "/": это единственная ссылка в шапке, которая должна
|
||||
* увести в закрытый контур.
|
||||
* Логотип НЕ ссылка: на публичном домене корень — это сам лэндинг, то есть
|
||||
* ссылка вела бы на текущую же страницу.
|
||||
*
|
||||
* «Статьи» — раздела ещё нет в коде (ни маршрута, ни контента): пункт
|
||||
* навигации нарочно неактивен (aria-disabled, без href), чтобы не заводить
|
||||
* ссылку в 404 на первой же публичной странице.
|
||||
* НЕАКТИВНЫЕ ПУНКТЫ. «Статьи», «Проверьте себя» и «Продажа под ключ» —
|
||||
* разделов нет в коде: ни маршрута, ни секции. Раньше три якоря вели на
|
||||
* `/mera-public#game` и `#turnkey`, которых не существует НИГДЕ, — то есть
|
||||
* клик уводил на другую страницу и там ничего не находил. Пункты приведены к
|
||||
* тому же виду, что «Статьи»: видны, но не кликабельны, пока раздела нет.
|
||||
*
|
||||
* Внутренние якоря идут через `/mera-public#...`, а не голый `#...`: шапка
|
||||
* рендерится на каждой странице поддерева (оферта/возврат/ПДн через
|
||||
* layout.tsx), и там `#accuracy` без пути к самому лэндингу никуда бы не
|
||||
* долистал.
|
||||
* ЯКОРЯ — голые (`#accuracy`, `#check`), потому что эта шапка рендерится
|
||||
* только на странице, где эти секции и лежат. На юридических страницах стоит
|
||||
* другая шапка (`_components/SiteHeader.tsx`), на `/estimate` — своя,
|
||||
* укороченная (`_components/estimate/EstimateHeader.tsx`): туда переносить
|
||||
* навигацию по секциям лэндинга незачем, там человек уже пришёл считать.
|
||||
*
|
||||
* «Для бизнеса» ведёт на АБСОЛЮТНЫЙ адрес B2B-контура: относительный «/» на
|
||||
* публичном домене вёл на сам лэндинг, то есть пункт не работал вовсе.
|
||||
*
|
||||
* Единственный клиентский остров — `CityPicker` (открытие/закрытие, поиск).
|
||||
* Сама шапка серверная.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { B2B_URL } from "../../content";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
import { CityPicker } from "./CityPicker";
|
||||
|
|
@ -43,32 +44,29 @@ export function HeaderV3() {
|
|||
</div>
|
||||
|
||||
<nav className={styles.hdrNav} aria-label="Основная навигация">
|
||||
<Link href="/mera-public#accuracy" className={styles.hdrNavLink}>
|
||||
<a href="#accuracy" className={styles.hdrNavLink}>
|
||||
Точность
|
||||
</Link>
|
||||
<Link href="/mera-public#game" className={styles.hdrNavLink}>
|
||||
Проверьте себя
|
||||
</Link>
|
||||
<Link href="/mera-public#turnkey" className={styles.hdrNavLink}>
|
||||
Продажа под ключ
|
||||
</Link>
|
||||
<span
|
||||
className={styles.hdrNavDisabled}
|
||||
aria-disabled="true"
|
||||
title="Раздел скоро откроется"
|
||||
>
|
||||
Статьи
|
||||
</span>
|
||||
</a>
|
||||
{["Проверьте себя", "Продажа под ключ", "Статьи"].map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className={styles.hdrNavDisabled}
|
||||
aria-disabled="true"
|
||||
title="Раздел скоро откроется"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className={styles.hdrRight}>
|
||||
<CityPicker />
|
||||
<Link href="/" className={styles.hdrBizLink}>
|
||||
<a href={B2B_URL} className={styles.hdrBizLink}>
|
||||
Для бизнеса
|
||||
</Link>
|
||||
<Link href="/mera-public#check" className={styles.hdrCta}>
|
||||
</a>
|
||||
<a href="#check" className={styles.hdrCta}>
|
||||
Проверить за 30 секунд
|
||||
</Link>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
/**
|
||||
* StickyCtaV3 — липкая нижняя CTA-панель (макет ~ строки 518-521).
|
||||
*
|
||||
* Кнопка — обычная ссылка на `/mera-public#check` (форма расчёта в Hero), а
|
||||
* не модалка: в этом заходе модального окна нет вовсе (макет вызывал
|
||||
* `openModal`, которого в коде не существует). Путь с префиксом, а не голый
|
||||
* `#check`: панель рендерится на каждой странице поддерева (оферта/возврат/
|
||||
* ПДн через layout.tsx), и там `#check` без пути к лэндингу никуда бы не
|
||||
* долистал.
|
||||
* Кнопка ведёт на `/estimate` — отдельный экран проверки, а не на якорь формы
|
||||
* в герое и не в модалку (макет вызывал `openModal`, которого в коде нет).
|
||||
* Панель липкая и видна в том числе внизу страницы, где формы уже не видно, —
|
||||
* якорь заставлял бы человека прокручивать обратно наверх ради полей, которые
|
||||
* на отдельном экране и так первое, что он увидит.
|
||||
*
|
||||
* `.barSpacer` — не декоративный отступ, а компенсация fixed-позиционирования:
|
||||
* `.barRoot` вырезан из потока документа, поэтому без спейсера он бы лёг
|
||||
|
|
@ -17,10 +16,10 @@
|
|||
* после подвала, как Header/Footer в текущем `layout.tsx`).
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
import { PublicLink } from "../PublicLink";
|
||||
|
||||
export function StickyCtaV3() {
|
||||
return (
|
||||
<>
|
||||
|
|
@ -29,9 +28,9 @@ export function StickyCtaV3() {
|
|||
<p className={styles.barText}>
|
||||
Узнайте цену и срок продажи своей квартиры
|
||||
</p>
|
||||
<Link href="/mera-public#check" className={styles.barBtn}>
|
||||
<PublicLink to="estimate" className={styles.barBtn}>
|
||||
Проверить за 30 секунд
|
||||
</Link>
|
||||
</PublicLink>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -34,30 +34,29 @@ import {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Включён ли публичный расчёт по адресу.
|
||||
* Включён ли ПЛАТНЫЙ публичный расчёт по адресу.
|
||||
*
|
||||
* Сегодня `false` и это не «недоделка фронта»: анонимный запрос к
|
||||
* `/api/v1/geocode/suggest` и `/api/v1/trade-in/estimate` отбивается
|
||||
* `rbac_guard` (backend/app/core/rbac.py::_PUBLIC_PATHS — эти пути в белом
|
||||
* списке отсутствуют), т.е. без отдельного backend-PR любая «живая» форма на
|
||||
* лэндинге отдавала бы 401. Форма поэтому честно сообщает, что расчёт ещё не
|
||||
* открыт, вместо имитации загрузки.
|
||||
*
|
||||
* Когда бэкенд откроет анонимный периметр — переключить в `true` и подключить
|
||||
* реальный сабмит в `_components/AddressForm.tsx` (там помечено TODO-местом).
|
||||
* ⚠️ ЗНАЧЕНИЕ ФЛАГА СУЗИЛОСЬ 15.08.2026. Раньше он означал «публичная форма
|
||||
* вообще что-нибудь делает»: анонимных ручек не существовало, `rbac_guard`
|
||||
* отбивал любой запрос с лэндинга, и форма честно писала «расчёт не открыт».
|
||||
* Теперь бесплатная часть ОТКРЫТА — `/estimate` ходит в `/api/public/mera/*`
|
||||
* (см. `backend/app/api/public/mera.py`) и показывает живую картину рынка
|
||||
* вокруг дома. Флаг остался только на платном шаге, и это правильное место
|
||||
* для него: бесплатная проба ничего не хранит, а платный расчёт хранит.
|
||||
*
|
||||
* ⚠️ ГЕЙТ: это НЕ однострочник. Переключение в `true` делает ложными публичные
|
||||
* утверждения, которые сегодня правдивы, поэтому вместе с флагом обязаны быть
|
||||
* сделаны:
|
||||
* 1. `privacy/page.tsx`, раздел «Что делает эта страница» — он УЖЕ условный по
|
||||
* этому флагу (ветка `true` описывает отправку и сохранение адреса);
|
||||
* перечитать текст обеих веток перед включением.
|
||||
* 1. `privacy/page.tsx`: п. 5.5 условен по этому флагу и утверждает, что
|
||||
* платный расчёт не открыт. Перечитать раздел 5 целиком — п. 5.4 уже
|
||||
* описывает бесплатную часть и переписан под неё.
|
||||
* 2. Согласие на обработку ПДн должно фиксироваться ДО первого INSERT в
|
||||
* `trade_in_estimates`: сегодня адрес физлица попадает в БД раньше любого
|
||||
* согласия (`address` NOT NULL, `expires_at` применяется только на чтении).
|
||||
* 3. Должен существовать реальный путь удаления данных — в бэкенде нет ни
|
||||
* DELETE-джоба в `app/tasks/**`, ни ручки erasure (проверено grep'ом);
|
||||
* privacy-страница поэтому и не обещает удаление.
|
||||
* К бесплатной пробе это не относится — она не пишет вовсе.
|
||||
* 3. Автоматический возврат средств обязан появиться ВМЕСТЕ с кнопкой
|
||||
* оплаты, а не после: он уже обещан в опубликованной политике возврата
|
||||
* (пп. 2.1 и 4), то есть является условием договора с момента публикации.
|
||||
*/
|
||||
export const PUBLIC_ESTIMATE_ENABLED: boolean = false;
|
||||
|
||||
|
|
@ -151,11 +150,12 @@ export const LEGAL_ENTITY: {
|
|||
* (rewrite на это же поддерево), и ИМЕННО короткие напечатаны в самих
|
||||
* документах и уйдут в заявку эквайеру.
|
||||
*
|
||||
* Навигация внутри сайта ходит по внутренним путям, а не по коротким: короткий
|
||||
* `/oferta` существует только на хосте meraocenka.ru, а то же поддерево
|
||||
* открывается и с gendsgn.ru/trade-in/mera-public — там ссылка на `/oferta`
|
||||
* ушла бы в 404. Канонический адрес для внешнего мира проставлен на каждой
|
||||
* странице через `alternates.canonical` (см. PUBLIC_ORIGIN ниже).
|
||||
* ⚠️ НАВИГАЦИЯ ПО НИМ БОЛЬШЕ НЕ ХОДИТ (решение владельца, 15.08.2026):
|
||||
* человек не должен видеть `/trade-in/mera-public/...` в адресной строке
|
||||
* публичного сервиса. Ссылки внутри публичных страниц ведут на короткие
|
||||
* адреса — см. `PUBLIC_ROUTES` ниже. Эти константы остались нужны для двух
|
||||
* вещей: цель Caddy-rewrite'ов описывается ими же, и на них ссылается B2B
|
||||
* (чекбокс согласия в v2 LeadForm.tsx), живущий на другом домене.
|
||||
*/
|
||||
// PRIVACY_PATH ре-экспортируется из "@/lib/legal-copy" (не литерал здесь) — тот же
|
||||
// путь нужен B2B trade-in виджетам (чекбокс согласия в v2 LeadForm.tsx), которым
|
||||
|
|
@ -164,13 +164,54 @@ export { PRIVACY_PATH } from "@/lib/legal-copy";
|
|||
export const OFFER_PATH = "/mera-public/oferta";
|
||||
export const REFUND_PATH = "/mera-public/refund";
|
||||
|
||||
/**
|
||||
* КОРОТКИЕ АДРЕСА ПУБЛИЧНОГО САЙТА — единственное, что должно попадать в
|
||||
* ссылки на страницах B2C-периметра.
|
||||
*
|
||||
* ПОЧЕМУ НЕ `next/link` С ЭТИМИ ЗНАЧЕНИЯМИ. `basePath=/trade-in` запечён в
|
||||
* образ, и `<Link href="/estimate">` эмитит `/trade-in/estimate` — путь,
|
||||
* которого не существует. Обойти basePath через Link нельзя, поэтому
|
||||
* публичные страницы ходят обычным `<a>` (компонент `PublicLink`, см.
|
||||
* `_components/PublicLink.tsx`). Полная перезагрузка на пяти статических
|
||||
* страницах ничего не стоит, а router-prefetch публичному дереву даже вреден:
|
||||
* он тянул бы чанки, которых анонимному посетителю видеть незачем.
|
||||
*
|
||||
* ЧЕМ ЗА ЭТО ПЛАТИМ. Короткие пути раздаёт ТОЛЬКО хост meraocenka.ru. То же
|
||||
* поддерево открывается и с `gendsgn.ru/trade-in/mera-public` — там переходы
|
||||
* по этим ссылкам уедут в чужой сайт (`/` — корень Site Finder) или в 404.
|
||||
* Это принято сознательно: публичный сервис живёт на своём домене, и делать
|
||||
* его адреса некрасивыми ради QA-открытия с соседнего хоста — не та цена.
|
||||
* Открывать лэндинг для проверки нужно на meraocenka.ru (он публичный, логин
|
||||
* не требуется). Длинные адреса продолжают работать: Caddy отдаёт с них 301
|
||||
* на короткие, так что старые ссылки и закладки не ломаются.
|
||||
*/
|
||||
export const PUBLIC_ROUTES = {
|
||||
home: "/",
|
||||
estimate: "/estimate",
|
||||
oferta: "/oferta",
|
||||
refund: "/refund",
|
||||
privacy: "/privacy",
|
||||
} as const;
|
||||
|
||||
/** Канонический публичный origin сервиса — база для canonical-ссылок. */
|
||||
export const PUBLIC_ORIGIN = "https://meraocenka.ru";
|
||||
|
||||
/**
|
||||
* B2B-контур («МЕРА для бизнеса») — АБСОЛЮТНЫЙ адрес, а не относительный путь.
|
||||
*
|
||||
* Он живёт на другом домене. Относительная ссылка `/` (как было в подвале и
|
||||
* шапке v3) на публичном домене вела на сам же лэндинг или в 404 — то есть
|
||||
* пункт меню «для бизнеса» не работал вовсе. Домен здесь захардкожен
|
||||
* осознанно: это ссылка НАРУЖУ, из одного продукта в другой, и подставлять её
|
||||
* из текущего хоста было бы неверно.
|
||||
*/
|
||||
export const B2B_URL = "https://gendsgn.ru/trade-in/";
|
||||
|
||||
/** Короткие публичные адреса тех же страниц (то, что напечатано в документах). */
|
||||
export const PUBLIC_PRIVACY_URL = `${PUBLIC_ORIGIN}/privacy`;
|
||||
export const PUBLIC_OFFER_URL = `${PUBLIC_ORIGIN}/oferta`;
|
||||
export const PUBLIC_REFUND_URL = `${PUBLIC_ORIGIN}/refund`;
|
||||
export const PUBLIC_PRIVACY_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.privacy}`;
|
||||
export const PUBLIC_OFFER_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.oferta}`;
|
||||
export const PUBLIC_REFUND_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.refund}`;
|
||||
export const PUBLIC_ESTIMATE_URL = `${PUBLIC_ORIGIN}${PUBLIC_ROUTES.estimate}`;
|
||||
|
||||
/**
|
||||
* Цена одного расчёта, ₽. Названа в оферте (п. 4.1) и в политике возврата —
|
||||
|
|
|
|||
130
tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts
Normal file
130
tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Проба покрытия → то, что видит человек.
|
||||
*
|
||||
* Вынесено из компонента отдельной чистой функцией сознательно: это не
|
||||
* форматирование, а место, где решается, что именно мы утверждаем о наших
|
||||
* данных. Такое проверяется тестами напрямую (`__tests__/coverage-copy.test.ts`),
|
||||
* а не через рендер формы.
|
||||
*
|
||||
* ДВА ПРАВИЛА, КОТОРЫЕ ЗДЕСЬ ЗАЩИЩАЮТСЯ
|
||||
*
|
||||
* 1. «Никогда не блокировать вывод» (директива владельца, 2026-08-10). Мало
|
||||
* данных — говорим прямо и предлагаем следующий шаг, а не показываем
|
||||
* экран-заглушку. Пустой экран человек читает как «сервис сломан», а не
|
||||
* как «по этому дому данных нет».
|
||||
*
|
||||
* 2. Честное имя величины. `median_listing_age_days` — возраст АКТИВНОГО
|
||||
* объявления, а не срок продажи: выборка цензурирована, эти квартиры ещё
|
||||
* продаются, реальная экспозиция проданных всегда меньше. Написать под
|
||||
* цифрой «за столько продаются» — соврать; поэтому подпись здесь одна и
|
||||
* тестом запинена. И при `median === null` (бэкенд занулил её, когда
|
||||
* возраст известен меньше чем по пяти объявлениям) плитки просто нет —
|
||||
* ноль вместо неизвестного значения был бы худшей из ошибок.
|
||||
*/
|
||||
|
||||
import type { CoverageProbe } from "./public-api";
|
||||
|
||||
export interface CoverageTile {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface CoverageVerdict {
|
||||
/** Влияет только на оформление (цвет плашки), не на смысл. */
|
||||
tone: "ok" | "thin" | "none";
|
||||
title: string;
|
||||
/** Абзацы под заголовком. */
|
||||
lines: readonly string[];
|
||||
tiles: readonly CoverageTile[];
|
||||
/** Показывать ли блок платного шага. */
|
||||
offerPaidStep: boolean;
|
||||
}
|
||||
|
||||
function pluralize(n: number, one: string, few: string, many: string): string {
|
||||
const mod100 = n % 100;
|
||||
if (mod100 >= 11 && mod100 <= 14) return many;
|
||||
const mod10 = n % 10;
|
||||
if (mod10 === 1) return one;
|
||||
if (mod10 >= 2 && mod10 <= 4) return few;
|
||||
return many;
|
||||
}
|
||||
|
||||
function radiusLabel(radiusM: number): string {
|
||||
return radiusM % 1000 === 0
|
||||
? `${radiusM / 1000} км`
|
||||
: `${radiusM} м`;
|
||||
}
|
||||
|
||||
function tilesFor(probe: CoverageProbe): CoverageTile[] {
|
||||
const tiles: CoverageTile[] = [
|
||||
{
|
||||
value: String(probe.n_listings),
|
||||
label: `${pluralize(probe.n_listings, "похожая квартира", "похожие квартиры", "похожих квартир")} продаётся в радиусе ${radiusLabel(probe.radius_m)}`,
|
||||
},
|
||||
];
|
||||
|
||||
if (probe.median_listing_age_days !== null) {
|
||||
tiles.push({
|
||||
value: `${probe.median_listing_age_days} дн.`,
|
||||
// Формулировка запинена тестом — см. шапку модуля.
|
||||
label: `столько в среднем висит объявление из этих ${probe.n_with_age}`,
|
||||
});
|
||||
}
|
||||
|
||||
return tiles;
|
||||
}
|
||||
|
||||
export function describeCoverage(probe: CoverageProbe): CoverageVerdict {
|
||||
const where = probe.city ? `в городе ${probe.city}` : "по этому адресу";
|
||||
|
||||
if (probe.status === "not_covered") {
|
||||
// Город не резолвится ни в один из тех, по которым мы собираем данные.
|
||||
if (!probe.city) {
|
||||
return {
|
||||
tone: "none",
|
||||
title: "Этот адрес вне области, по которой мы собираем данные",
|
||||
lines: [
|
||||
"Сейчас это Свердловская область: Екатеринбург целиком и ещё несколько городов вокруг. По другим регионам данных у нас нет — и придумывать цифру мы не станем.",
|
||||
"Проверьте, что в адресе указан нужный город, или попробуйте другой дом.",
|
||||
],
|
||||
tiles: [],
|
||||
offerPaidStep: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Город наш, но когорта пустая.
|
||||
return {
|
||||
tone: "none",
|
||||
title: "Рядом сейчас не продаётся ничего похожего",
|
||||
lines: [
|
||||
`Мы искали квартиры ${where} — в радиусе ${radiusLabel(probe.radius_m)} от дома, с тем же числом комнат и близкой площадью. Активных объявлений под эти условия не нашлось.`,
|
||||
"Это говорит о рынке, а не о вашей квартире: в тихом районе или по редкой планировке такое обычное дело. Попробуйте изменить площадь или число комнат — посмотрим, что есть по соседству.",
|
||||
],
|
||||
tiles: [],
|
||||
offerPaidStep: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (probe.status === "thin") {
|
||||
return {
|
||||
tone: "thin",
|
||||
title: "Данные есть, но их мало",
|
||||
lines: [
|
||||
`${where.charAt(0).toUpperCase()}${where.slice(1)} мы нашли ${probe.n_listings} ${pluralize(probe.n_listings, "подходящее объявление", "подходящих объявления", "подходящих объявлений")} — меньше, чем ${probe.threshold}, с которых мы считаем выборку уверенной.`,
|
||||
"Расчёт по такой выборке мы сделаем, но разброс будет шире обычного, и в самом отчёте это будет написано — а не спрятано.",
|
||||
],
|
||||
tiles: tilesFor(probe),
|
||||
offerPaidStep: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tone: "ok",
|
||||
title: "По вашему дому данные есть",
|
||||
lines: [
|
||||
`${where.charAt(0).toUpperCase()}${where.slice(1)} нашлось достаточно похожих квартир, чтобы считать по ним, а не по среднему за квадратный метр.`,
|
||||
],
|
||||
tiles: tilesFor(probe),
|
||||
offerPaidStep: true,
|
||||
};
|
||||
}
|
||||
65
tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts
Normal file
65
tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* Черновик заявки: то, что человек уже набрал на лэндинге, чтобы не набирать
|
||||
* это заново на `/estimate`.
|
||||
*
|
||||
* ПОЧЕМУ sessionStorage, А НЕ QUERY-ПАРАМЕТРЫ. Очевидный способ — уйти на
|
||||
* `/estimate?address=Ленина+5`. Так делать нельзя: адрес квартиры в URL
|
||||
* попадёт в access-логи Caddy (`/var/log/caddy/meraocenka.ru.log`) рядом с IP
|
||||
* посетителя — то есть мы начнём хранить персональные данные ровно там, где
|
||||
* публично обещали ничего не хранить, и до всякого согласия (#2895).
|
||||
* sessionStorage не уходит с машины вообще: ни в запрос, ни в лог, ни в
|
||||
* Referer соседнего сайта. Живёт до закрытия вкладки — ровно столько, сколько
|
||||
* нужно для перехода между двумя страницами.
|
||||
*
|
||||
* Не заводить здесь ничего сверх этих трёх полей: чем больше сюда положить,
|
||||
* тем сильнее соблазн начать хранить черновики «на подольше».
|
||||
*/
|
||||
|
||||
const KEY = "mera:estimate-draft";
|
||||
|
||||
export interface EstimateDraft {
|
||||
address: string;
|
||||
/** Строкой — это ровно то, что было в поле ввода, без домыслов о числе. */
|
||||
rooms?: string;
|
||||
area?: string;
|
||||
city?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Все обращения обёрнуты: в приватном режиме Safari и при выключенном
|
||||
* хранилище `sessionStorage` бросает на записи, а на чтении может вернуть
|
||||
* мусор от постороннего кода. Черновик — удобство; уронить из-за него страницу
|
||||
* нельзя.
|
||||
*/
|
||||
export function saveDraft(draft: EstimateDraft): void {
|
||||
try {
|
||||
window.sessionStorage.setItem(KEY, JSON.stringify(draft));
|
||||
} catch {
|
||||
// Не сохранился — человек просто наберёт адрес заново.
|
||||
}
|
||||
}
|
||||
|
||||
export function takeDraft(): EstimateDraft | null {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(KEY);
|
||||
if (!raw) return null;
|
||||
// Забираем «на вынос»: черновик нужен ровно на один переход. Иначе
|
||||
// возврат на /estimate через неделю в той же вкладке подставил бы чужой
|
||||
// по смыслу адрес.
|
||||
window.sessionStorage.removeItem(KEY);
|
||||
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (typeof parsed !== "object" || parsed === null) return null;
|
||||
const value = parsed as Record<string, unknown>;
|
||||
if (typeof value.address !== "string") return null;
|
||||
|
||||
return {
|
||||
address: value.address,
|
||||
rooms: typeof value.rooms === "string" ? value.rooms : undefined,
|
||||
area: typeof value.area === "string" ? value.area : undefined,
|
||||
city: typeof value.city === "string" ? value.city : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
61
tradein-mvp/frontend/src/app/mera-public/estimate/page.tsx
Normal file
61
tradein-mvp/frontend/src/app/mera-public/estimate/page.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import type { Metadata } from "next";
|
||||
|
||||
import { EstimateFlow } from "../_components/estimate/EstimateFlow";
|
||||
import { EstimateHeader } from "../_components/estimate/EstimateHeader";
|
||||
import { FooterV3 } from "../_components/v3/FooterV3";
|
||||
import { PUBLIC_ESTIMATE_URL } from "../content";
|
||||
import styles from "../landing-v3.module.css";
|
||||
|
||||
/**
|
||||
* Страница проверки квартиры — `meraocenka.ru/estimate`.
|
||||
*
|
||||
* ЗАЧЕМ ОТДЕЛЬНЫМ ЭКРАНОМ. Лэндинг и расчёт решают разные задачи. Лэндинг
|
||||
* объясняет и убеждает, поэтому форма там — витрина: короткая, в окружении
|
||||
* секций, которые за неё же и агитируют. Пришедший считать человек в этом
|
||||
* окружении мешает сам себе: подсказкам адреса нужно место под список,
|
||||
* результату — место рядом с полями, а соседние секции в этот момент только
|
||||
* отвлекают. Отсюда отдельный адрес, на который ведут все кнопки лэндинга.
|
||||
*
|
||||
* Побочный выигрыш — этот URL можно дать человеку напрямую (в переписке, в
|
||||
* рекламе), минуя чтение лэндинга.
|
||||
*
|
||||
* NOINDEX — по той же причине, что и на остальных страницах B2C-дерева:
|
||||
* маркетинговые метрики на лэндинге пока плейсхолдеры из макета (#2904), а
|
||||
* приём оплаты не подключён. Снимать — вместе с ними, одним решением.
|
||||
*/
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Проверить квартиру — МЕРА",
|
||||
description:
|
||||
"Бесплатно посмотреть, сколько похожих квартир продаётся рядом с вашим домом и как долго висят их объявления.",
|
||||
alternates: { canonical: PUBLIC_ESTIMATE_URL },
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function MeraEstimatePage() {
|
||||
return (
|
||||
<>
|
||||
<EstimateHeader />
|
||||
<main id="content" className={styles.estSection} tabIndex={-1}>
|
||||
<div className={styles.estContainer}>
|
||||
<p className={styles.heroResultEyebrow}>БЕСПЛАТНАЯ ПРОВЕРКА</p>
|
||||
<h1 className={styles.estTitle}>Что происходит с ценами рядом с вашим домом</h1>
|
||||
<p className={styles.estLead}>
|
||||
Укажите дом, число комнат и площадь — покажем, сколько похожих
|
||||
квартир сейчас продаётся вокруг и как долго они висят в продаже. Без
|
||||
регистрации, без звонков; введённый адрес мы никуда не сохраняем.
|
||||
</p>
|
||||
|
||||
<EstimateFlow />
|
||||
|
||||
<p className={styles.estDisclaimer}>
|
||||
Это рыночная справка по сопоставимым объектам, а не отчёт об оценке
|
||||
по 135-ФЗ: для банка, суда, опеки или нотариуса нужен отчёт
|
||||
аккредитованного оценщика.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
<FooterV3 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1355,3 +1355,130 @@
|
|||
opacity: 0.72;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
* Страница проверки квартиры (/estimate) — префикс est.
|
||||
* Секция = estimate/page.tsx + _components/estimate/EstimateFlow.tsx.
|
||||
*
|
||||
* Переиспользует поля и плитки результата из блока «Герой» (.hero*): это те же
|
||||
* элементы того же макета, и разводить вторую копию их оформления значило бы
|
||||
* завести два места, где живёт вид одного и того же поля ввода. Здесь —
|
||||
* только то, чего в герое нет: шапка страницы, выпадашка автокомплита и
|
||||
* блок платного шага.
|
||||
* ========================================================================== */
|
||||
|
||||
.estSection {
|
||||
background: var(--b2c-page-bg);
|
||||
padding-block: clamp(28px, 5vw, 64px) clamp(48px, 8vw, 96px);
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.estContainer {
|
||||
max-width: 760px;
|
||||
margin-inline: auto;
|
||||
padding-inline: clamp(20px, 5vw, 56px);
|
||||
}
|
||||
|
||||
.estTitle {
|
||||
margin: 10px 0 0;
|
||||
font-size: clamp(28px, 4.4vw, 44px);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.025em;
|
||||
font-weight: 800;
|
||||
color: var(--b2c-ink);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.estLead {
|
||||
margin: 14px 0 0;
|
||||
font-size: clamp(15px, 1.6vw, 17px);
|
||||
line-height: 1.65;
|
||||
color: var(--b2c-muted);
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
.estCard {
|
||||
margin-top: clamp(24px, 4vw, 36px);
|
||||
background: var(--b2c-surface);
|
||||
border: 1px solid var(--b2c-line);
|
||||
border-radius: 14px;
|
||||
padding: clamp(18px, 3vw, 28px);
|
||||
box-shadow: 0 24px 64px color-mix(in srgb, var(--b2c-ink) 8%, transparent);
|
||||
}
|
||||
|
||||
.estDisclaimer {
|
||||
margin-top: clamp(20px, 3vw, 28px);
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--b2c-muted);
|
||||
}
|
||||
|
||||
/* --- Автокомплит ---------------------------------------------------------
|
||||
* Выпадашка позиционируется абсолютно: список, раздвигающий форму, смещает
|
||||
* поля под курсором на каждое нажатие клавиши — попасть мышью в подсказку
|
||||
* становится нельзя. */
|
||||
.estAutocomplete {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.estSuggestList {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
inset-inline: 0;
|
||||
top: 100%;
|
||||
margin: 4px 0 0;
|
||||
padding: 4px;
|
||||
list-style: none;
|
||||
background: var(--b2c-surface);
|
||||
border: 1px solid var(--b2c-line);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 18px 40px color-mix(in srgb, var(--b2c-ink) 14%, transparent);
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.estSuggestItem {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
padding: 9px 11px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: var(--b2c-ink);
|
||||
}
|
||||
|
||||
.estSuggestItem:hover,
|
||||
.estSuggestItemActive {
|
||||
background: var(--b2c-surface-soft);
|
||||
}
|
||||
|
||||
.estSuggestMain {
|
||||
display: block;
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.estSuggestSub {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 12.5px;
|
||||
color: var(--b2c-muted);
|
||||
}
|
||||
|
||||
/* --- Результат ----------------------------------------------------------- */
|
||||
.estResult {
|
||||
margin-top: clamp(18px, 3vw, 26px);
|
||||
padding-top: clamp(18px, 3vw, 24px);
|
||||
border-top: 1px solid var(--b2c-line);
|
||||
}
|
||||
|
||||
.estPaidStep {
|
||||
margin-top: 18px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid var(--b2c-line);
|
||||
border-radius: 12px;
|
||||
background: var(--b2c-surface-soft);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { IBM_Plex_Mono, Manrope } from "next/font/google";
|
|||
|
||||
import { REGION_NAME } from "./content";
|
||||
import styles from "./landing.module.css";
|
||||
import { landingVars } from "./theme";
|
||||
import { b2cVars, landingVars } from "./theme";
|
||||
|
||||
/**
|
||||
* ШАПКА И ПОДВАЛ ЖИВУТ В СТРАНИЦАХ, А НЕ ЗДЕСЬ (15.08.2026). Раньше layout
|
||||
|
|
@ -76,7 +76,7 @@ export default function MeraPublicLayout({
|
|||
return (
|
||||
<div
|
||||
className={`${manrope.variable} ${plexMono.variable} ${styles.page}`}
|
||||
style={landingVars}
|
||||
style={{ ...landingVars, ...b2cVars }}
|
||||
>
|
||||
{/* Цель ссылки — <main id="content"> с tabIndex={-1} (см. page.tsx и
|
||||
privacy/page.tsx). Без tabIndex Safari/VoiceOver не переносит фокус на
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
LEGAL_DOCS_REVISION,
|
||||
LEGAL_ENTITY,
|
||||
PRIVACY_PATH,
|
||||
PUBLIC_OFFER_URL,
|
||||
PUBLIC_REFUND_URL,
|
||||
REFUND_PATH,
|
||||
SERVICE_PRICE_RUB,
|
||||
SUPPORT_EMAIL,
|
||||
} from "../content";
|
||||
import styles from "../landing.module.css";
|
||||
|
||||
import { PublicLink } from "../_components/PublicLink";
|
||||
|
||||
import { SiteFooter } from "../_components/SiteFooter";
|
||||
import { SiteHeader } from "../_components/SiteHeader";
|
||||
|
||||
|
|
@ -62,12 +61,12 @@ export default function MeraPublicOfferPage() {
|
|||
<SiteHeader />
|
||||
<main id="content" className={styles.main} tabIndex={-1}>
|
||||
<div className={`${styles.container} ${styles.doc}`}>
|
||||
<Link
|
||||
<PublicLink
|
||||
className={`${styles.link} ${styles.docBack}`}
|
||||
href="/mera-public"
|
||||
to="home"
|
||||
>
|
||||
← На главную
|
||||
</Link>
|
||||
</PublicLink>
|
||||
|
||||
<h1 className={styles.h2}>Публичная оферта</h1>
|
||||
<p className={styles.docMeta}>
|
||||
|
|
@ -315,9 +314,9 @@ export default function MeraPublicOfferPage() {
|
|||
<p>
|
||||
8.0. Условия настоящего раздела дублируются и подробно раскрыты в
|
||||
отдельном документе —{" "}
|
||||
<Link className={styles.link} href={REFUND_PATH}>
|
||||
<PublicLink className={styles.link} to="refund">
|
||||
Политике возврата денежных средств
|
||||
</Link>
|
||||
</PublicLink>
|
||||
, размещённой по адресу {PUBLIC_REFUND_URL}. В случае расхождений
|
||||
между настоящим разделом и указанным документом применяется
|
||||
настоящая Оферта.
|
||||
|
|
@ -380,9 +379,9 @@ export default function MeraPublicOfferPage() {
|
|||
<p>
|
||||
9.1. Обработка персональных данных Заказчика осуществляется
|
||||
Исполнителем в соответствии с{" "}
|
||||
<Link className={styles.link} href={PRIVACY_PATH}>
|
||||
<PublicLink className={styles.link} to="privacy">
|
||||
Политикой обработки персональных данных
|
||||
</Link>
|
||||
</PublicLink>
|
||||
, размещённой на Сайте и являющейся неотъемлемой частью настоящей
|
||||
Оферты.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
LEGAL_ENTITY,
|
||||
|
|
@ -12,6 +11,8 @@ import {
|
|||
SUPPORT_TELEGRAM_URL,
|
||||
} from "../content";
|
||||
import styles from "../landing.module.css";
|
||||
|
||||
import { PublicLink } from "../_components/PublicLink";
|
||||
import { safeUrl } from "@/lib/safeUrl";
|
||||
|
||||
import { SiteFooter } from "../_components/SiteFooter";
|
||||
|
|
@ -29,9 +30,19 @@ import { SiteHeader } from "../_components/SiteHeader";
|
|||
* стала неудобной, а потому, что перестала быть правдой.
|
||||
*
|
||||
* ЧТО ОСТАЛОСЬ ОТ ПРЕЖНЕЙ СТРАНИЦЫ — правило честности:
|
||||
* - Раздел про страницу ввода адреса УСЛОВЕН по `PUBLIC_ESTIMATE_ENABLED`.
|
||||
* Пока публичный расчёт выключен, адрес физически не покидает браузер, и
|
||||
* писать обратное нельзя; текст меняется вместе с флагом, а не потом.
|
||||
* - Раздел про страницу ввода адреса переписан 15.08.2026 ВМЕСТЕ с кодом.
|
||||
* Прежний п. 5.4 утверждал, что введённый адрес не покидает браузер, — и
|
||||
* это было правдой, пока форма ничего не отправляла. С открытием
|
||||
* бесплатной проверки (`/estimate` → `/api/public/mera/*`) адрес уходит на
|
||||
* сервер, поэтому утверждение заменено на точное: передаётся, используется
|
||||
* однократно, в базах не сохраняется. Последнее проверено по коду, а не
|
||||
* предположено: `suggest()` работает без кэша (докстринг
|
||||
* `services/geocoder.py`), проба покрытия — один SELECT, а `RequestAudit`
|
||||
* пишет строку только при наличии username, которого у анонима нет; в
|
||||
* любом случае адрес едет телом запроса и в аудит не попадает.
|
||||
* - Пункт про ПЛАТНЫЙ расчёт остался условным по `PUBLIC_ESTIMATE_ENABLED`:
|
||||
* вот он действительно хранит результат, и его текст обязан меняться
|
||||
* вместе с флагом, а не после.
|
||||
* - Срок хранения оплаченного отчёта рендерится из
|
||||
* `PAID_REPORT_RETENTION_MONTHS`, а НЕ числом, вписанным в текст, — это
|
||||
* гейтится тестом `test_paid_retention_text_consistency.py` (бэкендовая
|
||||
|
|
@ -65,12 +76,12 @@ export default function MeraPublicPrivacyPage() {
|
|||
<SiteHeader />
|
||||
<main id="content" className={styles.main} tabIndex={-1}>
|
||||
<div className={`${styles.container} ${styles.doc}`}>
|
||||
<Link
|
||||
<PublicLink
|
||||
className={`${styles.link} ${styles.docBack}`}
|
||||
href="/mera-public"
|
||||
to="home"
|
||||
>
|
||||
← На главную
|
||||
</Link>
|
||||
</PublicLink>
|
||||
|
||||
<h1 className={styles.h2}>
|
||||
Политика обработки персональных данных
|
||||
|
|
@ -197,13 +208,22 @@ export default function MeraPublicPrivacyPage() {
|
|||
сервисов; статические ресурсы страницы (шрифты и т. п.) подключаются с
|
||||
домена Оператора.
|
||||
</p>
|
||||
<p>
|
||||
5.4. Бесплатная проверка по адресу выполняется без регистрации и без
|
||||
оплаты. Введённый адрес передаётся на сервер Оператора — без этого
|
||||
подобрать сопоставимые объекты невозможно, — используется однократно
|
||||
для формирования подсказок адреса и поиска сопоставимых объявлений и
|
||||
не сохраняется: указанные операции выполняются в режиме чтения и не
|
||||
создают записей об адресе в базах данных Оператора. Адрес передаётся
|
||||
телом запроса и не записывается в журналы веб-сервера. Для
|
||||
формирования подсказок адрес может передаваться внешним сервисам
|
||||
геокодирования.
|
||||
</p>
|
||||
{PUBLIC_ESTIMATE_ENABLED ? null : (
|
||||
<p>
|
||||
5.4. На сегодняшний день публичный расчёт по адресу на Сайте ещё не
|
||||
открыт: форма адреса на главной странице работает только в браузере
|
||||
пользователя — введённый адрес не передаётся на сервер Оператора и
|
||||
нигде не сохраняется. Пункты 5.1–5.2 применяются с момента открытия
|
||||
публичного расчёта.
|
||||
5.5. Платный расчёт по адресу на Сайте на сегодняшний день ещё не
|
||||
открыт. Пункты 5.1–5.2 в части хранения результата расчёта
|
||||
применяются с момента его открытия.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
|
|||
150
tradein-mvp/frontend/src/app/mera-public/public-api.ts
Normal file
150
tradein-mvp/frontend/src/app/mera-public/public-api.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* Клиент публичного (анонимного) API МЕРЫ — единственный модуль лэндинга,
|
||||
* который вообще ходит в сеть.
|
||||
*
|
||||
* ПОЧЕМУ НЕ `@/lib/api`. Тот модуль — часть закрытого контура: тянет
|
||||
* `sessionId`, обработку 401 и редирект на /login. Публичное дерево
|
||||
* `app/mera-public/**` держится пустым по зависимостям (guard —
|
||||
* `scripts/check-mera-public-isolation.mjs`), потому что root-layout
|
||||
* оборачивает в `RouteGuard` в том числе и лэндинг: всё, что импортировано
|
||||
* здесь статически, webpack кладёт в чанк, который скачивает анонимный
|
||||
* посетитель. Поэтому — свой тонкий `fetch` без единого импорта.
|
||||
*
|
||||
* ПОЧЕМУ ПУТЬ С `/trade-in`, ХОТЯ ДОМЕН ПУБЛИЧНЫЙ. Один и тот же URL обязан
|
||||
* работать и на `meraocenka.ru/`, и на `gendsgn.ru/trade-in/mera-public`
|
||||
* (оттуда страницу открывают для QA). На gendsgn.ru корневой `/api/*` занят
|
||||
* бэкендом Site Finder, поэтому короткий `/api/public/*` потребовал бы там
|
||||
* второго Caddy-handle, выигрывающего по специфичности у существующего — то
|
||||
* есть работоспособность формы зависела бы от порядка сортировки матчеров в
|
||||
* чужом site-блоке. С префиксом `/trade-in` запрос ловит уже существующий
|
||||
* `handle /trade-in/api/*`, а на публичном домене заведён ровно один новый.
|
||||
* Разбор периметра — `backend/app/api/public/mera.py`.
|
||||
*
|
||||
* NB: это не `basePath` Next'а. `fetch` его не подставляет — префикс здесь
|
||||
* настоящая часть URL, и хардкодить её приходится осознанно.
|
||||
*/
|
||||
|
||||
const API_BASE = "/trade-in/api/public/mera";
|
||||
|
||||
/** Кандидат автокомплита. Поля — как в `SuggestItem` бэкенда. */
|
||||
export interface AddressSuggestion {
|
||||
label: string;
|
||||
full_address: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
kind: string;
|
||||
fias_id: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ответ пробы покрытия. Зеркалит `CoverageProbeResponse` (backend
|
||||
* app/schemas/trade_in.py) — с той же оговоркой про честность имён:
|
||||
*
|
||||
* `median_listing_age_days` — возраст АКТИВНОГО объявления, а не срок продажи.
|
||||
* Выборка цензурирована (эти квартиры ещё продаются), поэтому называть её
|
||||
* «за сколько продаются» нельзя. Бэкенд принудительно отдаёт null, если
|
||||
* возраст известен меньше чем по пяти объявлениям, — UI обязан этот null
|
||||
* пережить и просто не показывать плитку, а не рисовать «0 дней».
|
||||
*/
|
||||
export interface CoverageProbe {
|
||||
status: "ok" | "thin" | "not_covered";
|
||||
n_listings: number;
|
||||
median_listing_age_days: number | null;
|
||||
n_with_age: number;
|
||||
radius_m: number;
|
||||
city: string;
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export interface CoverageQuery {
|
||||
lat: number;
|
||||
lon: number;
|
||||
rooms: number;
|
||||
area_m2: number;
|
||||
}
|
||||
|
||||
/** Почему запрос не удался — от этого зависит текст, который увидит человек. */
|
||||
export type ApiFailureKind = "rate-limited" | "offline" | "server";
|
||||
|
||||
export class PublicApiError extends Error {
|
||||
readonly kind: ApiFailureKind;
|
||||
/** Сколько секунд ждать (только для `rate-limited`). */
|
||||
readonly retryAfterS: number | null;
|
||||
|
||||
constructor(kind: ApiFailureKind, message: string, retryAfterS: number | null = null) {
|
||||
super(message);
|
||||
this.name = "PublicApiError";
|
||||
this.kind = kind;
|
||||
this.retryAfterS = retryAfterS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Единая обработка ответа. 429 отделён от прочих ошибок намеренно: «слишком
|
||||
* часто, подождите минуту» — это инструкция, которую человек может выполнить,
|
||||
* а «что-то пошло не так» на том же месте выглядит поломкой сервиса и уводит
|
||||
* его со страницы.
|
||||
*/
|
||||
async function parse<T>(res: Response): Promise<T> {
|
||||
if (res.status === 429) {
|
||||
const header = res.headers.get("Retry-After");
|
||||
const seconds = header ? Number.parseInt(header, 10) : Number.NaN;
|
||||
throw new PublicApiError(
|
||||
"rate-limited",
|
||||
"Слишком много запросов подряд.",
|
||||
Number.isFinite(seconds) ? seconds : null,
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new PublicApiError("server", `Сервис ответил ошибкой (${res.status}).`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
/** `fetch`, который бросает наш тип и на сетевом сбое тоже. */
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, init);
|
||||
} catch (cause) {
|
||||
// AbortError — это не сбой, а наш же отменённый предыдущий запрос:
|
||||
// пробрасываем как есть, вызывающий код его молча глотает.
|
||||
if (cause instanceof DOMException && cause.name === "AbortError") {
|
||||
throw cause;
|
||||
}
|
||||
throw new PublicApiError("offline", "Не получилось связаться с сервисом.");
|
||||
}
|
||||
return parse<T>(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Подсказки адреса. POST у читающей ручки — намеренно: на публичном домене
|
||||
* включён access-лог Caddy, и он пишет URI целиком, поэтому `?q=Малышева+51`
|
||||
* сохранил бы адрес квартиры в файл рядом с IP посетителя. На странице мы
|
||||
* обещаем обратное. Тело в лог не попадает. Тот же довод, что у черновика с
|
||||
* лэндинга (`estimate-draft.ts` — sessionStorage вместо query-параметров).
|
||||
*/
|
||||
export async function fetchAddressSuggestions(
|
||||
query: string,
|
||||
options: { cityHint?: string; signal?: AbortSignal } = {},
|
||||
): Promise<AddressSuggestion[]> {
|
||||
const data = await request<{ items: AddressSuggestion[] }>(`${API_BASE}/suggest`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ q: query, city_hint: options.cityHint ?? null }),
|
||||
signal: options.signal,
|
||||
});
|
||||
return data.items;
|
||||
}
|
||||
|
||||
export async function fetchCoverage(
|
||||
query: CoverageQuery,
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<CoverageProbe> {
|
||||
return request<CoverageProbe>(`${API_BASE}/coverage`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(query),
|
||||
signal: options.signal,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
LEGAL_DOCS_REVISION,
|
||||
LEGAL_ENTITY,
|
||||
OFFER_PATH,
|
||||
PUBLIC_OFFER_URL,
|
||||
PUBLIC_REFUND_URL,
|
||||
SERVICE_PRICE_RUB,
|
||||
|
|
@ -12,6 +10,8 @@ import {
|
|||
} from "../content";
|
||||
import styles from "../landing.module.css";
|
||||
|
||||
import { PublicLink } from "../_components/PublicLink";
|
||||
|
||||
import { SiteFooter } from "../_components/SiteFooter";
|
||||
import { SiteHeader } from "../_components/SiteHeader";
|
||||
|
||||
|
|
@ -53,12 +53,12 @@ export default function MeraPublicRefundPage() {
|
|||
<SiteHeader />
|
||||
<main id="content" className={styles.main} tabIndex={-1}>
|
||||
<div className={`${styles.container} ${styles.doc}`}>
|
||||
<Link
|
||||
<PublicLink
|
||||
className={`${styles.link} ${styles.docBack}`}
|
||||
href="/mera-public"
|
||||
to="home"
|
||||
>
|
||||
← На главную
|
||||
</Link>
|
||||
</PublicLink>
|
||||
|
||||
<h1 className={styles.h2}>Политика возврата денежных средств</h1>
|
||||
<p className={styles.docMeta}>
|
||||
|
|
@ -75,9 +75,9 @@ export default function MeraPublicRefundPage() {
|
|||
<p>
|
||||
1.2. Настоящая Политика размещена в свободном доступе в сети Интернет
|
||||
по адресу {PUBLIC_REFUND_URL} и является неотъемлемой частью{" "}
|
||||
<Link className={styles.link} href={OFFER_PATH}>
|
||||
<PublicLink className={styles.link} to="oferta">
|
||||
Публичной оферты
|
||||
</Link>
|
||||
</PublicLink>
|
||||
, размещённой по адресу {PUBLIC_OFFER_URL}. Оферта и настоящая
|
||||
Политика применяются совместно; в случае противоречий между ними
|
||||
приоритет имеет Оферта.
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import type { CSSProperties } from "react";
|
|||
|
||||
import { tokens } from "@/components/trade-in/v2/tokens";
|
||||
|
||||
import { b2c } from "./b2c-tokens";
|
||||
|
||||
/**
|
||||
* Набор CSS-переменных лэндинга. Приводится к CSSProperties: TS не знает про
|
||||
* произвольные `--*` ключи, но React их корректно проставляет в style.
|
||||
|
|
@ -63,3 +65,49 @@ export const landingVars = {
|
|||
"--m-font-sans": tokens.font.sans,
|
||||
"--m-font-mono": tokens.font.mono,
|
||||
} as CSSProperties;
|
||||
|
||||
/**
|
||||
* Переменные палитры v3 (`--b2c-*`) для `landing-v3.module.css`.
|
||||
*
|
||||
* ЧЕГО ЗДЕСЬ НЕ ХВАТАЛО. `b2c-tokens.ts` завёл палитру макета и запретил hex в
|
||||
* CSS — но моста между ними не появилось: файл не импортировал НИКТО, ни одна
|
||||
* `--b2c-*` переменная нигде не объявлялась, и `var(--b2c-page-bg)` на живой
|
||||
* странице резолвился в пустоту. Каскад при этом не падает — он просто
|
||||
* пропускает такое объявление, поэтому страница отдавала 200, проходила
|
||||
* сборку и выглядела при этом бесцветной: карточки без фона и рамок, кнопки
|
||||
* без заливки, акцент отсутствует (замер на
|
||||
* `meraocenka.ru/trade-in/mera-public/v3`, 15.08.2026).
|
||||
*
|
||||
* Отсюда правило на будущее: TS-файл токенов, который никто не импортирует, —
|
||||
* не источник правды, а мёртвый код. Единственное, что делает палитру живой,
|
||||
* это вот такой мост, применённый к корневому узлу.
|
||||
*
|
||||
* Оба набора (`--m-*` и `--b2c-*`) вешаются на ОДИН и тот же div лэндинга:
|
||||
* v1-страницы читают первый, v3 — второй, и лишние объявления ничего не
|
||||
* стоят. Разделять их по разным обёрткам значило бы городить два layout'а
|
||||
* ради экономии полутора десятков строк в атрибуте style.
|
||||
*/
|
||||
export const b2cVars = {
|
||||
"--b2c-page-bg": b2c.pageBg,
|
||||
"--b2c-surface": b2c.surface,
|
||||
"--b2c-surface-soft": b2c.surfaceSoft,
|
||||
"--b2c-surface-dark": b2c.surfaceDark,
|
||||
|
||||
"--b2c-ink": b2c.ink,
|
||||
"--b2c-muted": b2c.muted,
|
||||
|
||||
"--b2c-line": b2c.line,
|
||||
"--b2c-line-soft": b2c.lineSoft,
|
||||
|
||||
// accent — только заливки/обводки; текст красится accentText (5.8:1).
|
||||
// Разбор контраста — в шапке b2c-tokens.ts.
|
||||
"--b2c-accent": b2c.accent,
|
||||
"--b2c-accent-deep": b2c.accentDeep,
|
||||
"--b2c-accent-text": b2c.accentText,
|
||||
"--b2c-on-accent": b2c.onAccent,
|
||||
|
||||
"--b2c-danger": b2c.danger,
|
||||
|
||||
"--b2c-font-sans": b2c.font.sans,
|
||||
"--b2c-font-mono": b2c.font.mono,
|
||||
} as CSSProperties;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue