From 6ac4ad9867c01f60df39d0c602c4c06002f4182e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 1 Aug 2026 23:01:19 +0300 Subject: [PATCH] =?UTF-8?q?feat(mera):=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D1=87=D0=BD=D1=8B=D0=B9=20=D0=BB=D1=8D=D0=BD=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20=C2=AB=D0=9C=D0=95=D0=A0=D0=90=C2=BB=20=E2=80=94=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BD=D1=82=20=D0=B8=20=D0=B2?= =?UTF-8?q?=D1=91=D1=80=D1=81=D1=82=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Только фронт. Caddyfile, DNS и бэкенд не тронуты: периметр и домен meraocenka.ru делаются отдельным PR, чтобы горячий Caddyfile не менялся параллельно с эпиком «единый вход». Заменяет заглушку из feat/mera-b2c-perimeter (48 строк «скоро откроется») на полноценную страницу: первый экран, как это работает, что человек получает, откуда данные, вопросы-ответы, подвал и страница обработки ПДн. ## Починен живой баг, из-за которого лэндинг уводил бы людей на форму входа RouteGuard регистрировал useEffect с router.push('/login') ДО early-return для публичных путей. Хуки выполняются всегда, поэтому на проде аноним на /mera-public получал фоновый GET /api/v1/me → 401 → редирект на логин, и «безусловный bypass» до этого просто не доходил. Guard разделён: RouteGuard теперь только смотрит pathname, а весь закрытый контур (useMe, RBAC, экраны отказа) вынесен в GuardedRoute и подключён через next/dynamic — это точка разрыва графа импортов, а не только логическая развилка. Проверено на живой странице: запросов к API — НОЛЬ. ## Обещания приведены в соответствие с продуктом Ревью по честности нашло 1 critical и 4 high — всё это обещания, которых продукт не выполняет. Убрано или переписано: - «Отчёт в PDF» — ручка owner-scoped, анониму отдаёт 401 by design; - «список объектов, на которых построен расчёт» — план прямо запрещает показывать анониму сырые объявления конкурентов; - шесть городов подавались как равнозначные, хотя сбор вне Екатеринбурга выключен (миграция 179, enabled=false) и данных единицы. Теперь честно: полное покрытие — Екатеринбург, по области данных меньше; - «в расчёте два типа данных» умалчивало третий — чужие оценочные модели, которые реально двигают итоговую цифру (estimator.py, IMV/Yandex blend); - страница ПДн обещала удаление данных, механизма которого нет. Тексты и правовые формулировки вынесены в content.ts одним местом, рядом с ссылками на код, который их подтверждает. Финальная редакция privacy — за юристом, это помечено в файле. ## Форма адреса — честная заглушка, и это вынужденно Живого автокомплита быть не может: _PUBLIC_PATHS «Меры» (rbac.py) открывает анониму только health/docs/login/logout и анонимный чат. /geocode/suggest и /trade-in/estimate отдают анониму 401. Открывать их до анти-абуза (этап 2 плана B2C) прямо запрещено планом. Форма честно говорит, что произойдёт, и не изображает работу, которой нет. Переключается флагом PUBLIC_ESTIMATE_ENABLED, рядом с ним — гейт из трёх условий. ## Проверено живьём, не по отчёту - 360px и 390px: горизонтального переполнения нет (единственный элемент за экраном — skip-link, так и задумано); - один h1, иерархия H1→H2→H3 без пропусков, landmark-разметка; - внешних ресурсов ноль — ни CDN, ни шрифтов, ни картинок с чужих доменов; - ноль запросов к /api/** со страницы; - tsc --noEmit чист. NB для ревьюера: .claude/rules/ui-*.md по frontmatter paths: матчат frontend/**, то есть корневой фронт Site Finder, а не tradein-mvp/frontend. Здесь применяется дизайн-система v2/tokens.ts. Tailwind в этом фронте нет. --- .../mera-public/_components/AddressForm.tsx | 232 +++++ .../mera-public/_components/DataSources.tsx | 55 ++ .../src/app/mera-public/_components/Faq.tsx | 65 ++ .../src/app/mera-public/_components/Hero.tsx | 87 ++ .../mera-public/_components/HowItWorks.tsx | 46 + .../mera-public/_components/SiteFooter.tsx | 97 ++ .../mera-public/_components/SiteHeader.tsx | 26 + .../mera-public/_components/WhatYouGet.tsx | 93 ++ .../frontend/src/app/mera-public/content.ts | 295 ++++++ .../src/app/mera-public/landing.module.css | 896 ++++++++++++++++++ .../frontend/src/app/mera-public/layout.tsx | 78 ++ .../frontend/src/app/mera-public/page.tsx | 30 + .../src/app/mera-public/privacy/page.tsx | 161 ++++ .../frontend/src/app/mera-public/theme.ts | 65 ++ .../src/components/auth/GuardedRoute.tsx | 142 +++ .../src/components/auth/RouteGuard.tsx | 153 ++- 16 files changed, 2424 insertions(+), 97 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/AddressForm.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/DataSources.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/Faq.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/Hero.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/HowItWorks.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/SiteFooter.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/SiteHeader.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/WhatYouGet.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/content.ts create mode 100644 tradein-mvp/frontend/src/app/mera-public/landing.module.css create mode 100644 tradein-mvp/frontend/src/app/mera-public/layout.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/page.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/theme.ts create mode 100644 tradein-mvp/frontend/src/components/auth/GuardedRoute.tsx diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/AddressForm.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/AddressForm.tsx new file mode 100644 index 00000000..d1eb1e7f --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/AddressForm.tsx @@ -0,0 +1,232 @@ +"use client"; + +/** + * AddressForm — поле адреса на первом экране. + * + * ЧТО ЭТА ФОРМА ДЕЛАЕТ СЕГОДНЯ И ПОЧЕМУ ИМЕННО ТАК + * + * Она не считает цену и не притворяется, что считает. Причина техническая и + * жёсткая: `rbac_guard` (backend/app/core/rbac.py) пропускает анонима только на + * пути из `_PUBLIC_PATHS`, а `/api/v1/geocode/suggest` и + * `/api/v1/trade-in/estimate` туда не входят — любой запрос отсюда вернул бы + * 401. Открытие анонимного периметра — отдельный backend-PR, вне границ этой + * задачи. Поэтому здесь честная валидация на клиенте + прямой ответ «публичный + * расчёт ещё не открыт» вместо фейкового спиннера. + * + * Что форма всё-таки делает по-настоящему: + * - проверяет, что адрес введён; + * - требует явно назвать город и не подставляет Екатеринбург молча. Это ровно + * тот баг, который чинил бэкенд в #2576: житель Нижнего Тагила вводил + * «Ленина, 1» и получал уверенную цену по одноимённой улице в ЕКБ. Правило + * из шапки `lib/city-registry.ts` — город считается известным только если + * пользователь его выбрал ИЛИ `detectCityInText` нашёл его в тексте; + * - если названного города нет в покрытии — мягко и честно говорит про + * Свердловскую область, не обещая «оценим любую квартиру в РФ». + * + * Осознанно НЕ переиспользован автокомплит из закрытого контура + * (ParamsPanel.tsx / AddressInput.tsx): он ходит в `/geocode/suggest` через + * `useGeocodeSuggest`, что для анонима = 401. Тянуть сюда хуки B2B-контура + * (useMe/useQuota/useHistory и соседей) запрещено — публичный экран не должен + * иметь к ним доступа даже теоретически. + * + * Когда бэкенд откроет анонимные ручки: переключить `PUBLIC_ESTIMATE_ENABLED` + * в content.ts и заменить ветку `notLaunched` в `handleSubmit` на реальный + * переход/запрос (комбобокс подсказок — по образцу ParamsPanel.tsx, вместе с + * его клавиатурной моделью и sr-live-регионом). + */ + +import { useId, useRef, useState } from "react"; +import type { FormEvent } from "react"; + +import { detectCityInText } from "@/lib/city-registry"; + +import { + COVERED_CITIES, + PRIMARY_CITY, + PUBLIC_ESTIMATE_ENABLED, + REGION_NAME, + SECONDARY_CITIES, +} from "../content"; +import styles from "../landing.module.css"; + +/** Значение