diff --git a/caddy/sites/apps.caddy b/caddy/sites/apps.caddy index a40d54a8..8010c04b 100644 --- a/caddy/sites/apps.caddy +++ b/caddy/sites/apps.caddy @@ -372,12 +372,35 @@ meraocenka.ru { # двух недель: владелец сравнивал по нему версии, и ссылка почти наверняка # осталась в закладках и в переписке. Поэтому 301 на корень, а не молчание # белого списка: человек по старой ссылке попадает ровно на то, что по ней - # и смотрел, только теперь это главная. Обе формы — со слэшем и без: - # `/v3/` вышел из @meraShortSlash вместе с остальным превью и иначе упал бы - # в catch-all 404. - @meraV3Gone path /v3 /v3/ + # и смотрел, только теперь это главная. + # + # ЧЕТЫРЕ ФОРМЫ, А НЕ ДВЕ. Превью раздавалось и коротким `/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 { - redir * / permanent + route { + uri strip_prefix /trade-in/mera-public + uri strip_prefix /v3 + redir * {uri} permanent + } } # Длинные адреса поддерева → 301 на короткие. Один канонический адрес у 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 c5c5a033..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,13 +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 = [ - // 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 f6dcfdc2..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 @@ -196,6 +196,11 @@ describe("короткие адреса публичного домена", () = * * Правильный ответ ровно один — 301 на корень, и он проверяется здесь, а не * держится на памяти о том, почему в конфиге появился ещё один матчер. + * + * ФОРМ ЧЕТЫРЕ, А НЕ ДВЕ. Превью раздавалось и коротким `/v3`, и длинным + * `/trade-in/mera-public/v3`; вторая пара выбыла из @meraLongPages вместе со + * страницей и никуда не попала — длинный адрес превью отдавал 404, ровно тот + * же дефект, что проверка выше ловит для страниц. */ it("адрес выбывшего превью /v3 отдаёт 301 на корень, а не 404", () => { const matcher = meraBlock.match(/@meraV3Gone path ([^\n]+)/)?.[1] ?? ""; @@ -203,14 +208,32 @@ describe("короткие адреса публичного домена", () = expect( paths, "нет матчера @meraV3Gone — старые ссылки на превью упадут в catch-all 404", - ).toEqual(["/v3", "/v3/"]); + ).toEqual([ + "/v3", + "/v3/", + "/trade-in/mera-public/v3", + "/trade-in/mera-public/v3/", + ]); + // Тело `handle` содержит вложенный `route { … }`, поэтому берём до + // закрывающей скобки НА УРОВНЕ handle (четыре пробела), а не до первой. const handler = - meraBlock.match(/handle @meraV3Gone \{([^}]*)\}/)?.[1] ?? ""; + meraBlock.match(/handle @meraV3Gone \{([\s\S]*?)\n {4}\}/)?.[1] ?? ""; expect( handler, "матчер есть, а редиректа в нём нет — зонд выше прошёл бы вхолостую", - ).toMatch(/redir\s+\*\s+\/\s+permanent/); + ).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"); }); }); 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 94248e71..9035fb13 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 @@ -11,7 +11,7 @@ import { FAQ } from "../content"; * Новый дизайн жил на /v3 и 29.08.2026 стал корнем meraocenka.ru. Перед этим * разведка нашла блоки лэндинга v1, которых в v3 не было вовсе: реестр * источников, «что вы получите» с дисклеймером 135-ФЗ, плашка покрытия и - * строка «мы не покупаем квартиры» в герое, два вопроса из FAQ, город в форме. + * строка «мы не покупаем квартиры» в герое, вопросы FAQ, город в форме. * Они перенесены — здесь проверяется, что перенос ЖИВОЙ: секции подключены к * странице, а компоненты читают те же константы, а не копии строк. * @@ -70,19 +70,62 @@ describe("секции читают константы, а не копии ст }); }); -describe("два вопроса из FAQ v1 живут в возражениях v3", () => { +describe("КАЖДЫЙ вопрос FAQ живёт в возражениях v3", () => { const objections = read("_components/v3/ObjectionsV3.tsx"); /** - * Проверка ДВУСТОРОННЯЯ: id должен и существовать в FAQ, и упоминаться в - * секции. Односторонняя пропустила бы главную ошибку — переименование id, от - * которого `FAQ.find` вернёт undefined, и вопрос молча исчезнет со страницы. + * Список вопросов НЕ ЗАХАРДКОЖЕН — он перебирается из самого `FAQ`. В этом + * вся разница с прежней версией проверки: она перечисляла два id вручную и + * потому была зелёной ровно в тот момент, когда `Faq.tsx` (единственный, кто + * рендерил все шесть) удалили вместе с лэндингом v1, а в возражения v3 + * перенесли только эти два. Четыре ответа, прошедшие юр-ревью, не + * рендерились нигде, и ни один гейт этого не сказал. + * + * Перебор по `FAQ` краснеет в обе стороны: + * - вопрос убрали из ObjectionsV3 → его id в файле нет → красный; + * - id переименовали в content.ts → в файле остался СТАРЫЙ id, `FAQ.find` + * молча вернёт undefined и вопрос исчезнет со страницы → красный; + * - в content.ts добавили седьмой вопрос → он обязан быть либо отрисован, + * либо осознанно исключён (тогда правится и этот тест), а не потерян. + * + * Ищем `...fromFaq("id")` со спредом, а не просто id: спред — это то, что + * кладёт вопрос в `ITEMS`. Объявление без спреда собралось бы, тайпчек бы + * промолчал, а на странице вопроса не было бы. */ - it.each(["how-do-you-know", "why-region"])("%s", (id) => { + it("в FAQ вообще есть вопросы — иначе перебор ниже пустой", () => { + expect(FAQ.length).toBeGreaterThan(0); + }); + + it.each(FAQ.map((f) => f.id))("%s отрисован", (id) => { expect( - FAQ.map((f) => f.id), - `id пропал из FAQ — ObjectionsV3 отрендерит на один вопрос меньше и никто не заметит`, - ).toContain(id); - expect(objections).toContain(`"${id}"`); + 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('» ЗДЕСЬ + * НЕТ — И ЭТО РЕШЕНИЕ, А НЕ ПОТЕРЯ. Строка жила в `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/v3/ObjectionsV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/ObjectionsV3.tsx index e7429ff7..9b1d736b 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/ObjectionsV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/ObjectionsV3.tsx @@ -12,17 +12,39 @@ * объявлений с публикации), а не макетной формулировкой про «отметки * продавцов», механики которых не существует. * - * ДВА ВОПРОСА ИЗ v1 (`FAQ` в content.ts) ИМПОРТИРУЮТСЯ, А НЕ ПЕРЕПИСАНЫ. - * Макетные шесть возражений — про оплату и доверие; в v1 было два вопроса, - * которых среди них нет вовсе, и оба несут границы продукта: - * - `how-do-you-know` — как вообще получается цена именно этой квартиры; - * - `why-region` — почему одна область, и чем «полное покрытие» отличается - * от «данных меньше». - * Первый стоит ПЕРВЫМ (это вопрос до всяких сомнений об оплате), второй — - * последним (география — то, с чем человек уходит). Берём их из `FAQ` по id, а - * не копией строк: копия разъедется с ответом на странице /docs и в v1, где - * тот же текст уже прошёл юр-ревью. Если id переименуют, вопрос молча - * исчезнет — это гейтится `__tests__/v3-ported-copy.test.ts`. + * ВЕСЬ `FAQ` ИЗ content.ts ИМПОРТИРУЕТСЯ, А НЕ ПЕРЕПИСЫВАЕТСЯ. Так было не + * сразу: при переносе брались только `how-do-you-know` и `why-region`, а + * остальные четыре вопроса v1 (`how-accurate`, `vs-marketplace`, + * `personal-data`, `bank-report`) не рендерились НИГДЕ — старый компонент + * `Faq.tsx`, который выводил все шесть, удалён вместе с лэндингом v1. + * Тематически их закрывали макетные возражения, но это были ПЕРЕСКАЗЫ, а + * оригиналы проходили юр-ревью. Две потери были содержательными: + * - `personal-data` — единственное место на сайте, где сказано «Имя, паспорт + * и документы на квартиру мы не спрашиваем», и единственная отсылка к + * странице про обработку персональных данных (152-ФЗ). Макетная версия + * оставляла только «отдельную галочку согласия», без ссылки; + * - `how-accurate` — «показываем диапазон, а не одно число» как прямой ответ + * на вопрос о точности. После потери рамка диапазона осталась только в + * `AccuracyV3`, рядом с плейсхолдерными числами. + * + * ЧТО СДЕЛАНО. Список ниже — один порядок чтения, в который вплетены и все + * шесть вопросов `FAQ` (по id, а не копией строк), и те макетные возражения, + * которым в `FAQ` пары нет вовсе: «откуда вы знаете факт сделки», «почему так + * дёшево», «это подписка». Три остальных макетных возражения — про + * калькуляторы, звонки и отчёт для банка — УДАЛЕНЫ как пересказы + * `vs-marketplace` / `personal-data` / `bank-report`: держать рядом два ответа + * на один вопрос хуже, чем один, а выбор между пересказом и выверенным + * оригиналом очевиден. По той же причине у «почему так дёшево» убран второй + * абзац — он был почти дословным повтором второго абзаца `how-accurate`, + * который теперь стоит следующим вопросом и отвечает на «это точно работает?» + * целиком. + * + * Ни одной строки не написано здесь заново: тексты либо приходят из + * `content.ts`, либо это уже стоявшие в этом файле макетные возражения. + * + * Если id переименуют или вопрос выпадет из списка, он молча исчезнет со + * страницы — гейтится `__tests__/v3-ported-copy.test.ts` двусторонне: тест + * перебирает `FAQ` и требует, чтобы КАЖДЫЙ его id был отрисован здесь. */ import { FAQ } from "../../content"; @@ -35,7 +57,20 @@ interface Objection { readonly a: readonly string[]; } -const OBJECTIONS: readonly Objection[] = [ +/** Вопрос из `FAQ` по id — `[]`, если id переименовали (тест это ловит). */ +function fromFaq(id: string): readonly Objection[] { + const item = FAQ.find((f) => f.id === id); + return item ? [{ q: item.q, a: item.a }] : []; +} + +/** + * Порядок чтения: как получается цена → откуда факт → сколько стоит и почему + * столько → насколько точно → чем отличается от бесплатного → как списывают + * деньги → что с данными → годится ли для банка → почему одна область. + * Сомнения про деньги стоят посередине, границы продукта — по краям. + */ +const ITEMS: readonly Objection[] = [ + ...fromFaq("how-do-you-know"), { q: "Откуда вы знаете факт сделки?", a: [ @@ -46,48 +81,50 @@ const OBJECTIONS: readonly Objection[] = [ q: "Почему так дёшево — это точно работает?", a: [ "Дёшево, потому что расчёт автоматический: без выезда, без оценщика, без комиссии. Вы платите за данные, а не за часы специалиста.", - "Точность прямо зависит от того, сколько нашлось сопоставимых объектов, — поэтому мы всегда пишем, на скольких объектах и сделках построен расчёт, и честно сообщаем, если данных по дому мало.", - ], - }, - { - q: "Чем это лучше бесплатных калькуляторов?", - a: [ - "Калькулятор площадки считает по объявлениям этой же площадки — то есть по ценам, которые продавцы просят, а не получают.", - "Мы смотрим сразу несколько площадок и добавляем зарегистрированные сделки, чтобы была видна разница между запрашиваемой и реальной ценой.", ], }, + ...fromFaq("how-accurate"), + ...fromFaq("vs-marketplace"), { q: "Это подписка? Спишут ли ещё раз?", a: [ "Нет. Это разовый платёж за один отчёт — без подписки, автопродлений и скрытых списаний. Отчёт остаётся у вас по ссылке.", ], }, - { - q: "Мне начнут звонить после проверки?", - a: [ - "Нет. Для расчёта нужны адрес и параметры квартиры — телефон мы не спрашиваем. Он появляется, только если вы сами решите оставить заявку, и всегда с отдельной галочкой согласия.", - ], - }, - { - q: "Это официальная оценка для банка?", - a: [ - "Нет. Для ипотеки, суда, опеки и нотариуса нужен отчёт аккредитованного оценщика по 135-ФЗ — это отдельная процедура с выездом. МЕРА отвечает на другой вопрос: за сколько квартира реально продаётся на рынке сегодня.", - ], - }, -]; - -/** Вопрос v1 по id — `[]`, если id переименовали (тест это ловит). */ -function fromFaq(id: string): readonly Objection[] { - const item = FAQ.find((f) => f.id === id); - return item ? [{ q: item.q, a: item.a }] : []; -} - -const ITEMS: readonly Objection[] = [ - ...fromFaq("how-do-you-know"), - ...OBJECTIONS, + ...fromFaq("personal-data"), + ...fromFaq("bank-report"), ...fromFaq("why-region"), ]; +/** + * Отсылка к странице про обработку персональных данных внутри ответа + * `personal-data`. В `content.ts` она лежит обычным текстом (там нет разметки), + * и в старом `Faq.tsx` так текстом и рендерилась — то есть 152-ФЗ-документ + * назывался, но открыть его отсюда было нельзя. + * + * Фраза вынесена константой, а не зашита в разметку: это КОПИЯ куска текста из + * `content.ts`, и разъехаться она может молча — переформулируют предложение, и + * `indexOf` не найдёт ничего, а ссылка тихо станет обычным текстом. Ровно это + * и проверяет `v3-ported-copy.test.ts`: фраза обязана дословно встречаться в + * ответе `personal-data`. + */ +const PRIVACY_PHRASE = "странице про обработку персональных данных"; + +/** Абзац ответа: фраза выше отдаётся ссылкой, остальное — как есть. */ +function renderAnswer(text: string) { + const at = text.indexOf(PRIVACY_PHRASE); + if (at < 0) return text; + return ( + <> + {text.slice(0, at)} + + {PRIVACY_PHRASE} + + {text.slice(at + PRIVACY_PHRASE.length)} + + ); +} + export function ObjectionsV3() { return (
@@ -110,7 +147,7 @@ export function ObjectionsV3() {
{o.a.map((p) => ( -

{p}

+

{renderAnswer(p)}

))}
diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css index 93bd1893..0d7d137c 100644 --- a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css +++ b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css @@ -2127,6 +2127,20 @@ margin-bottom: 0; } +/* Ссылка ВНУТРИ предложения (отсылка к странице про обработку персональных + данных в ответе `personal-data`) — то же inline-исключение из правила зоны + нажатия, что у .ftrInlineLink: раздувать строку текста было бы хуже. */ +.objAnswerLink { + color: var(--b2c-accent-text); + font-weight: 600; + text-decoration: underline; + text-underline-offset: 3px; +} + +.objAnswerLink:hover { + color: var(--b2c-ink); +} + /* --- Статьи --------------------------------------------------------------- */ .artSection { max-width: 1280px;