feat(mera/b2c): новый дизайн становится корнем meraocenka.ru, функционал старого перенесён #3236
30 changed files with 1006 additions and 882 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 не протекает в публичные ссылки", () => {
|
||||
|
|
|
|||
|
|
@ -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 (обычный <a>): next/link подставит basePath",
|
||||
).toContain('<PublicLink to="privacy"');
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
||||
/** Значение <option> «моего города нет в списке». */
|
||||
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<Feedback>(NONE);
|
||||
const addressRef = useRef<HTMLInputElement>(null);
|
||||
const cityRef = useRef<HTMLSelectElement>(null);
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
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 (
|
||||
<form className={styles.form} onSubmit={handleSubmit} noValidate>
|
||||
<div className={styles.formRow}>
|
||||
<div className={`${styles.field} ${styles.fieldCity}`}>
|
||||
<label className={styles.label} htmlFor={cityId}>
|
||||
Город
|
||||
</label>
|
||||
<select
|
||||
id={cityId}
|
||||
ref={cityRef}
|
||||
className={`${styles.select} ${cityInvalid ? styles.inputInvalid : ""}`}
|
||||
value={city}
|
||||
onChange={(event) => {
|
||||
setCity(event.target.value);
|
||||
setFeedback(NONE);
|
||||
}}
|
||||
aria-invalid={cityInvalid || undefined}
|
||||
aria-describedby={cityInvalid ? describedBy : undefined}
|
||||
>
|
||||
<option value="">Выберите город</option>
|
||||
{COVERED_CITIES.map((label) => (
|
||||
<option key={label} value={label}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
<option value={OTHER_CITY}>Другой город</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.fieldAddress}`}>
|
||||
<label className={styles.label} htmlFor={addressId}>
|
||||
Улица и дом
|
||||
</label>
|
||||
<input
|
||||
id={addressId}
|
||||
ref={addressRef}
|
||||
className={`${styles.input} ${addressInvalid ? styles.inputInvalid : ""}`}
|
||||
type="text"
|
||||
name="address"
|
||||
autoComplete="street-address"
|
||||
enterKeyHint="go"
|
||||
placeholder="Например, Ленина, 5"
|
||||
value={address}
|
||||
onChange={(event) => {
|
||||
setAddress(event.target.value);
|
||||
setFeedback(NONE);
|
||||
}}
|
||||
aria-invalid={addressInvalid || undefined}
|
||||
aria-describedby={addressInvalid ? describedBy : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Кнопка НИКОГДА не disabled по валидности: disabled-кнопка не
|
||||
диспатчит submit, и невалидная попытка молча ничего бы не делала
|
||||
вместо объяснения (тот же фикс, что в v2/LeadForm.tsx). */}
|
||||
<button type="submit" className={styles.cta}>
|
||||
Узнать цену
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className={styles.formHint}>
|
||||
Ничего не спишется и не позвонит: телефон мы спрашиваем, только если вы
|
||||
сами оставите заявку.
|
||||
</p>
|
||||
|
||||
{/* Живая область объявляется скринридеру при любой смене содержимого.
|
||||
Держим её в DOM постоянно — регион, добавленный в момент ошибки,
|
||||
часть скринридеров не озвучивает. */}
|
||||
<div id={feedbackId} role="status" aria-live="polite">
|
||||
{feedback.kind === "error" && (
|
||||
<div className={`${styles.formFeedback} ${styles.formFeedbackError}`}>
|
||||
<p className={styles.formFeedbackText}>{feedback.text}</p>
|
||||
</div>
|
||||
)}
|
||||
{feedback.kind === "info" && (
|
||||
<div className={styles.formFeedback}>
|
||||
<p className={styles.formFeedbackTitle}>{feedback.title}</p>
|
||||
{feedback.lines.map((line) => (
|
||||
<p key={line} className={styles.formFeedbackText}>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<section className={styles.section} aria-labelledby="sources-title">
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHead}>
|
||||
<h2 id="sources-title" className={styles.h2}>
|
||||
Откуда мы берём данные
|
||||
</h2>
|
||||
{/* Без числа групп в тексте: группы выводятся из реестра источников,
|
||||
и «два типа данных» уже однажды разъехалось с кодом — оценочные
|
||||
модели площадок участвуют в расчёте (estimator.py, IMV/Yandex
|
||||
blend), но в тексте их не было. */}
|
||||
<p className={styles.sectionLead}>
|
||||
Мы не опрашиваем экспертов и не берём цифры из головы. Данные
|
||||
разного происхождения отвечают на разные вопросы — поэтому мы держим
|
||||
их раздельно и показываем, что откуда.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.sourceGroups}>
|
||||
{SOURCE_GROUPS.map((group) => (
|
||||
<div key={group.title} className={styles.sourceGroup}>
|
||||
<h3 className={styles.sourceGroupTitle}>{group.title}</h3>
|
||||
<ul className={styles.sourceChips} role="list">
|
||||
{group.items.map((item) => (
|
||||
<li key={item} className={styles.sourceChip}>
|
||||
<span className={styles.sourceChipDot} aria-hidden="true" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className={styles.sourceGroupNote}>{group.note}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
/**
|
||||
* Faq — аккордеон на нативных <details>/<summary>. Серверный компонент.
|
||||
*
|
||||
* Почему без "use client": браузер уже умеет раскрывать details по Enter/Space,
|
||||
* ставит фокус на summary и сам сообщает состояние скринридеру. Самописный
|
||||
* аккордеон на useState потребовал бы вручную воспроизвести aria-expanded,
|
||||
* управление фокусом и клавиатуру — и работал бы хуже до гидратации. Клиентский
|
||||
* JS здесь не нужен вообще.
|
||||
*/
|
||||
|
||||
import { FAQ } from "../content";
|
||||
import styles from "../landing.module.css";
|
||||
|
||||
export function Faq() {
|
||||
return (
|
||||
<section
|
||||
className={`${styles.section} ${styles.sectionAlt}`}
|
||||
aria-labelledby="faq-title"
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHead}>
|
||||
<h2 id="faq-title" className={styles.h2}>
|
||||
Частые вопросы
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className={styles.faqList}>
|
||||
{FAQ.map((item) => (
|
||||
<details key={item.id} className={styles.faqItem}>
|
||||
<summary className={styles.faqSummary}>
|
||||
<span>{item.q}</span>
|
||||
<ChevronIcon />
|
||||
</summary>
|
||||
<div className={styles.faqAnswer}>
|
||||
{item.a.map((paragraph) => (
|
||||
<p key={paragraph}>{paragraph}</p>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronIcon() {
|
||||
return (
|
||||
<svg
|
||||
className={styles.faqChevron}
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<section className={styles.hero} aria-labelledby="hero-title">
|
||||
{/* Два вложенных div'а, а не два класса на одном: `.container` задаёт
|
||||
общую 1120px-сетку страницы и центрирует её, `.heroInner` — узкую
|
||||
колонку измерения (720px) ВНУТРИ неё, прижатую к левому краю. Пока оба
|
||||
класса висели на одном элементе, побеждал max-width: 720px, и весь
|
||||
первый экран уезжал вправо относительно всех секций ниже (на 1440px —
|
||||
на 200px). */}
|
||||
<div className={styles.container}>
|
||||
<div className={styles.heroInner}>
|
||||
<p className={styles.eyebrow}>Мера · оценка квартиры</p>
|
||||
|
||||
<h1 id="hero-title" className={styles.h1}>
|
||||
Сколько на самом деле стоит ваша квартира
|
||||
</h1>
|
||||
|
||||
<p className={styles.heroLead}>
|
||||
Введите адрес — покажем, за сколько продаются похожие квартиры рядом
|
||||
и, если по вашему дому или поблизости есть зарегистрированные
|
||||
сделки, за сколько их реально покупают. Без звонка риелтора и без
|
||||
визита оценщика.
|
||||
</p>
|
||||
|
||||
{/* Покрытие подано неравномерно намеренно — см. COVERED_CITIES в
|
||||
content.ts: список городов в реестре ≠ карта покрытия. */}
|
||||
<div className={styles.regionBadge}>
|
||||
<p className={styles.regionBadgeTitle}>
|
||||
<PinIcon />
|
||||
{REGION_NAME}
|
||||
</p>
|
||||
<p className={styles.regionBadgeCities}>
|
||||
Полное покрытие — {PRIMARY_CITY}. По остальным городам области (
|
||||
{SECONDARY_CITIES.join(", ")}) данных меньше, и оценка там может
|
||||
быть ориентировочной. По другим регионам не считаем вовсе — не
|
||||
хотим гадать.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AddressForm />
|
||||
|
||||
<p className={styles.heroNote}>
|
||||
«Мера» — сервис оценки вторичного жилья по рыночным данным. Мы не
|
||||
покупаем квартиры и не берём их на продажу: наша работа — показать
|
||||
цифру и то, откуда она взялась.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PinIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
/**
|
||||
* HowItWorks — три шага. Серверный компонент.
|
||||
*
|
||||
* Разметка — упорядоченный список <ol>: порядок шагов несёт смысл, и
|
||||
* скринридер объявит «список из 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 (
|
||||
<section className={styles.section} aria-labelledby="how-title">
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHead}>
|
||||
<h2 id="how-title" className={styles.h2}>
|
||||
Как это работает
|
||||
</h2>
|
||||
<p className={styles.sectionLead}>
|
||||
Три шага, никакой регистрации на входе.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol className={styles.steps} role="list">
|
||||
{STEPS.map((step, index) => (
|
||||
<li key={step.title} className={styles.step}>
|
||||
<span className={styles.stepNum} aria-hidden="true">
|
||||
ШАГ {index + 1}
|
||||
</span>
|
||||
<h3 className={styles.stepTitle}>{step.title}</h3>
|
||||
<p className={styles.stepText}>{step.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<footer className={styles.footer}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.footerGrid}>
|
||||
<div>
|
||||
<div className={styles.wordmark}>
|
||||
<span className={styles.wordmarkDot} aria-hidden="true" />
|
||||
МЕРА
|
||||
</div>
|
||||
<p className={styles.footerText} style={{ marginTop: 10 }}>
|
||||
Оценка квартир на вторичном рынке по сделкам и объявлениям.{" "}
|
||||
{REGION_NAME}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className={styles.footerTitle}>Связаться</p>
|
||||
<p className={styles.footerText}>
|
||||
Почта:{" "}
|
||||
<a className={styles.link} href={`mailto:${SUPPORT_EMAIL}`}>
|
||||
{SUPPORT_EMAIL}
|
||||
</a>
|
||||
</p>
|
||||
{telegramHref ? (
|
||||
<p className={styles.footerText}>
|
||||
Поддержка в Telegram:{" "}
|
||||
<a
|
||||
className={styles.link}
|
||||
href={telegramHref}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{SUPPORT_TELEGRAM_LABEL}
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className={styles.footerTitle}>Документы</p>
|
||||
<ul className={styles.footerLinks} role="list">
|
||||
<li>
|
||||
<PublicLink className={styles.link} to="oferta">
|
||||
Публичная оферта
|
||||
</PublicLink>
|
||||
</li>
|
||||
<li>
|
||||
<PublicLink className={styles.link} to="refund">
|
||||
Политика возврата
|
||||
</PublicLink>
|
||||
</li>
|
||||
<li>
|
||||
<PublicLink className={styles.link} to="privacy">
|
||||
Обработка персональных данных
|
||||
</PublicLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.footerBottom}>
|
||||
<span>© {year} МЕРА</span>
|
||||
{LEGAL_ENTITY && (
|
||||
<span>
|
||||
{LEGAL_ENTITY.name}, ИНН {LEGAL_ENTITY.inn}, ОГРН{" "}
|
||||
{LEGAL_ENTITY.ogrn}, {LEGAL_ENTITY.address}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
Оценка носит информационный характер и не является офертой или
|
||||
отчётом об оценке.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<header className={styles.header}>
|
||||
<div className={`${styles.container} ${styles.headerInner}`}>
|
||||
<div className={styles.wordmark}>
|
||||
<span className={styles.wordmarkDot} aria-hidden="true" />
|
||||
МЕРА
|
||||
</div>
|
||||
<p className={styles.headerTag}>
|
||||
Оценка вторичного жилья по рыночным данным · {REGION_NAME}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<section
|
||||
className={`${styles.section} ${styles.sectionAlt}`}
|
||||
aria-labelledby="get-title"
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHead}>
|
||||
<h2 id="get-title" className={styles.h2}>
|
||||
Что вы получите
|
||||
</h2>
|
||||
<p className={styles.sectionLead}>
|
||||
Не «примерную стоимость», а расчёт, который видно насквозь.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className={styles.deliverables} role="list">
|
||||
{DELIVERABLES.map((item) => (
|
||||
<li key={item.title} className={styles.deliverable}>
|
||||
<CheckIcon />
|
||||
<div>
|
||||
<h3 className={styles.deliverableTitle}>{item.title}</h3>
|
||||
<p className={styles.deliverableText}>{item.text}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className={styles.disclaimer}>
|
||||
<InfoIcon />
|
||||
<span>{DELIVERABLES_DISCLAIMER}</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg
|
||||
className={styles.checkIcon}
|
||||
width="17"
|
||||
height="17"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="17"
|
||||
height="17"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
style={{ flex: "0 0 auto", marginTop: 2 }}
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<line x1="12" y1="11" x2="12" y2="16.5" />
|
||||
<line x1="12" y1="7.6" x2="12" y2="7.7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
/** Значение <option> «моего города нет в списке» (то же, что в v1-форме). */
|
||||
const OTHER_CITY = "__other__";
|
||||
|
||||
/** Три состояния квартиры — дословно из макета (строка 734 исходника). */
|
||||
const CONDITIONS = ["Требует ремонта", "Хорошее", "Отличное"] as const;
|
||||
type Condition = (typeof CONDITIONS)[number];
|
||||
|
||||
type FieldName = "address" | "area" | "rooms" | "floor" | "condition";
|
||||
type FieldName = "address" | "city" | "area" | "rooms" | "floor" | "condition";
|
||||
|
||||
type Feedback = { kind: "none" } | { kind: "error"; field: FieldName; text: string };
|
||||
type Feedback =
|
||||
| { kind: "none" }
|
||||
| { kind: "error"; field: FieldName; text: string }
|
||||
| { kind: "info"; title: string; lines: readonly string[] };
|
||||
|
||||
const NONE: Feedback = { kind: "none" };
|
||||
|
||||
export function FreeCheckCard() {
|
||||
const addressId = useId();
|
||||
const cityId = useId();
|
||||
const areaId = useId();
|
||||
const roomsId = useId();
|
||||
const floorId = useId();
|
||||
|
|
@ -48,6 +82,7 @@ export function FreeCheckCard() {
|
|||
const feedbackId = useId();
|
||||
|
||||
const [address, setAddress] = useState("");
|
||||
const [city, setCity] = useState("");
|
||||
const [area, setArea] = useState("");
|
||||
const [rooms, setRooms] = useState("");
|
||||
const [floor, setFloor] = useState("");
|
||||
|
|
@ -55,6 +90,7 @@ export function FreeCheckCard() {
|
|||
const [feedback, setFeedback] = useState<Feedback>(NONE);
|
||||
|
||||
const addressRef = useRef<HTMLInputElement>(null);
|
||||
const cityRef = useRef<HTMLSelectElement>(null);
|
||||
const areaRef = useRef<HTMLInputElement>(null);
|
||||
const roomsRef = useRef<HTMLInputElement>(null);
|
||||
const floorRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -82,6 +118,33 @@ export function FreeCheckCard() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Город известен, только если его выбрали руками или назвали в тексте
|
||||
// адреса. Ничего не додумываем — см. шапку файла.
|
||||
const detected = detectCityInText(trimmedAddress);
|
||||
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 (!trimmedArea) {
|
||||
setFeedback({
|
||||
kind: "error",
|
||||
|
|
@ -122,15 +185,24 @@ export function FreeCheckCard() {
|
|||
return;
|
||||
}
|
||||
|
||||
if (city === "" && detected) {
|
||||
// Город распознали в тексте — синхронизируем селект, чтобы человек видел,
|
||||
// что именно мы поняли, и мог поправить.
|
||||
setCity(detected);
|
||||
}
|
||||
|
||||
// Уходим на отдельный экран проверки — там живёт автокомплит и результат.
|
||||
// Черновиком отдаём только то, что использует бесплатная проба покрытия:
|
||||
// адрес, комнаты, площадь. Этаж и состояние она не использует вовсе (см.
|
||||
// CoverageProbeInput на бэке) — их спросит платный шаг, когда появится,
|
||||
// и спросит на том же экране, а не тащит через переход.
|
||||
// Черновик кладём в sessionStorage, а НЕ в query-параметры: адрес в URL
|
||||
// попал бы в access-логи Caddy рядом с IP посетителя (см. estimate-draft.ts).
|
||||
// Этаж не передаём — его не использует ни проба покрытия, ни экран
|
||||
// /estimate, и повторно набирать одно короткое поле дешевле, чем хранить
|
||||
// лишнее. Состояние передаём: его спросили обязательным полем здесь.
|
||||
saveDraft({
|
||||
address: trimmedAddress,
|
||||
rooms: trimmedRooms,
|
||||
area: trimmedArea,
|
||||
city: resolvedCity,
|
||||
condition,
|
||||
});
|
||||
window.location.assign(PUBLIC_ROUTES.estimate);
|
||||
}
|
||||
|
|
@ -146,6 +218,33 @@ export function FreeCheckCard() {
|
|||
|
||||
<div className={styles.heroCardBody}>
|
||||
<form className={styles.heroForm} onSubmit={handleSubmit} noValidate>
|
||||
{/* Город — ПЕРВЫМ полем: одинаковые названия улиц есть в разных
|
||||
городах области, и вопрос «какой город» должен стоять до того,
|
||||
как человек наберёт улицу, а не после (#2576). */}
|
||||
<label className={styles.heroField} htmlFor={cityId}>
|
||||
<span className={styles.heroFieldLabel}>Город</span>
|
||||
<select
|
||||
id={cityId}
|
||||
ref={cityRef}
|
||||
className={`${styles.heroSelect} ${errorField === "city" ? styles.heroInputInvalid : ""}`}
|
||||
value={city}
|
||||
onChange={(event) => {
|
||||
setCity(event.target.value);
|
||||
clearFeedback();
|
||||
}}
|
||||
aria-invalid={errorField === "city" || undefined}
|
||||
aria-describedby={errorField === "city" ? describedBy : undefined}
|
||||
>
|
||||
<option value="">Выберите город</option>
|
||||
{COVERED_CITIES.map((label) => (
|
||||
<option key={label} value={label}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
<option value={OTHER_CITY}>Другой город</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className={styles.heroField} htmlFor={addressId}>
|
||||
<span className={styles.heroFieldLabel}>Адрес</span>
|
||||
<input
|
||||
|
|
@ -274,6 +373,16 @@ export function FreeCheckCard() {
|
|||
<p className={styles.heroFormFeedbackText}>{feedback.text}</p>
|
||||
</div>
|
||||
)}
|
||||
{feedback.kind === "info" && (
|
||||
<div className={styles.heroFormFeedback}>
|
||||
<p className={styles.heroFormFeedbackTitle}>{feedback.title}</p>
|
||||
{feedback.lines.map((line) => (
|
||||
<p key={line} className={styles.heroFormFeedbackText}>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,8 +9,26 @@
|
|||
* `FREE_SAMPLE_PLACEHOLDER.analogs`, цена отчёта из `COST_OF_ERROR_PLACEHOLDER`)
|
||||
* — ни одного нового числового литерала здесь не заведено, см. шапку
|
||||
* `../../marketing-v3.ts`.
|
||||
*
|
||||
* ПОРТ ИЗ v1 (`_components/Hero.tsx`), два блока внизу левой колонки:
|
||||
* - плашка покрытия: полное — `PRIMARY_CITY`, частично — `SECONDARY_CITIES`,
|
||||
* по другим регионам не считаем вовсе. Города берутся из реестра, не из
|
||||
* строки (см. COVERED_CITIES в content.ts: список городов ≠ карта
|
||||
* покрытия). В v3 этой границы не было нигде, кроме подписи в выпадашке
|
||||
* города в шапке — то есть человек узнавал про неё, только открыв список.
|
||||
* - строка «мы не покупаем квартиры и не берём их на продажу» — единственное
|
||||
* место, где сказано, чем сервис НЕ является; в v3 её не было.
|
||||
* Оба стоят В КОНЦЕ левой колонки намеренно: на узком экране колонка целиком
|
||||
* идёт до карточки проверки, значит человек прочитает ограничение раньше, чем
|
||||
* начнёт вводить адрес — то самое требование, ради которого плашка в v1 стояла
|
||||
* перед формой. Текст перенесён дословно.
|
||||
*/
|
||||
|
||||
import {
|
||||
PRIMARY_CITY,
|
||||
REGION_NAME,
|
||||
SECONDARY_CITIES,
|
||||
} from "../../content";
|
||||
import { ACCURACY_PLACEHOLDER, COST_OF_ERROR_PLACEHOLDER, FREE_SAMPLE_PLACEHOLDER } from "../../marketing-v3";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
|
|
@ -98,6 +116,24 @@ export function HeroV3() {
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Покрытие подано неравномерно намеренно — см. COVERED_CITIES в
|
||||
content.ts: список городов в реестре ≠ карта покрытия. */}
|
||||
<div className={styles.heroRegion}>
|
||||
<p className={styles.heroRegionTitle}>{REGION_NAME}</p>
|
||||
<p className={styles.heroRegionText}>
|
||||
Полное покрытие — {PRIMARY_CITY}. По остальным городам области (
|
||||
{SECONDARY_CITIES.join(", ")}) данных меньше, и оценка там может
|
||||
быть ориентировочной. По другим регионам не считаем вовсе — не
|
||||
хотим гадать.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className={styles.heroNote}>
|
||||
«Мера» — сервис оценки вторичного жилья по рыночным данным. Мы не
|
||||
покупаем квартиры и не берём их на продажу: наша работа — показать
|
||||
цифру и то, откуда она взялась.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FreeCheckCard />
|
||||
|
|
|
|||
|
|
@ -11,8 +11,43 @@
|
|||
* сделки отвечаем тем, что в продукте ЕСТЬ (сделки Росреестра + снятие
|
||||
* объявлений с публикации), а не макетной формулировкой про «отметки
|
||||
* продавцов», механики которых не существует.
|
||||
*
|
||||
* ВЕСЬ `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";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
import { PublicLink } from "../PublicLink";
|
||||
|
|
@ -22,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: [
|
||||
|
|
@ -33,36 +81,50 @@ const OBJECTIONS: readonly Objection[] = [
|
|||
q: "Почему так дёшево — это точно работает?",
|
||||
a: [
|
||||
"Дёшево, потому что расчёт автоматический: без выезда, без оценщика, без комиссии. Вы платите за данные, а не за часы специалиста.",
|
||||
"Точность прямо зависит от того, сколько нашлось сопоставимых объектов, — поэтому мы всегда пишем, на скольких объектах и сделках построен расчёт, и честно сообщаем, если данных по дому мало.",
|
||||
],
|
||||
},
|
||||
{
|
||||
q: "Чем это лучше бесплатных калькуляторов?",
|
||||
a: [
|
||||
"Калькулятор площадки считает по объявлениям этой же площадки — то есть по ценам, которые продавцы просят, а не получают.",
|
||||
"Мы смотрим сразу несколько площадок и добавляем зарегистрированные сделки, чтобы была видна разница между запрашиваемой и реальной ценой.",
|
||||
],
|
||||
},
|
||||
...fromFaq("how-accurate"),
|
||||
...fromFaq("vs-marketplace"),
|
||||
{
|
||||
q: "Это подписка? Спишут ли ещё раз?",
|
||||
a: [
|
||||
"Нет. Это разовый платёж за один отчёт — без подписки, автопродлений и скрытых списаний. Отчёт остаётся у вас по ссылке.",
|
||||
],
|
||||
},
|
||||
{
|
||||
q: "Мне начнут звонить после проверки?",
|
||||
a: [
|
||||
"Нет. Для расчёта нужны адрес и параметры квартиры — телефон мы не спрашиваем. Он появляется, только если вы сами решите оставить заявку, и всегда с отдельной галочкой согласия.",
|
||||
],
|
||||
},
|
||||
{
|
||||
q: "Это официальная оценка для банка?",
|
||||
a: [
|
||||
"Нет. Для ипотеки, суда, опеки и нотариуса нужен отчёт аккредитованного оценщика по 135-ФЗ — это отдельная процедура с выездом. МЕРА отвечает на другой вопрос: за сколько квартира реально продаётся на рынке сегодня.",
|
||||
],
|
||||
},
|
||||
...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)}
|
||||
<PublicLink to="privacy" className={styles.objAnswerLink}>
|
||||
{PRIVACY_PHRASE}
|
||||
</PublicLink>
|
||||
{text.slice(at + PRIVACY_PHRASE.length)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ObjectionsV3() {
|
||||
return (
|
||||
<section className={styles.objSection} aria-labelledby="obj-v3-title">
|
||||
|
|
@ -77,7 +139,7 @@ export function ObjectionsV3() {
|
|||
</PublicLink>
|
||||
</div>
|
||||
<div className={styles.objList}>
|
||||
{OBJECTIONS.map((o) => (
|
||||
{ITEMS.map((o) => (
|
||||
<details key={o.q} className={styles.objItem}>
|
||||
<summary className={styles.objSummary}>
|
||||
<span className={styles.objQuestion}>{o.q}</span>
|
||||
|
|
@ -85,7 +147,7 @@ export function ObjectionsV3() {
|
|||
</summary>
|
||||
<div className={styles.objAnswer}>
|
||||
{o.a.map((p) => (
|
||||
<p key={p.slice(0, 24)}>{p}</p>
|
||||
<p key={p.slice(0, 24)}>{renderAnswer(p)}</p>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* SourcesV3 — реестр источников данных в дизайне v3. Серверный компонент.
|
||||
*
|
||||
* ОТКУДА ВЗЯТО. Это порт `_components/DataSources.tsx` (лэндинг v1): в v3
|
||||
* реестра источников не было вовсе — единственное упоминание жило одной фразой
|
||||
* внутри ответа ObjectionsV3. Заголовок, лид и содержимое групп перенесены
|
||||
* ДОСЛОВНО: текст проходил юр-ревью, а группы (`SOURCE_GROUPS` в content.ts)
|
||||
* по-прежнему выводятся из `lib/source-registry.ts` — названия площадок здесь
|
||||
* не вбиты строками и не могут разъехаться с тем, что реально участвует в
|
||||
* расчёте (контракт честности #2211).
|
||||
*
|
||||
* ГДЕ СТОИТ И ПОЧЕМУ. Сразу после `AccuracyV3`. «Точность» утверждает, что мы
|
||||
* сверяем прогноз с фактом сделки; первый вопрос к такому утверждению — «на
|
||||
* каких данных». Держать ответ через две секции (в возражениях) значит дать
|
||||
* человеку усомниться раньше, чем он дочитает до ответа. Секция без своей
|
||||
* подложки — чередование фонов v3 (accSection surface → эта page-bg →
|
||||
* wygSection surface) сохраняется.
|
||||
*
|
||||
* Логотипов площадок нет — по той же причине, что и в v1: чужие товарные знаки
|
||||
* это отдельный юридический вопрос, а картинки пришлось бы тянуть с чужих
|
||||
* доменов. Текстовые чипы решают ту же задачу.
|
||||
*/
|
||||
|
||||
import { SOURCE_GROUPS } from "../../content";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
export function SourcesV3() {
|
||||
return (
|
||||
<section className={styles.srcSection} aria-labelledby="sources-v3-title">
|
||||
<div className={styles.srcHead}>
|
||||
<div className={styles.accEyebrow} aria-hidden="true">
|
||||
ОТКУДА ЦИФРЫ
|
||||
</div>
|
||||
<h2 id="sources-v3-title" className={styles.srcTitle}>
|
||||
Откуда мы берём данные
|
||||
</h2>
|
||||
{/* Без числа групп в тексте: группы выводятся из реестра источников,
|
||||
и «два типа данных» уже однажды разъехалось с кодом — оценочные
|
||||
модели площадок участвуют в расчёте (estimator.py, IMV/Yandex
|
||||
blend), но в тексте их не было. */}
|
||||
<p className={styles.srcLead}>
|
||||
Мы не опрашиваем экспертов и не берём цифры из головы. Данные разного
|
||||
происхождения отвечают на разные вопросы — поэтому мы держим их
|
||||
раздельно и показываем, что откуда.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className={styles.srcGrid} role="list">
|
||||
{SOURCE_GROUPS.map((group) => (
|
||||
<li key={group.title} className={styles.srcCard}>
|
||||
<h3 className={styles.srcCardTitle}>{group.title}</h3>
|
||||
<ul className={styles.srcChips} role="list">
|
||||
{group.items.map((item) => (
|
||||
<li key={item} className={styles.srcChip}>
|
||||
<span className={styles.srcChipDot} aria-hidden="true" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className={styles.srcNote}>{group.note}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* WhatYouGetV3 — «что вы получите» + дисклеймер 135-ФЗ в дизайне v3.
|
||||
* Серверный компонент.
|
||||
*
|
||||
* ОТКУДА ВЗЯТО. Порт `_components/WhatYouGet.tsx` (лэндинг v1). Заголовок, лид,
|
||||
* пункты (`DELIVERABLES`) и дисклеймер (`DELIVERABLES_DISCLAIMER`) перенесены
|
||||
* дословно из content.ts — там же разобрано, почему в списке нет PDF-отчёта.
|
||||
*
|
||||
* ЗАЧЕМ ЭТО ОБЯЗАТЕЛЬНО. В v3 не было ни списка того, что человек получает, ни
|
||||
* дисклеймера про 135-ФЗ. Второе — не украшение: «Мера» даёт рыночную оценку по
|
||||
* сопоставимым объектам, а не отчёт аккредитованного оценщика, и сказать об
|
||||
* этом нужно ровно там, где человек читает, что именно он купит. В v3 ближайшее
|
||||
* по смыслу было в ответе «Это официальная оценка для банка?» — то есть только
|
||||
* тому, кто сам догадался спросить.
|
||||
*
|
||||
* ГДЕ СТОИТ И ПОЧЕМУ. После `SourcesV3` и до «Цены ошибки»: «сверяем с фактом» →
|
||||
* «вот на каких данных» → «вот что вы получаете на руки» — доказательная часть
|
||||
* целиком, и всё это ДО первого разговора про деньги. Подложка `--b2c-surface`
|
||||
* с рамками — чередование фонов v3 (Sources page-bg → эта surface → игра
|
||||
* page-bg).
|
||||
*/
|
||||
|
||||
import { DELIVERABLES, DELIVERABLES_DISCLAIMER } from "../../content";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
export function WhatYouGetV3() {
|
||||
return (
|
||||
<section className={styles.wygSection} aria-labelledby="get-v3-title">
|
||||
<div className={styles.wygContainer}>
|
||||
<div className={styles.wygHead}>
|
||||
<div className={styles.accEyebrow} aria-hidden="true">
|
||||
ЧТО НА РУКАХ
|
||||
</div>
|
||||
<h2 id="get-v3-title" className={styles.wygTitle}>
|
||||
Что вы получите
|
||||
</h2>
|
||||
<p className={styles.wygLead}>
|
||||
Не «примерную стоимость», а расчёт, который видно насквозь.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className={styles.wygGrid} role="list">
|
||||
{DELIVERABLES.map((item) => (
|
||||
<li key={item.title} className={styles.wygItem}>
|
||||
<span className={styles.wygIcon} aria-hidden="true">
|
||||
✓
|
||||
</span>
|
||||
<div>
|
||||
<h3 className={styles.wygItemTitle}>{item.title}</h3>
|
||||
<p className={styles.wygItemText}>{item.text}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className={styles.wygDisclaimer}>{DELIVERABLES_DISCLAIMER}</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,8 +11,11 @@
|
|||
* Referer соседнего сайта. Живёт до закрытия вкладки — ровно столько, сколько
|
||||
* нужно для перехода между двумя страницами.
|
||||
*
|
||||
* Не заводить здесь ничего сверх этих трёх полей: чем больше сюда положить,
|
||||
* тем сильнее соблазн начать хранить черновики «на подольше».
|
||||
* Не заводить здесь ничего сверх того, что человек УЖЕ ввёл на лэндинге: чем
|
||||
* больше сюда положить, тем сильнее соблазн начать хранить черновики «на
|
||||
* подольше». `condition` появился по этому же правилу, а не вопреки ему —
|
||||
* карточка v3 спрашивает состояние обязательным полем, и до появления этого
|
||||
* поля ответ просто выбрасывался на переходе.
|
||||
*/
|
||||
|
||||
const KEY = "mera:estimate-draft";
|
||||
|
|
@ -23,6 +26,12 @@ export interface EstimateDraft {
|
|||
rooms?: string;
|
||||
area?: string;
|
||||
city?: string;
|
||||
/**
|
||||
* Состояние квартиры, как его назвал человек в карточке v3. Бесплатная проба
|
||||
* покрытия его не использует (`CoverageProbeInput` на бэке о нём не знает) —
|
||||
* он ждёт платного шага; хранится ровно один переход, как и всё остальное.
|
||||
*/
|
||||
condition?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -58,6 +67,8 @@ export function takeDraft(): EstimateDraft | null {
|
|||
rooms: typeof value.rooms === "string" ? value.rooms : undefined,
|
||||
area: typeof value.area === "string" ? value.area : undefined,
|
||||
city: typeof value.city === "string" ? value.city : undefined,
|
||||
condition:
|
||||
typeof value.condition === "string" ? value.condition : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -2832,3 +2846,236 @@
|
|||
margin-top: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
* Порт v1-блоков в дизайн v3 (волна «ничего не потерять при переключении
|
||||
* корня»): SourcesV3 (префикс src), WhatYouGetV3 (префикс wyg) и добавки к
|
||||
* герою/карточке проверки (префикс hero — дописаны сюда, а не в блок героя
|
||||
* выше, по правилу из шапки файла: «дописывай в конец, не переписывай чужие
|
||||
* блоки»).
|
||||
* ========================================================================== */
|
||||
|
||||
/* -- Герой: плашка покрытия и строка «мы не покупаем квартиры» ------------- */
|
||||
|
||||
/* Акцентная полоса слева, а не заливка: плашка должна читаться как оговорка
|
||||
рядом с обещанием, а не как ещё один CTA. Тот же приём, что у
|
||||
.stepsItemAccent (граница акцентом на светлой подложке) — сплошной заливкой
|
||||
accent красить нельзя, текст на ней не проходит AA (см. b2c-tokens.ts). */
|
||||
.heroRegion {
|
||||
margin-top: 24px;
|
||||
padding: 14px 16px;
|
||||
background: var(--b2c-surface);
|
||||
border: 1px solid var(--b2c-line);
|
||||
border-left: 3px solid var(--b2c-accent);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.heroRegionTitle {
|
||||
margin: 0 0 6px;
|
||||
font-family: var(--b2c-font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--b2c-accent-text);
|
||||
}
|
||||
|
||||
.heroRegionText {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--b2c-muted);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.heroNote {
|
||||
margin: 16px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--b2c-muted);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
/* Селект города в карточке проверки. Наследует поле ввода целиком и НЕ снимает
|
||||
appearance: без нативной стрелки селект неотличим от input, а рисовать свою
|
||||
пришлось бы фоновой картинкой — ради одного поля не стоит. */
|
||||
.heroSelect {
|
||||
composes: heroInput;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* -- SourcesV3 — «Откуда мы берём данные» ---------------------------------- */
|
||||
|
||||
.srcSection {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: clamp(48px, 6vw, 80px) clamp(20px, 5vw, 56px);
|
||||
}
|
||||
|
||||
.srcHead {
|
||||
max-width: 700px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.srcTitle {
|
||||
margin: 0 0 14px;
|
||||
font-size: clamp(26px, 3.4vw, 38px);
|
||||
line-height: 1.12;
|
||||
letter-spacing: -0.015em;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.srcLead {
|
||||
margin: 0;
|
||||
font-size: 16.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--b2c-muted);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.srcGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
|
||||
gap: 20px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.srcCard {
|
||||
padding: 26px;
|
||||
background: var(--b2c-surface);
|
||||
border: 1px solid var(--b2c-line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.srcCardTitle {
|
||||
margin: 0 0 14px;
|
||||
font-weight: 800;
|
||||
font-size: 17.5px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.srcChips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 0 0 14px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.srcChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 11px;
|
||||
background: var(--b2c-page-bg);
|
||||
border: 1px solid var(--b2c-line);
|
||||
border-radius: 999px;
|
||||
font-family: var(--b2c-font-mono);
|
||||
font-size: 11.5px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.srcChipDot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--b2c-accent);
|
||||
}
|
||||
|
||||
.srcNote {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--b2c-muted);
|
||||
}
|
||||
|
||||
/* -- WhatYouGetV3 — «Что вы получите» + дисклеймер 135-ФЗ ------------------ */
|
||||
|
||||
.wygSection {
|
||||
background: var(--b2c-surface);
|
||||
border-top: 1px solid var(--b2c-line);
|
||||
border-bottom: 1px solid var(--b2c-line);
|
||||
}
|
||||
|
||||
.wygContainer {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: clamp(48px, 6vw, 80px) clamp(20px, 5vw, 56px);
|
||||
}
|
||||
|
||||
.wygHead {
|
||||
max-width: 700px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.wygTitle {
|
||||
margin: 0 0 14px;
|
||||
font-size: clamp(26px, 3.4vw, 38px);
|
||||
line-height: 1.12;
|
||||
letter-spacing: -0.015em;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.wygLead {
|
||||
margin: 0;
|
||||
font-size: 16.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--b2c-muted);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.wygGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
|
||||
gap: 20px;
|
||||
margin: 0 0 24px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.wygItem {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
background: var(--b2c-page-bg);
|
||||
border: 1px solid var(--b2c-line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.wygIcon {
|
||||
flex: 0 0 auto;
|
||||
font-weight: 800;
|
||||
line-height: 1.4;
|
||||
color: var(--b2c-accent-text);
|
||||
}
|
||||
|
||||
.wygItemTitle {
|
||||
margin: 0 0 6px;
|
||||
font-weight: 800;
|
||||
font-size: 16.5px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.wygItemText {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--b2c-muted);
|
||||
}
|
||||
|
||||
/* Дисклеймер красится ink, а не muted: это ограничение услуги, а не сноска —
|
||||
приглушать его значило бы прятать. Полоса акцентом — как у .heroRegion. */
|
||||
.wygDisclaimer {
|
||||
margin: 0;
|
||||
padding: 16px 18px;
|
||||
background: var(--b2c-page-bg);
|
||||
border: 1px solid var(--b2c-line);
|
||||
border-left: 3px solid var(--b2c-accent);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--b2c-ink);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@ import { b2cVars, landingVars } from "./theme";
|
|||
|
||||
/**
|
||||
* ШАПКА И ПОДВАЛ ЖИВУТ В СТРАНИЦАХ, А НЕ ЗДЕСЬ (15.08.2026). Раньше layout
|
||||
* рендерил SiteHeader/SiteFooter для всего поддерева. С появлением второй
|
||||
* версии лэндинга (`v3/`, макет владельца) это стало блокером: вложенный
|
||||
* layout в Next добавляется К родительскому, а не заменяет его, поэтому
|
||||
* страница с собственной шапкой получала бы ДВЕ шапки сразу. Теперь layout
|
||||
* отвечает только за обёртку — шрифты, токены, skip-link, — а каждая страница
|
||||
* сама выбирает свой хром: v1 берёт SiteHeader/SiteFooter, v3 — HeaderV3/
|
||||
* FooterV3/StickyCtaV3.
|
||||
* рендерил общий хром для всего поддерева. С появлением второй версии
|
||||
* лэндинга (макет владельца) это стало блокером: вложенный layout в Next
|
||||
* добавляется К родительскому, а не заменяет его, поэтому страница с
|
||||
* собственной шапкой получала бы ДВЕ шапки сразу. Теперь layout отвечает
|
||||
* только за обёртку — шрифты, токены, skip-link, — а каждая страница сама
|
||||
* выбирает свой хром: лэндинг берёт HeaderV3/FooterV3/StickyCtaV3, внутренние
|
||||
* страницы (проверка, статьи, документы, юр-документы) — InnerHeader/FooterV3.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ import styles from "../landing.module.css";
|
|||
|
||||
import { PublicLink } from "../_components/PublicLink";
|
||||
|
||||
import { SiteFooter } from "../_components/SiteFooter";
|
||||
import { SiteHeader } from "../_components/SiteHeader";
|
||||
import { InnerHeader } from "../_components/InnerHeader";
|
||||
import { FooterV3 } from "../_components/v3/FooterV3";
|
||||
|
||||
/**
|
||||
* Публичная оферта на оказание информационно-аналитических услуг «МЕРА».
|
||||
|
|
@ -58,7 +58,7 @@ export default function MeraPublicOfferPage() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<InnerHeader />
|
||||
<main id="content" className={styles.main} tabIndex={-1}>
|
||||
<div className={`${styles.container} ${styles.doc}`}>
|
||||
<PublicLink
|
||||
|
|
@ -480,7 +480,7 @@ export default function MeraPublicOfferPage() {
|
|||
)}
|
||||
</div>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
<FooterV3 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +1,71 @@
|
|||
import { AccuracyV3 } from "./_components/v3/AccuracyV3";
|
||||
import { ArticlesV3 } from "./_components/v3/ArticlesV3";
|
||||
import { CostOfErrorV3 } from "./_components/v3/CostOfErrorV3";
|
||||
import { DealsTickerV3 } from "./_components/v3/DealsTickerV3";
|
||||
import { FooterV3 } from "./_components/v3/FooterV3";
|
||||
import { GuessGameV3 } from "./_components/v3/GuessGameV3";
|
||||
import { HeaderV3 } from "./_components/v3/HeaderV3";
|
||||
import { HeroV3 } from "./_components/v3/HeroV3";
|
||||
import { ObjectionsV3 } from "./_components/v3/ObjectionsV3";
|
||||
import { SourcesV3 } from "./_components/v3/SourcesV3";
|
||||
import { StepsV3 } from "./_components/v3/StepsV3";
|
||||
import { StickyCtaV3 } from "./_components/v3/StickyCtaV3";
|
||||
import { TwoPathsV3 } from "./_components/v3/TwoPathsV3";
|
||||
import { WhatYouGetV3 } from "./_components/v3/WhatYouGetV3";
|
||||
import styles from "./landing.module.css";
|
||||
|
||||
/**
|
||||
* Публичный лэндинг «МЕРА» — первое публичное лицо продукта.
|
||||
*
|
||||
* Серверный компонент: никаких хуков, никакого клиентского JS, кроме одного
|
||||
* острова — `AddressForm` (её "use client" оправдан состоянием поля и ответом
|
||||
* на сабмит). FAQ-аккордеон клиентским НЕ является: он на нативных <details>.
|
||||
* V3 СТАЛ КОРНЕМ (29.08.2026). До этого дня здесь жила сборка v1 (Hero /
|
||||
* HowItWorks / WhatYouGet / DataSources / Faq), а дизайн по макету владельца
|
||||
* лежал рядом, на `/mera-public/v3`, как превью для сравнения. Вариант
|
||||
* выбран — превью переехало сюда целиком, каталог `v3/` и компоненты v1
|
||||
* удалены, а `/v3` на публичном домене оставлен постоянным редиректом на
|
||||
* корень (`caddy/sites/apps.caddy`), чтобы закладки владельца не отдавали 404.
|
||||
*
|
||||
* Порядок блоков продиктован тем, в каком порядке у собственника квартиры
|
||||
* возникают вопросы: что это и работает ли у меня → как это устроено → что я
|
||||
* получу → можно ли этому верить → а если у меня возражение → как связаться.
|
||||
* Порядок секций — тот же, что был отлажен на превью: лента «прогноз → факт» →
|
||||
* герой с формой → как это устроено → точность → на каких данных → что вы
|
||||
* получите → игра «Угадай цену» → цена ошибки → два пути → возражения →
|
||||
* статьи. Доказательная часть (точность, источники, состав результата) идёт
|
||||
* подряд и целиком ДО первого разговора про деньги.
|
||||
*
|
||||
* МЕТАДАННЫЕ ЖИВУТ В `layout.tsx`, а не здесь. Заголовок и `robots: { index:
|
||||
* false }` там уже ровно те же, что стояли в `v3/page.tsx`; второй экземпляр
|
||||
* на уровне страницы ничего бы не менял, зато мог бы разъехаться с первым.
|
||||
* NOINDEX НЕ СНИМАТЬ вместе с этим переключением: он держится за
|
||||
* плейсхолдерные числа в `marketing-v3.ts` (гейт #2904), а не за то, какой
|
||||
* вариант дизайна стоит в корне.
|
||||
*
|
||||
* ЧЕГО ЗДЕСЬ ПО-ПРЕЖНЕМУ НЕТ:
|
||||
* - модалка проверки — сознательно заменена экраном /estimate (см.
|
||||
* StickyCtaV3), все CTA макетного `openModal` ведут туда;
|
||||
* - партнёрская заявка «продажи под ключ» — кнопка ведёт в Telegram
|
||||
* поддержки до появления формы и договора.
|
||||
*
|
||||
* ЧИСЛА в секциях «Точность» и «Цена ошибки» — плейсхолдеры из макета,
|
||||
* см. `marketing-v3.ts`: они обязаны быть заменены вычисляемыми до снятия
|
||||
* `robots: noindex`.
|
||||
*/
|
||||
|
||||
import { DataSources } from "./_components/DataSources";
|
||||
import { Faq } from "./_components/Faq";
|
||||
import { Hero } from "./_components/Hero";
|
||||
import { HowItWorks } from "./_components/HowItWorks";
|
||||
import { WhatYouGet } from "./_components/WhatYouGet";
|
||||
import styles from "./landing.module.css";
|
||||
|
||||
import { SiteFooter } from "./_components/SiteFooter";
|
||||
import { SiteHeader } from "./_components/SiteHeader";
|
||||
|
||||
export default function MeraPublicPage() {
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<HeaderV3 />
|
||||
<main id="content" className={styles.main} tabIndex={-1}>
|
||||
<Hero />
|
||||
<HowItWorks />
|
||||
<WhatYouGet />
|
||||
<DataSources />
|
||||
<Faq />
|
||||
<DealsTickerV3 />
|
||||
<HeroV3 />
|
||||
<StepsV3 />
|
||||
<AccuracyV3 />
|
||||
<SourcesV3 />
|
||||
<WhatYouGetV3 />
|
||||
<GuessGameV3 />
|
||||
<CostOfErrorV3 />
|
||||
<TwoPathsV3 />
|
||||
<ObjectionsV3 />
|
||||
<ArticlesV3 />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
<FooterV3 />
|
||||
<StickyCtaV3 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ import styles from "../landing.module.css";
|
|||
import { PublicLink } from "../_components/PublicLink";
|
||||
import { safeUrl } from "@/lib/safeUrl";
|
||||
|
||||
import { SiteFooter } from "../_components/SiteFooter";
|
||||
import { SiteHeader } from "../_components/SiteHeader";
|
||||
import { InnerHeader } from "../_components/InnerHeader";
|
||||
import { FooterV3 } from "../_components/v3/FooterV3";
|
||||
|
||||
/**
|
||||
* Политика обработки персональных данных по ст. 18.1 152-ФЗ.
|
||||
|
|
@ -83,7 +83,7 @@ export default function MeraPublicPrivacyPage() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<InnerHeader />
|
||||
<main id="content" className={styles.main} tabIndex={-1}>
|
||||
<div className={`${styles.container} ${styles.doc}`}>
|
||||
<PublicLink
|
||||
|
|
@ -316,7 +316,7 @@ export default function MeraPublicPrivacyPage() {
|
|||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
<FooterV3 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ import styles from "../landing.module.css";
|
|||
|
||||
import { PublicLink } from "../_components/PublicLink";
|
||||
|
||||
import { SiteFooter } from "../_components/SiteFooter";
|
||||
import { SiteHeader } from "../_components/SiteHeader";
|
||||
import { InnerHeader } from "../_components/InnerHeader";
|
||||
import { FooterV3 } from "../_components/v3/FooterV3";
|
||||
|
||||
/**
|
||||
* Политика возврата денежных средств.
|
||||
|
|
@ -50,7 +50,7 @@ export default function MeraPublicRefundPage() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<InnerHeader />
|
||||
<main id="content" className={styles.main} tabIndex={-1}>
|
||||
<div className={`${styles.container} ${styles.doc}`}>
|
||||
<PublicLink
|
||||
|
|
@ -215,7 +215,7 @@ export default function MeraPublicRefundPage() {
|
|||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
<FooterV3 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
import type { Metadata } from "next";
|
||||
|
||||
import { AccuracyV3 } from "../_components/v3/AccuracyV3";
|
||||
import { ArticlesV3 } from "../_components/v3/ArticlesV3";
|
||||
import { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3";
|
||||
import { DealsTickerV3 } from "../_components/v3/DealsTickerV3";
|
||||
import { FooterV3 } from "../_components/v3/FooterV3";
|
||||
import { HeaderV3 } from "../_components/v3/HeaderV3";
|
||||
import { GuessGameV3 } from "../_components/v3/GuessGameV3";
|
||||
import { HeroV3 } from "../_components/v3/HeroV3";
|
||||
import { ObjectionsV3 } from "../_components/v3/ObjectionsV3";
|
||||
import { StepsV3 } from "../_components/v3/StepsV3";
|
||||
import { StickyCtaV3 } from "../_components/v3/StickyCtaV3";
|
||||
import { TwoPathsV3 } from "../_components/v3/TwoPathsV3";
|
||||
import styles from "../landing.module.css";
|
||||
|
||||
/**
|
||||
* Лэндинг v3 — сборка по дизайн-макету владельца (15.08.2026).
|
||||
*
|
||||
* ЭТО ПРЕВЬЮ, А НЕ ЗАМЕНА. Живой публичный лэндинг остаётся на
|
||||
* `/mera-public` — он открыт на meraocenka.ru и его нельзя менять
|
||||
* посреди согласования дизайна. Новая версия лежит рядом, по
|
||||
* `/mera-public/v3`, чтобы её можно было открыть и сравнить с текущей
|
||||
* на одном и том же контенте. Переключение — отдельным решением, когда
|
||||
* версия принята.
|
||||
*
|
||||
* ОБНОВЛЕНИЕ 29.08 (макет «B2C модуль», Макс): добавлены лента
|
||||
* «прогноз → факт», игра «Угадай цену», «Два пути», «Возражения» и тизер
|
||||
* статей. Данные ленты/игры — реквизит из marketing-v3.ts под тем же гейтом
|
||||
* #2904 (контура сверки прогноза с фактом в продукте по-прежнему нет).
|
||||
*
|
||||
* ЧЕГО ЗДЕСЬ ПО-ПРЕЖНЕМУ НЕТ:
|
||||
* - страницы статей («МЕРА статьи»/«МЕРА статья») — карточки тизера
|
||||
* рендерятся без ссылок до появления раздела;
|
||||
* - модалка проверки — сознательно заменена экраном /estimate (см.
|
||||
* StickyCtaV3), все CTA макетного `openModal` ведут туда;
|
||||
* - партнёрская заявка «продажи под ключ» — кнопка ведёт в Telegram
|
||||
* поддержки до появления формы и договора.
|
||||
*
|
||||
* ЧИСЛА в секции «Точность» и «Цена ошибки» — плейсхолдеры из макета,
|
||||
* см. `marketing-v3.ts`: они обязаны быть заменены вычисляемыми до снятия
|
||||
* `robots: noindex`.
|
||||
*/
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Мера · расчёт стоимости квартиры по рыночным данным",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function MeraPublicV3Page() {
|
||||
return (
|
||||
<>
|
||||
<HeaderV3 />
|
||||
<main id="content" className={styles.main} tabIndex={-1}>
|
||||
<DealsTickerV3 />
|
||||
<HeroV3 />
|
||||
<StepsV3 />
|
||||
<AccuracyV3 />
|
||||
<GuessGameV3 />
|
||||
<CostOfErrorV3 />
|
||||
<TwoPathsV3 />
|
||||
<ObjectionsV3 />
|
||||
<ArticlesV3 />
|
||||
</main>
|
||||
<FooterV3 />
|
||||
<StickyCtaV3 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -81,6 +81,18 @@ const PUBLIC_PATHS = ["/mera-public"];
|
|||
*
|
||||
* Совпадение теперь запинено тестом (`__tests__/RouteGuard.publicPaths.test.ts`
|
||||
* читает Caddyfile), потому что комментарий этот инцидент уже не предотвратил.
|
||||
*
|
||||
* `/v3` УБРАН 29.08.2026 — сознательно, а не заодно с уборкой. Превью стало
|
||||
* корнем, страницы по этому адресу больше нет: на meraocenka.ru `/v3` теперь
|
||||
* 301 на `/` (матчер `@meraV3Gone`), и редирект отрабатывает в Caddy — до
|
||||
* приложения такой запрос не доходит вовсе, значит и гварду решать нечего. На
|
||||
* gendsgn.ru короткие адреса не раздаются, там `/v3` и раньше был 404. То
|
||||
* есть строка стала недостижимой, а недостижимая строка в списке публичных
|
||||
* путей — не «безобидный остаток»: она сообщает следующему читателю, что по
|
||||
* `/v3` есть публичная страница, и переживёт появление НОВОЙ страницы с тем
|
||||
* же адресом, автоматически сделав её публичной без отдельного решения.
|
||||
* Гейт ниже односторонний (Caddy → гвард), поэтому сам он лишнюю строку не
|
||||
* ловит — убирать приходится руками.
|
||||
*/
|
||||
const PUBLIC_SHORT_PATHS = [
|
||||
"/",
|
||||
|
|
@ -88,7 +100,6 @@ const PUBLIC_SHORT_PATHS = [
|
|||
"/oferta",
|
||||
"/refund",
|
||||
"/privacy",
|
||||
"/v3",
|
||||
"/articles",
|
||||
"/articles/kak-ocenit-kvartiru",
|
||||
"/docs",
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ describe("isPublicPath", () => {
|
|||
it("пропускает поддерево лэндинга (путь без basePath)", () => {
|
||||
expect(isPublicPath("/mera-public")).toBe(true);
|
||||
expect(isPublicPath("/mera-public/privacy")).toBe(true);
|
||||
expect(isPublicPath("/mera-public/v3")).toBe(true);
|
||||
expect(isPublicPath("/mera-public/estimate")).toBe(true);
|
||||
});
|
||||
|
||||
it("пропускает короткие адреса публичного домена", () => {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
* В репозитории уже есть соседние, но НЕ идентичные дисклеймеры — не путать и
|
||||
* не плодить пятый/шестой литерал с той же сутью:
|
||||
* - `src/app/mera-public/content.ts` → DELIVERABLES_DISCLAIMER (135-ФЗ)
|
||||
* - `src/app/mera-public/_components/SiteFooter.tsx`
|
||||
* - `src/app/mera-public/_components/v3/FooterV3.tsx`
|
||||
* - `src/app/mera-public/oferta/page.tsx`
|
||||
* - `backend/app/services/exporters/trade_in_pdf.py` (версия для PDF-отчёта)
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue