diff --git a/caddy/sites/apps.caddy b/caddy/sites/apps.caddy index ef58bab2..8010c04b 100644 --- a/caddy/sites/apps.caddy +++ b/caddy/sites/apps.caddy @@ -347,11 +347,7 @@ meraocenka.ru { # `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант # без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на # «/», и запрос закольцуется. - # `/v3` — ВРЕМЕННОЕ превью второго варианта дизайна, а не публичная - # страница: владелец сравнивает его с текущим лэндингом. Оно `noindex` и - # ни с одной страницы на него нет ссылки. Убрать эту строку в тот момент, - # когда вариант выберут и он станет корнем. - @meraPages path /estimate /oferta /refund /privacy /v3 /articles /articles/kak-ocenit-kvartiru /docs + @meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs handle @meraPages { rewrite * /trade-in/mera-public{path} reverse_proxy tradein-frontend:3000 { @@ -365,11 +361,48 @@ meraocenka.ru { # голый 404 (так было и до этого PR, с момента #2615). Заодно это # замыкает цепочку для длинных адресов со слэшем: они приходят на короткий # со слэшем и здесь нормализуются. - @meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|v3|articles|articles/kak-ocenit-kvartiru|docs)/$ + @meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|docs)/$ handle @meraShortSlash { redir * /{re.shortslash.1} permanent } + # `/v3` — АДРЕС ПРЕВЬЮ, КОТОРОГО БОЛЬШЕ НЕТ. С 29.08.2026 выбранный вариант + # дизайна стоит в корне, каталог `v3/` во фронте удалён, и проксировать + # сюда нечего — Next отдал бы 404. Но адрес жил на публичном домене больше + # двух недель: владелец сравнивал по нему версии, и ссылка почти наверняка + # осталась в закладках и в переписке. Поэтому 301 на корень, а не молчание + # белого списка: человек по старой ссылке попадает ровно на то, что по ней + # и смотрел, только теперь это главная. + # + # ЧЕТЫРЕ ФОРМЫ, А НЕ ДВЕ. Превью раздавалось и коротким `/v3`, и длинным + # `/trade-in/mera-public/v3` — вторая пара до 29.08.2026 жила в + # @meraLongPages и оттуда выбыла вместе со страницей. Короткие формы её + # не подхватывают (матчер `path` требует точного совпадения), поэтому + # длинный адрес превью падал в catch-all 404 — тот же класс дефекта, + # ради которого длинные адреса вообще оставлены живыми. Все четыре формы + # ведут в один корень, поэтому и матчер один. + # + # QUERY ПЕРЕНОСИТСЯ — `redir * {uri}`, как в @meraLongPages, а не + # `redir * /`, как было здесь до этой правки. Причина ровно та же, что + # записана у @meraLongPages: по разосланной ссылке приходят с UTM-метками, + # и `/v3?utm=…` не должен терять атрибуцию на редиректе. Оба + # `uri strip_prefix` срезают путь целиком (для `/v3` — до пустого, для + # `/v3/` — до `/`), а `{uri}` при пустом пути даёт `/`, поэтому запрос без + # параметров отдаёт чистый `Location: /` без хвоста `?`. Обёртка `route` + # обязательна по той же причине, что у @meraLongPages: внутри `handle` + # порядок директив определяет Caddy, и без неё `redir` выполнится раньше + # `uri`. Проверено на живом Caddy 2.11 (прод — `caddy:2`): все четыре формы + # с параметрами и без дают 301 на `/` с сохранённой query, а лишний + # сегмент (`/trade-in/mera-public/v3/evil`) по-прежнему падает в 404. + @meraV3Gone path /v3 /v3/ /trade-in/mera-public/v3 /trade-in/mera-public/v3/ + handle @meraV3Gone { + route { + uri strip_prefix /trade-in/mera-public + uri strip_prefix /v3 + redir * {uri} permanent + } + } + # Длинные адреса поддерева → 301 на короткие. Один канонический адрес у # страницы, а не два работающих. # @@ -413,7 +446,7 @@ meraocenka.ru { # порядок директив внутри `handle` определяет Caddy, и без неё `redir` # выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу # (бесконечный цикл; поймано на локальном стенде). - @meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/v3 /trade-in/mera-public/v3/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/docs /trade-in/mera-public/docs/ + @meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/docs /trade-in/mera-public/docs/ handle @meraLongPages { route { uri strip_prefix /trade-in/mera-public diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts index e75875c6..6a081c4d 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts @@ -48,6 +48,21 @@ describe("черновик", () => { expect(takeDraft()).toBeNull(); }); + it("доносит всё, что человек ввёл на лэндинге", () => { + // Город — гео-гейт (#2576), состояние — обязательное поле карточки v3: + // спросить и выбросить хуже, чем донести до платного шага. + saveDraft({ + address: "Ленина 5", + rooms: "2", + area: "54", + city: "Нижний Тагил", + condition: "Хорошее", + }); + const draft = takeDraft(); + expect(draft?.city).toBe("Нижний Тагил"); + expect(draft?.condition).toBe("Хорошее"); + }); + it("не падает на мусоре в хранилище", () => { window.sessionStorage.setItem("mera:estimate-draft", "{это не json"); expect(takeDraft()).toBeNull(); diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/marketing-placeholders-gate.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/marketing-placeholders-gate.test.ts index 272b4390..db5ad391 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/marketing-placeholders-gate.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/marketing-placeholders-gate.test.ts @@ -34,9 +34,21 @@ function read(rel: string): string { return readFileSync(join(ROOT, rel), "utf8"); } -/** Компоненты лэндинга v3, которые могут ссылаться на плейсхолдеры. */ +/** + * Компоненты лэндинга v3, которые могут ссылаться на плейсхолдеры. + * + * САМОЙ СТРАНИЦЫ (`page.tsx`) ЗДЕСЬ НЕТ, И ЭТО НЕ ПРОПУСК. 29.08.2026 лэндинг + * переехал из `v3/page.tsx` в корень, и путь в этом списке обновили вслед за + * файлом — с обоснованием «иначе строка перестала бы что-либо сканировать». + * Обоснование звучало как проверенный факт, но проверено не было: ни в + * `page.tsx`, ни в прежнем `v3/page.tsx` нет ни одного имени из + * `PLACEHOLDER_EXPORTS` — страница только собирает секции из импортов, а числа + * живут в самих секциях. То есть строка не давала покрытия ни до переезда, ни + * после, и держать её значило держать ложное ощущение охвата. Появится величина + * прямо в разметке страницы — строку сюда вернуть (и тогда она будет что-то + * значить). + */ const V3_SOURCES = [ - "v3/page.tsx", "_components/v3/HeroV3.tsx", "_components/v3/AccuracyV3.tsx", "_components/v3/FreeCheckCard.tsx", diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts index d7150cbc..e356272d 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts @@ -92,11 +92,22 @@ describe("короткие адреса публичного домена", () = }); /** - * Разрешённый «лишний» путь: временное превью второго варианта дизайна. - * Ссылок на него нет, страница noindex; строка удаляется вместе с выбором - * варианта. Держим здесь, чтобы проверка ниже была ДВУСТОРОННЕЙ. + * Раньше здесь стоял список PREVIEW_ONLY = ["/v3"] — путь, открытый наружу + * сверх PUBLIC_ROUTES, ради превью второго варианта дизайна. 29.08.2026 + * вариант выбран и стал корнем, каталог `v3/` во фронте удалён, поэтому + * список пуст: наружу не открыто ничего сверх известных страниц. + * + * Пустая константа, а не удалённая: она несёт формат, в котором такое + * исключение вообще допустимо (поимённо, с объяснением, в коде), и держит + * проверку ниже ДВУСТОРОННЕЙ. Заведётся следующее превью — строка вернётся + * сюда, а не тихо в Caddyfile. + * + * Сам `/v3` при этом не исчез с домена: он стал 301 на корень (проверка + * «адрес выбывшего превью остаётся живым» ниже). Редирект живёт в другом + * матчере, `@meraV3Gone`, и в @meraPages его быть не должно — путь, который + * проксируется во фронт, отдал бы 404. */ - const PREVIEW_ONLY = ["/v3"]; + const PREVIEW_ONLY: string[] = []; const meraPagesLine = meraBlock.match(/@meraPages path ([^\n]+)/); @@ -173,6 +184,57 @@ describe("короткие адреса публичного домена", () = ).toBe(true); } }); + + /** + * ВЫБЫВШЕЕ ПРЕВЬЮ ОБЯЗАНО ОСТАТЬСЯ ЖИВЫМ АДРЕСОМ. `/v3` две недели был + * открыт на публичном домене — владелец сравнивал по нему варианты дизайна, + * и ссылка разошлась по закладкам и переписке. 29.08.2026 вариант стал + * корнем, каталог `v3/` во фронте удалён; проксировать этот путь больше + * нельзя (Next отдаст 404), но и просто выкинуть его из белого списка + * нельзя — это тот же класс дефекта, ради которого заведены проверки выше: + * рабочая ссылка молча превращается в 404. + * + * Правильный ответ ровно один — 301 на корень, и он проверяется здесь, а не + * держится на памяти о том, почему в конфиге появился ещё один матчер. + * + * ФОРМ ЧЕТЫРЕ, А НЕ ДВЕ. Превью раздавалось и коротким `/v3`, и длинным + * `/trade-in/mera-public/v3`; вторая пара выбыла из @meraLongPages вместе со + * страницей и никуда не попала — длинный адрес превью отдавал 404, ровно тот + * же дефект, что проверка выше ловит для страниц. + */ + it("адрес выбывшего превью /v3 отдаёт 301 на корень, а не 404", () => { + const matcher = meraBlock.match(/@meraV3Gone path ([^\n]+)/)?.[1] ?? ""; + const paths = matcher.split(/\s+/).filter(Boolean); + expect( + paths, + "нет матчера @meraV3Gone — старые ссылки на превью упадут в catch-all 404", + ).toEqual([ + "/v3", + "/v3/", + "/trade-in/mera-public/v3", + "/trade-in/mera-public/v3/", + ]); + + // Тело `handle` содержит вложенный `route { … }`, поэтому берём до + // закрывающей скобки НА УРОВНЕ handle (четыре пробела), а не до первой. + const handler = + meraBlock.match(/handle @meraV3Gone \{([\s\S]*?)\n {4}\}/)?.[1] ?? ""; + expect( + handler, + "матчер есть, а редиректа в нём нет — зонд выше прошёл бы вхолостую", + ).toMatch(/redir\s+\*\s+\{uri\}\s+permanent/); + /** + * Query обязана переноситься — иначе `/v3?utm=…` теряет атрибуцию у уже + * разосланных ссылок. Один `redir * {uri}` этого не гарантирует: без + * срезания пути он вернул бы Location, равный исходному адресу (цикл). + * Поэтому проверяется вся связка, а не только форма редиректа. + */ + expect( + handler, + "путь не срезается — {uri} вернёт исходный адрес, редирект закольцуется", + ).toContain("uri strip_prefix /v3"); + expect(handler).toContain("uri strip_prefix /trade-in/mera-public"); + }); }); describe("basePath не протекает в публичные ссылки", () => { diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/v3-ported-copy.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/v3-ported-copy.test.ts new file mode 100644 index 00000000..9035fb13 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/v3-ported-copy.test.ts @@ -0,0 +1,131 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { FAQ } from "../content"; + +/** + * Гейт «при переключении корня на v3 ничего не потерялось». + * + * Новый дизайн жил на /v3 и 29.08.2026 стал корнем meraocenka.ru. Перед этим + * разведка нашла блоки лэндинга v1, которых в v3 не было вовсе: реестр + * источников, «что вы получите» с дисклеймером 135-ФЗ, плашка покрытия и + * строка «мы не покупаем квартиры» в герое, вопросы FAQ, город в форме. + * Они перенесены — здесь проверяется, что перенос ЖИВОЙ: секции подключены к + * странице, а компоненты читают те же константы, а не копии строк. + * + * Проверки читают исходники с диска — тот же приём, что в + * `public-perimeter.test.ts` и `marketing-placeholders-gate.test.ts`: ломается + * здесь связь между файлами (страница ↔ секция, секция ↔ content.ts), а её + * юнит-тест на модуле не видит. Удалённая из вёрстки секция собирается, + * тайпчек молчит, и заметить это может только человек на живой странице. + */ + +const MERA_PUBLIC = path.resolve(__dirname, ".."); + +function read(rel: string): string { + return readFileSync(path.join(MERA_PUBLIC, rel), "utf8"); +} + +describe("страница v3 рендерит перенесённые секции", () => { + const page = read("page.tsx"); + + it.each(["SourcesV3", "WhatYouGetV3"])("<%s /> подключён", (name) => { + expect(page).toContain(`<${name} />`); + expect(page).toContain(`_components/v3/${name}`); + }); +}); + +describe("секции читают константы, а не копии строк", () => { + it("реестр источников выводится из SOURCE_GROUPS", () => { + expect(read("_components/v3/SourcesV3.tsx")).toContain( + 'SOURCE_GROUPS } from "../../content"', + ); + }); + + it("«что вы получите» несёт дисклеймер 135-ФЗ рядом со списком", () => { + const src = read("_components/v3/WhatYouGetV3.tsx"); + expect(src).toContain("DELIVERABLES"); + expect( + src, + "дисклеймер обязан стоять рядом со списком того, что человек получает", + ).toContain("DELIVERABLES_DISCLAIMER"); + }); + + it("герой называет границу покрытия и то, чем сервис не является", () => { + const hero = read("_components/v3/HeroV3.tsx"); + expect(hero).toContain("SECONDARY_CITIES"); + expect(hero).toContain("не покупаем квартиры и не берём их на продажу"); + }); + + it("карточка проверки спрашивает город и не додумывает его", () => { + const card = read("_components/v3/FreeCheckCard.tsx"); + // #2576: без города «Ленина, 1» давало уверенную цену по одноимённой улице + // в Екатеринбурге жителю Нижнего Тагила. + expect(card).toContain("COVERED_CITIES"); + expect(card).toContain("detectCityInText"); + // Состояние спрашивается обязательным полем — значит обязано доехать. + expect(card).toMatch(/saveDraft\([^)]*condition/s); + }); +}); + +describe("КАЖДЫЙ вопрос FAQ живёт в возражениях v3", () => { + const objections = read("_components/v3/ObjectionsV3.tsx"); + + /** + * Список вопросов НЕ ЗАХАРДКОЖЕН — он перебирается из самого `FAQ`. В этом + * вся разница с прежней версией проверки: она перечисляла два id вручную и + * потому была зелёной ровно в тот момент, когда `Faq.tsx` (единственный, кто + * рендерил все шесть) удалили вместе с лэндингом v1, а в возражения v3 + * перенесли только эти два. Четыре ответа, прошедшие юр-ревью, не + * рендерились нигде, и ни один гейт этого не сказал. + * + * Перебор по `FAQ` краснеет в обе стороны: + * - вопрос убрали из ObjectionsV3 → его id в файле нет → красный; + * - id переименовали в content.ts → в файле остался СТАРЫЙ id, `FAQ.find` + * молча вернёт undefined и вопрос исчезнет со страницы → красный; + * - в content.ts добавили седьмой вопрос → он обязан быть либо отрисован, + * либо осознанно исключён (тогда правится и этот тест), а не потерян. + * + * Ищем `...fromFaq("id")` со спредом, а не просто id: спред — это то, что + * кладёт вопрос в `ITEMS`. Объявление без спреда собралось бы, тайпчек бы + * промолчал, а на странице вопроса не было бы. + */ + it("в FAQ вообще есть вопросы — иначе перебор ниже пустой", () => { + expect(FAQ.length).toBeGreaterThan(0); + }); + + it.each(FAQ.map((f) => f.id))("%s отрисован", (id) => { + expect( + objections, + `вопрос "${id}" не попадает в ITEMS — на странице его не будет, и заметить это может только человек`, + ).toContain(`...fromFaq("${id}")`); + }); + + /** + * Отсылка к странице про обработку персональных данных обязана быть ЖИВОЙ + * ССЫЛКОЙ. В `content.ts` она лежит текстом, ObjectionsV3 линкует её по + * дословному совпадению — значит переформулировка предложения в content.ts + * тихо превращает ссылку обратно в текст. Проверка сверяет обе стороны. + */ + it("отсылка к странице ПДн отдаётся ссылкой, а не текстом", () => { + const phrase = + objections.match(/const PRIVACY_PHRASE = "([^"]+)"/)?.[1] ?? ""; + expect( + phrase, + "зонд сломан: в ObjectionsV3 не найдена константа связующей фразы", + ).not.toBe(""); + + const answer = FAQ.find((f) => f.id === "personal-data")?.a.join(" ") ?? ""; + expect( + answer, + "фраза в content.ts переформулирована — ссылка на страницу ПДн молча станет обычным текстом", + ).toContain(phrase); + + expect( + objections, + "ссылка обязана идти через PublicLink (обычный ): next/link подставит basePath", + ).toContain(' «моего города нет в списке». */ -const OTHER_CITY = "__other__"; - -type Feedback = - | { kind: "none" } - | { kind: "error"; field: "address" | "city"; text: string } - | { kind: "info"; title: string; lines: readonly string[] }; - -const NONE: Feedback = { kind: "none" }; - -export function AddressForm() { - const addressId = useId(); - const cityId = useId(); - const feedbackId = useId(); - - const [address, setAddress] = useState(""); - const [city, setCity] = useState(""); - const [feedback, setFeedback] = useState(NONE); - const addressRef = useRef(null); - const cityRef = useRef(null); - - function handleSubmit(event: FormEvent) { - event.preventDefault(); - - const trimmed = address.trim(); - if (!trimmed) { - setFeedback({ - kind: "error", - field: "address", - text: "Напишите улицу и номер дома — например, «Ленина, 5».", - }); - addressRef.current?.focus(); - return; - } - - // Город известен, только если его выбрали руками или назвали в тексте - // адреса. Ничего не додумываем — см. шапку файла. - const detected = detectCityInText(trimmed); - const resolvedCity = - city === OTHER_CITY ? OTHER_CITY : city || detected || ""; - - if (!resolvedCity) { - setFeedback({ - kind: "error", - field: "city", - text: "Выберите город: одинаковые названия улиц есть в разных городах области, и без города цена будет не про вашу квартиру.", - }); - cityRef.current?.focus(); - return; - } - - if (resolvedCity === OTHER_CITY) { - setFeedback({ - kind: "info", - title: `Пока мы считаем только по одному региону — ${REGION_NAME}`, - lines: [ - `Данные мы собираем сами, город за городом: полностью — ${PRIMARY_CITY}, частично — ${SECONDARY_CITIES.join(", ")}. По остальным адресам оценка была бы догадкой, поэтому мы её не показываем.`, - ], - }); - return; - } - - if (city === "" && detected) { - // Город распознали в тексте — синхронизируем селект, чтобы человек видел, - // что именно мы поняли, и мог поправить. - setCity(detected); - } - - // Уходим на отдельный экран проверки. Черновик кладём в sessionStorage, а - // НЕ в query-параметры: адрес в URL попал бы в access-логи Caddy рядом с - // IP посетителя — то есть мы бы хранили персональные данные там, где - // публично обещали ничего не хранить (см. estimate-draft.ts). - saveDraft({ address: trimmed, city: resolvedCity }); - window.location.assign(PUBLIC_ROUTES.estimate); - } - - const addressInvalid = - feedback.kind === "error" && feedback.field === "address"; - const cityInvalid = feedback.kind === "error" && feedback.field === "city"; - const describedBy = feedback.kind === "none" ? undefined : feedbackId; - - return ( -
-
-
- - -
- -
- - { - setAddress(event.target.value); - setFeedback(NONE); - }} - aria-invalid={addressInvalid || undefined} - aria-describedby={addressInvalid ? describedBy : undefined} - /> -
- - {/* Кнопка НИКОГДА не disabled по валидности: disabled-кнопка не - диспатчит submit, и невалидная попытка молча ничего бы не делала - вместо объяснения (тот же фикс, что в v2/LeadForm.tsx). */} - -
- -

