diff --git a/caddy/sites/apps.caddy b/caddy/sites/apps.caddy index ef58bab2..a40d54a8 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,25 @@ 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/` вышел из @meraShortSlash вместе с остальным превью и иначе упал бы + # в catch-all 404. + @meraV3Gone path /v3 /v3/ + handle @meraV3Gone { + redir * / permanent + } + # Длинные адреса поддерева → 301 на короткие. Один канонический адрес у # страницы, а не два работающих. # @@ -413,7 +423,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__/marketing-placeholders-gate.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/marketing-placeholders-gate.test.ts index 272b4390..c5c5a033 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 @@ -36,7 +36,11 @@ function read(rel: string): string { /** Компоненты лэндинга v3, которые могут ссылаться на плейсхолдеры. */ const V3_SOURCES = [ - "v3/page.tsx", + // 29.08.2026: лэндинг v3 переехал из `v3/page.tsx` в корень. Путь обязан + // ехать вместе с ним — `placeholdersInUse()` молча пропускает ненайденный + // файл (это правильно для переименований), поэтому устаревшая строка не + // покраснела бы, а просто перестала бы что-либо сканировать. + "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..f6dcfdc2 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,34 @@ describe("короткие адреса публичного домена", () = ).toBe(true); } }); + + /** + * ВЫБЫВШЕЕ ПРЕВЬЮ ОБЯЗАНО ОСТАТЬСЯ ЖИВЫМ АДРЕСОМ. `/v3` две недели был + * открыт на публичном домене — владелец сравнивал по нему варианты дизайна, + * и ссылка разошлась по закладкам и переписке. 29.08.2026 вариант стал + * корнем, каталог `v3/` во фронте удалён; проксировать этот путь больше + * нельзя (Next отдаст 404), но и просто выкинуть его из белого списка + * нельзя — это тот же класс дефекта, ради которого заведены проверки выше: + * рабочая ссылка молча превращается в 404. + * + * Правильный ответ ровно один — 301 на корень, и он проверяется здесь, а не + * держится на памяти о том, почему в конфиге появился ещё один матчер. + */ + 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/"]); + + const handler = + meraBlock.match(/handle @meraV3Gone \{([^}]*)\}/)?.[1] ?? ""; + expect( + handler, + "матчер есть, а редиректа в нём нет — зонд выше прошёл бы вхолостую", + ).toMatch(/redir\s+\*\s+\/\s+permanent/); + }); }); 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 index d43cb628..94248e71 100644 --- 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 @@ -8,8 +8,8 @@ import { FAQ } from "../content"; /** * Гейт «при переключении корня на v3 ничего не потерялось». * - * Корень meraocenka.ru отдаёт лэндинг v1, а новый дизайн живёт на /v3 и скоро - * станет корнем. Разведка нашла блоки, которых в v3 не было вовсе: реестр + * Новый дизайн жил на /v3 и 29.08.2026 стал корнем meraocenka.ru. Перед этим + * разведка нашла блоки лэндинга v1, которых в v3 не было вовсе: реестр * источников, «что вы получите» с дисклеймером 135-ФЗ, плашка покрытия и * строка «мы не покупаем квартиры» в герое, два вопроса из FAQ, город в форме. * Они перенесены — здесь проверяется, что перенос ЖИВОЙ: секции подключены к @@ -29,7 +29,7 @@ function read(rel: string): string { } describe("страница v3 рендерит перенесённые секции", () => { - const page = read("v3/page.tsx"); + const page = read("page.tsx"); it.each(["SourcesV3", "WhatYouGetV3"])("<%s /> подключён", (name) => { expect(page).toContain(`<${name} />`); diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/AddressForm.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/AddressForm.tsx deleted file mode 100644 index 55126344..00000000 --- a/tradein-mvp/frontend/src/app/mera-public/_components/AddressForm.tsx +++ /dev/null @@ -1,225 +0,0 @@ -"use client"; - -/** - * AddressForm — поле адреса на первом экране. - * - * ЧТО ЭТА ФОРМА ДЕЛАЕТ СЕГОДНЯ И ПОЧЕМУ ИМЕННО ТАК - * - * Она не считает цену и не притворяется, что считает. Причина техническая и - * жёсткая: `rbac_guard` (backend/app/core/rbac.py) пропускает анонима только на - * пути из `_PUBLIC_PATHS`, а `/api/v1/geocode/suggest` и - * `/api/v1/trade-in/estimate` туда не входят — любой запрос отсюда вернул бы - * 401. Открытие анонимного периметра — отдельный backend-PR, вне границ этой - * задачи. Поэтому здесь честная валидация на клиенте + прямой ответ «публичный - * расчёт ещё не открыт» вместо фейкового спиннера. - * - * Что форма всё-таки делает по-настоящему: - * - проверяет, что адрес введён; - * - требует явно назвать город и не подставляет Екатеринбург молча. Это ровно - * тот баг, который чинил бэкенд в #2576: житель Нижнего Тагила вводил - * «Ленина, 1» и получал уверенную цену по одноимённой улице в ЕКБ. Правило - * из шапки `lib/city-registry.ts` — город считается известным только если - * пользователь его выбрал ИЛИ `detectCityInText` нашёл его в тексте; - * - если названного города нет в покрытии — мягко и честно говорит про - * Свердловскую область, не обещая «оценим любую квартиру в РФ». - * - * Осознанно НЕ переиспользован автокомплит из закрытого контура - * (ParamsPanel.tsx / AddressInput.tsx): он ходит в `/geocode/suggest` через - * `useGeocodeSuggest`, что для анонима = 401. Тянуть сюда хуки B2B-контура - * (useMe/useQuota/useHistory и соседей) запрещено — публичный экран не должен - * иметь к ним доступа даже теоретически. - * - * КУДА ВЕДЁТ КНОПКА (15.08.2026). Раньше сабмит упирался в сообщение «расчёт - * ещё не открыли» — анонимных ручек не существовало. Теперь они есть - * (`/api/public/mera/*`), а сама проверка живёт на отдельном экране - * `/estimate`: там у автокомплита есть место под список подсказок, а у - * результата — место рядом с полями. Эта форма осталась входной точкой - * лэндинга и делает ровно две полезные вещи — не пускает дальше без адреса и - * требует явно назвать город (#2576: «Ленина, 1» без города давало уверенную - * цену по одноимённой улице в ЕКБ жителю Нижнего Тагила). - */ - -import { useId, useRef, useState } from "react"; -import type { FormEvent } from "react"; - -import { detectCityInText } from "@/lib/city-registry"; - -import { - COVERED_CITIES, - PRIMARY_CITY, - PUBLIC_ROUTES, - REGION_NAME, - SECONDARY_CITIES, -} from "../content"; -import { saveDraft } from "../estimate-draft"; -import styles from "../landing.module.css"; - -/** Значение