From d4e106882c92eb309abc92d31bb601693f3c90a4 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 18:52:09 +0500 Subject: [PATCH 1/3] =?UTF-8?q?feat(mera/b2c):=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=D1=81=D1=82=D0=B8=20=D0=BF=D0=BE=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D1=8F=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8?= =?UTF-8?q?=20v1=20=D0=B2=20=D0=BB=D1=8D=D0=BD=D0=B4=D0=B8=D0=BD=D0=B3=20v?= =?UTF-8?q?3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Корень meraocenka.ru отдаёт v1, новый дизайн живёт на /v3 и скоро станет корнем. Шесть вещей из v1 в v3 отсутствовали — при переключении они бы просто исчезли с публичного сайта: - реестра источников не было вовсе (SourcesV3, из SOURCE_GROUPS); - «что вы получите» и дисклеймера 135-ФЗ не было (WhatYouGetV3): о том, что это не отчёт аккредитованного оценщика, узнавал только тот, кто сам догадался открыть нужное возражение; - герой не называл границу покрытия (полное — Екатеринбург, частично — остальные города области, по другим регионам не считаем) и не говорил, что мы не покупаем квартиры и не берём их на продажу; - два вопроса FAQ («откуда вы знаете, сколько стоит именно моя квартира», «почему только Свердловская область») в возражениях v3 не звучали — теперь берутся из FAQ по id, а не копией строк; - карточка проверки не спрашивала город: гео-гейт #2576 на новом лэндинге был сломан (без города «Ленина, 1» уходит в одноимённую улицу ЕКБ); - она же спрашивала состояние обязательным полем и выбрасывала ответ на переходе — теперь он доезжает в черновике. Тексты перенесены дословно (проходили юр-ревью), новых чисел нет. Новые секции встали после «Точности»: сверяем прогноз с фактом → на каких данных → что получаете на руки, всё до первого разговора про деньги. Гейт: __tests__/v3-ported-copy.test.ts — проверен мутацией (снятие секции со страницы и переименование id вопроса красят его). --- .../__tests__/estimate-draft.test.ts | 15 ++ .../__tests__/v3-ported-copy.test.ts | 88 +++++++ .../_components/v3/FreeCheckCard.tsx | 123 ++++++++- .../app/mera-public/_components/v3/HeroV3.tsx | 36 +++ .../_components/v3/ObjectionsV3.tsx | 27 +- .../mera-public/_components/v3/SourcesV3.tsx | 66 +++++ .../_components/v3/WhatYouGetV3.tsx | 60 +++++ .../src/app/mera-public/estimate-draft.ts | 15 +- .../src/app/mera-public/landing-v3.module.css | 233 ++++++++++++++++++ .../frontend/src/app/mera-public/v3/page.tsx | 12 + 10 files changed, 665 insertions(+), 10 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/v3-ported-copy.test.ts create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/SourcesV3.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/WhatYouGetV3.tsx 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__/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..d43cb628 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/v3-ported-copy.test.ts @@ -0,0 +1,88 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { FAQ } from "../content"; + +/** + * Гейт «при переключении корня на v3 ничего не потерялось». + * + * Корень meraocenka.ru отдаёт лэндинг v1, а новый дизайн живёт на /v3 и скоро + * станет корнем. Разведка нашла блоки, которых в 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("v3/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 v1 живут в возражениях v3", () => { + const objections = read("_components/v3/ObjectionsV3.tsx"); + + /** + * Проверка ДВУСТОРОННЯЯ: id должен и существовать в FAQ, и упоминаться в + * секции. Односторонняя пропустила бы главную ошибку — переименование id, от + * которого `FAQ.find` вернёт undefined, и вопрос молча исчезнет со страницы. + */ + it.each(["how-do-you-know", "why-region"])("%s", (id) => { + expect( + FAQ.map((f) => f.id), + `id пропал из FAQ — ObjectionsV3 отрендерит на один вопрос меньше и никто не заметит`, + ).toContain(id); + expect(objections).toContain(`"${id}"`); + }); +}); 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"; +/** Значение