- Ничего не спишется и не позвонит: телефон мы спрашиваем, только если вы - сами оставите заявку. -

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

{feedback.text}

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

{feedback.title}

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

- {line} -

- ))} -
- )} -
-
- ); -} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/DataSources.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/DataSources.tsx deleted file mode 100644 index 3ddb3e4f..00000000 --- a/tradein-mvp/frontend/src/app/mera-public/_components/DataSources.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/** - * DataSources — блок доверия «откуда мы берём цифры». Серверный компонент. - * - * Названия площадок НЕ вбиты строками: группы собираются в content.ts из - * `lib/source-registry.ts`, который и есть единственный источник правды по - * источникам (контракт честности #2211). Добавится площадка в реестр — она - * появится здесь сама; исчезнет — исчезнет и тут. - * - * Логотипов площадок нет намеренно: чужие товарные знаки на публичной странице - * — отдельный юридический вопрос, а картинки пришлось бы тянуть с чужих - * доменов (запрещено). Текстовые чипы решают ту же задачу. - */ - -import { SOURCE_GROUPS } from "../content"; -import styles from "../landing.module.css"; - -export function DataSources() { - return ( -
-
-
-

- Откуда мы берём данные -

- {/* Без числа групп в тексте: группы выводятся из реестра источников, - и «два типа данных» уже однажды разъехалось с кодом — оценочные - модели площадок участвуют в расчёте (estimator.py, IMV/Yandex - blend), но в тексте их не было. */} -

- Мы не опрашиваем экспертов и не берём цифры из головы. Данные - разного происхождения отвечают на разные вопросы — поэтому мы держим - их раздельно и показываем, что откуда. -

-
- -
- {SOURCE_GROUPS.map((group) => ( -
-

{group.title}

-
    - {group.items.map((item) => ( -
  • -
  • - ))} -
-

{group.note}

-
- ))} -
-
-
- ); -} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/Faq.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/Faq.tsx deleted file mode 100644 index 2e35afb0..00000000 --- a/tradein-mvp/frontend/src/app/mera-public/_components/Faq.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Faq — аккордеон на нативных
/. Серверный компонент. - * - * Почему без "use client": браузер уже умеет раскрывать details по Enter/Space, - * ставит фокус на summary и сам сообщает состояние скринридеру. Самописный - * аккордеон на useState потребовал бы вручную воспроизвести aria-expanded, - * управление фокусом и клавиатуру — и работал бы хуже до гидратации. Клиентский - * JS здесь не нужен вообще. - */ - -import { FAQ } from "../content"; -import styles from "../landing.module.css"; - -export function Faq() { - return ( -
-
-
-

- Частые вопросы -

-
- -
- {FAQ.map((item) => ( -
- - {item.q} - - -
- {item.a.map((paragraph) => ( -

{paragraph}

- ))} -
-
- ))} -
-
-
- ); -} - -function ChevronIcon() { - return ( - - ); -} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/Hero.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/Hero.tsx deleted file mode 100644 index 5fd5c376..00000000 --- a/tradein-mvp/frontend/src/app/mera-public/_components/Hero.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Hero — первый экран: что это, для кого, и сразу граница по географии. - * - * Плашка «Свердловская область» стоит ДО формы намеренно (решение владельца): - * человек должен узнать про ограничение раньше, чем потратит время на ввод - * адреса, а не после. Города берутся из реестра (`COVERED_CITIES`), не из - * строки, — иначе разъедутся с реальным покрытием сбора. - * - * Серверный компонент; клиентская часть — только `AddressForm`. - */ - -import { PRIMARY_CITY, REGION_NAME, SECONDARY_CITIES } from "../content"; -import styles from "../landing.module.css"; - -import { AddressForm } from "./AddressForm"; - -export function Hero() { - return ( -
- {/* Два вложенных div'а, а не два класса на одном: `.container` задаёт - общую 1120px-сетку страницы и центрирует её, `.heroInner` — узкую - колонку измерения (720px) ВНУТРИ неё, прижатую к левому краю. Пока оба - класса висели на одном элементе, побеждал max-width: 720px, и весь - первый экран уезжал вправо относительно всех секций ниже (на 1440px — - на 200px). */} -
-
-

Мера · оценка квартиры

- -

- Сколько на самом деле стоит ваша квартира -

- -

- Введите адрес — покажем, за сколько продаются похожие квартиры рядом - и, если по вашему дому или поблизости есть зарегистрированные - сделки, за сколько их реально покупают. Без звонка риелтора и без - визита оценщика. -

- - {/* Покрытие подано неравномерно намеренно — см. COVERED_CITIES в - content.ts: список городов в реестре ≠ карта покрытия. */} -
-

- - {REGION_NAME} -

-

- Полное покрытие — {PRIMARY_CITY}. По остальным городам области ( - {SECONDARY_CITIES.join(", ")}) данных меньше, и оценка там может - быть ориентировочной. По другим регионам не считаем вовсе — не - хотим гадать. -

-
- - - -

- «Мера» — сервис оценки вторичного жилья по рыночным данным. Мы не - покупаем квартиры и не берём их на продажу: наша работа — показать - цифру и то, откуда она взялась. -

-
-
-
- ); -} - -function PinIcon() { - return ( - - ); -} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/HowItWorks.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/HowItWorks.tsx deleted file mode 100644 index 83e273ee..00000000 --- a/tradein-mvp/frontend/src/app/mera-public/_components/HowItWorks.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/** - * HowItWorks — три шага. Серверный компонент. - * - * Разметка — упорядоченный список
    : порядок шагов несёт смысл, и - * скринридер объявит «список из 3 элементов, элемент 1», без декоративных - * кружков с цифрами. - * - * `role="list"` — не избыточность. WebKit СНИМАЕТ роли list/listitem со списка, - * у которого `list-style: none` (а он здесь есть, см. `.steps`), и в - * Safari/VoiceOver порядковый номер исчезал бы полностью: из семантики — из-за - * этого quirk'а, из текста — потому что видимая подпись «ШАГ N» помечена - * aria-hidden именно чтобы не дублировать семантику. Атрибут возвращает роли - * обратно. То же самое сделано у остальных списков лэндинга с list-style: none. - */ - -import { STEPS } from "../content"; -import styles from "../landing.module.css"; - -export function HowItWorks() { - return ( -
    -
    -
    -

    - Как это работает -

    -

    - Три шага, никакой регистрации на входе. -

    -
    - -
      - {STEPS.map((step, index) => ( -
    1. - -

      {step.title}

      -

      {step.text}

      -
    2. - ))} -
    -
    -
    - ); -} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/InnerHeader.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/InnerHeader.tsx index 318afde2..398f140a 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/InnerHeader.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/InnerHeader.tsx @@ -15,6 +15,32 @@ import { PublicLink } from "./PublicLink"; * * Логотип отсюда — ссылка на главную: это единственный обратный путь на * лэндинг, и он же то, что человек инстинктивно нажимает. + * + * ДЕСКРИПТОРА «ОЦЕНКА ВТОРИЧНОГО ЖИЛЬЯ ПО РЫНОЧНЫМ ДАННЫМ · <РЕГИОН>» ЗДЕСЬ + * НЕТ — И ЭТО РЕШЕНИЕ, А НЕ ПОТЕРЯ. Строка жила в `SiteHeader.tsx` (шапка + * лэндинга v1) с формулировкой, о которой просил юрист 14.08.2026: слова «по + * рыночным данным» — то, чем сервис отличается от оценки по 135-ФЗ, и они + * должны стоять в заметном дескрипторе, а не только в дисклеймерах внизу. + * 29.08.2026 `SiteHeader` удалён, юр-страницы переведены на эту шапку, и + * строка с них ушла. + * + * Почему она сюда не возвращается: + * - ЭТО ШАПКА НЕ ТОЛЬКО ЮР-СТРАНИЦ. Её же носят `/estimate`, `/articles`, + * `/docs`, у которых дескриптора не было никогда: возврат «на место» + * означал бы новую строку на трёх страницах, которых правка не касается. + * - ТРЕБОВАНИЕ ЮРИСТА БЫЛО ПРО ЛЭНДИНГ — про первое, что видит посетитель. + * На корне оно выполняется и сейчас, только не шапкой: `HeroV3` первым же + * экраном пишет «„Мера“ — сервис оценки вторичного жилья по рыночным + * данным», и там же стоит граница покрытия. + * - НА САМИХ ЮР-СТРАНИЦАХ ЭТО СКАЗАНО СИЛЬНЕЕ, ЧЕМ В ДЕСКРИПТОРЕ. Оферта + * говорит то же в тексте договора (п. 1.3 «индикативный расчёт + * ориентировочной рыночной стоимости», п. 6.2 «не является отчётом об + * оценке по 135-ФЗ»), а `FooterV3` на каждой из этих страниц несёт и + * рыночную природу оценки с регионом («Оценка квартир на вторичном рынке + * по сделкам и объявлениям. <регион>»), и прямой отказ («не является + * офертой или отчётом об оценке»). + * Появится страница, где ни то ни другое не выполняется, — дескриптор нужно + * вернуть ей, а не этой общей шапке. */ export function InnerHeader() { return ( diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/SiteFooter.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/SiteFooter.tsx deleted file mode 100644 index e7f82812..00000000 --- a/tradein-mvp/frontend/src/app/mera-public/_components/SiteFooter.tsx +++ /dev/null @@ -1,117 +0,0 @@ -/** - * SiteFooter — подвал. Серверный компонент. - * - * 2026-08-13: блоки реквизитов и почты, которых здесь раньше не было, теперь - * рендерятся — данные появились (`LEGAL_ENTITY`, `SUPPORT_EMAIL` в content.ts), - * и это не косметика: наименование, ИНН, ОГРН и работающий контакт на странице - * с офертой — прямое требование модерации эквайера и условие идентифицируемости - * оператора по 152-ФЗ. Значения импортируются, а не пишутся строками: тот же - * набор рендерят оферта и политика ПДн, и разъехаться они не должны. - * - * ССЫЛКИ НА ДОКУМЕНТЫ — КОРОТКИЕ (изменено 15.08.2026 по решению владельца). - * Раньше здесь стояли внутренние пути `/mera-public/oferta` и соседи, чтобы - * подвал работал и при открытии поддерева с gendsgn.ru. Теперь наоборот: - * человек на публичном сервисе не должен видеть `/trade-in/mera-public/...` в - * адресной строке, и именно короткие адреса напечатаны внутри самих - * документов. Цена решения — переходы в подвале работают только на - * meraocenka.ru; разбор и что с этим делать — в шапке `PUBLIC_ROUTES` - * (content.ts). - * - * Внешняя ссылка проверяется `safeUrl` (правило frontend.md: ничего в href без - * валидации схемы) и открывается в новой вкладке с rel="noreferrer". - */ - -import { safeUrl } from "@/lib/safeUrl"; - -import { - LEGAL_ENTITY, - REGION_NAME, - SUPPORT_EMAIL, - SUPPORT_TELEGRAM_LABEL, - SUPPORT_TELEGRAM_URL, -} from "../content"; -import styles from "../landing.module.css"; - -import { PublicLink } from "./PublicLink"; - -export function SiteFooter() { - const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL); - const year = new Date().getFullYear(); - - return ( -
    - ); -} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/SiteHeader.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/SiteHeader.tsx deleted file mode 100644 index a4c4d29a..00000000 --- a/tradein-mvp/frontend/src/app/mera-public/_components/SiteHeader.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/** - * SiteHeader — шапка лэндинга. Серверный компонент: интерактивности нет. - * - * Логотип НЕ является ссылкой на "/": в next.config.ts стоит redirect "/" → "/v2", - * то есть клик по нему выкинул бы публичного посетителя в закрытое B2B-приложение - * (и дальше на /login). Пока публичная страница одна, вордмарк — просто текст. - * - * ФОРМУЛИРОВКА ДЕСКРИПТОРА (14.08.2026, юр-документ владельца, блок 3). Строка - * шапки обязана читаться как «МЕРА · Оценка вторичного жилья по рыночным данным · - * <регион>»: слова «по рыночным данным» — то, чем сервис отличается от оценки по - * 135-ФЗ, и юрист попросил, чтобы это стояло в самом заметном дескрипторе, а не - * только в дисклеймерах внизу. «МЕРА» приходит из вордмарка ниже (у него свой - * letter-spacing), поэтому в headerTag её нет — строка складывается из двух узлов. - * Регион — из REGION_NAME, не строкой (правило content.ts). - */ - -import { REGION_NAME } from "../content"; -import styles from "../landing.module.css"; - -export function SiteHeader() { - return ( -
    -
    -
    -
    -

    - Оценка вторичного жилья по рыночным данным · {REGION_NAME} -

    -
    -
    - ); -} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/WhatYouGet.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/WhatYouGet.tsx deleted file mode 100644 index cb274240..00000000 --- a/tradein-mvp/frontend/src/app/mera-public/_components/WhatYouGet.tsx +++ /dev/null @@ -1,93 +0,0 @@ -/** - * WhatYouGet — что человек получает по итогу. Серверный компонент. - * - * Каждый пункт соответствует блоку, который результат оценки рендерит сегодня - * (`v2/types.ts` → ResultCard.value/range/ppm/delta/ResultMeta.builtOn). - * Ничего «планируемого» и ничего недоступного анониму в списке нет — правило и - * разбор по PDF в шапке content.ts. - * - * Дисклеймер про 135-ФЗ обязателен: «Мера» даёт рыночную оценку по - * сопоставимым объектам, а не отчёт аккредитованного оценщика. - */ - -import { DELIVERABLES, DELIVERABLES_DISCLAIMER } from "../content"; -import styles from "../landing.module.css"; - -export function WhatYouGet() { - return ( -
    -
    -
    -

    - Что вы получите -

    -

    - Не «примерную стоимость», а расчёт, который видно насквозь. -

    -
    - -
      - {DELIVERABLES.map((item) => ( -
    • - -
      -

      {item.title}

      -

      {item.text}

      -
      -
    • - ))} -
    - -

    - - {DELIVERABLES_DISCLAIMER} -

    -
    -
    - ); -} - -function CheckIcon() { - return ( - - ); -} - -function InfoIcon() { - return ( - - ); -} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx index 67c632f6..789793b4 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx @@ -20,27 +20,61 @@ * * Ветка результата ПОСТРОЕНА (`FreeResultV3.tsx`), но не подключена — см. * TODO у `PUBLIC_ESTIMATE_ENABLED` ниже и JSDoc в `FreeResultV3.tsx`. + * + * ГОРОД (порт из `../AddressForm.tsx`, v1). До этого карточка города не + * спрашивала вовсе — то есть гео-гейт продукта на новом лэндинге был сломан: + * это ровно тот баг, который бэкенд чинил в #2576 (житель Нижнего Тагила + * вводил «Ленина, 1» и получал уверенную цену по одноимённой улице в ЕКБ). + * Правило из шапки `lib/city-registry.ts`: город считается известным, только + * если человек выбрал его сам ИЛИ `detectCityInText` нашёл его в тексте + * адреса; молча подставлять Екатеринбург нельзя. Ответ про город вне покрытия + * — тот же и дословно тот же, что в v1. Выпадашка города в шапке + * (`CityPicker`) сюда НЕ пробрасывается: она презентационная и своим выбором + * не владеет (см. её JSDoc), а гейт обязан читать то, что человек указал + * рядом с адресом. + * + * СОСТОЯНИЕ ТЕПЕРЬ ДОЕЗЖАЕТ ДО ЧЕРНОВИКА. Поле было обязательным, а его + * значение никуда не клалось — человек выбирал состояние квартиры, и мы его + * выбрасывали на переходе. Бесплатная проба покрытия состояние действительно + * не использует (см. `CoverageProbeInput` на бэке), но выбросить уже + * полученный ответ и спросить его второй раз на платном шаге — хуже, чем + * донести его в `sessionStorage` до того шага. */ import { useId, useRef, useState } from "react"; import type { FormEvent } from "react"; -import { PRIMARY_CITY, PUBLIC_ROUTES } from "../../content"; +import { detectCityInText } from "@/lib/city-registry"; + +import { + COVERED_CITIES, + PRIMARY_CITY, + PUBLIC_ROUTES, + REGION_NAME, + SECONDARY_CITIES, +} from "../../content"; import { saveDraft } from "../../estimate-draft"; import styles from "../../landing-v3.module.css"; +/** Значение