feat(mera/b2c): лэндинг v3 становится корнем публичного домена
Вариант дизайна выбран — превью с /mera-public/v3 переехало в корень целиком, вместе с блоками, перенесёнными из v1 предыдущим шагом. Каталог v3/ и компоненты v1 (Hero / HowItWorks / WhatYouGet / DataSources / Faq / AddressForm / SiteHeader / SiteFooter) удалены: их данные живут в content.ts и уже читаются секциями v3, поэтому ушла только вёрстка. Юридические страницы (оферта, возврат, ПДн) держались на SiteHeader/ SiteFooter — единственные, кто их ещё импортировал. Переведены на InnerHeader + FooterV3, тем же способом, что /estimate, /articles и /docs. ПЕРИМЕТР. /v3 убран из @meraPages, @meraShortSlash и @meraLongPages, но не выброшен: адрес две недели был открыт наружу и разошёлся по закладкам, поэтому вместо тишины белого списка — 301 на корень (@meraV3Gone, обе формы). На это заведена проверка в public-perimeter.test.ts по образцу соседних; PREVIEW_ONLY стал пустым, а не удалённым — он держит проверку «наружу не открыто ничего сверх известных страниц» двусторонней. Из PUBLIC_SHORT_PATHS гварда /v3 убран: редирект отрабатывает в Caddy, до приложения запрос не доходит, а недостижимая строка в списке публичных путей сделала бы публичной будущую страницу с тем же адресом. Гейт Caddy→гвард односторонний и сам бы этого не поймал — обоснование в коде. NOINDEX ОСТАВЛЕН. Он держится за плейсхолдерные числа marketing-v3.ts (#2904), а не за то, какой вариант дизайна стоит в корне. Путь в гейте обновлён на page.tsx: placeholdersInUse() молча пропускает ненайденный файл, поэтому устаревшая строка не покраснела бы, а перестала бы сканировать.
This commit is contained in:
parent
d4e106882c
commit
6976ca8915
21 changed files with 159 additions and 864 deletions
|
|
@ -347,11 +347,7 @@ meraocenka.ru {
|
||||||
# `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант
|
# `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант
|
||||||
# без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на
|
# без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на
|
||||||
# «/», и запрос закольцуется.
|
# «/», и запрос закольцуется.
|
||||||
# `/v3` — ВРЕМЕННОЕ превью второго варианта дизайна, а не публичная
|
@meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs
|
||||||
# страница: владелец сравнивает его с текущим лэндингом. Оно `noindex` и
|
|
||||||
# ни с одной страницы на него нет ссылки. Убрать эту строку в тот момент,
|
|
||||||
# когда вариант выберут и он станет корнем.
|
|
||||||
@meraPages path /estimate /oferta /refund /privacy /v3 /articles /articles/kak-ocenit-kvartiru /docs
|
|
||||||
handle @meraPages {
|
handle @meraPages {
|
||||||
rewrite * /trade-in/mera-public{path}
|
rewrite * /trade-in/mera-public{path}
|
||||||
reverse_proxy tradein-frontend:3000 {
|
reverse_proxy tradein-frontend:3000 {
|
||||||
|
|
@ -365,11 +361,25 @@ meraocenka.ru {
|
||||||
# голый 404 (так было и до этого PR, с момента #2615). Заодно это
|
# голый 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 {
|
handle @meraShortSlash {
|
||||||
redir * /{re.shortslash.1} permanent
|
redir * /{re.shortslash.1} permanent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# `/v3` — АДРЕС ПРЕВЬЮ, КОТОРОГО БОЛЬШЕ НЕТ. С 29.08.2026 выбранный вариант
|
||||||
|
# дизайна стоит в корне, каталог `v3/` во фронте удалён, и проксировать
|
||||||
|
# сюда нечего — Next отдал бы 404. Но адрес жил на публичном домене больше
|
||||||
|
# двух недель: владелец сравнивал по нему версии, и ссылка почти наверняка
|
||||||
|
# осталась в закладках и в переписке. Поэтому 301 на корень, а не молчание
|
||||||
|
# белого списка: человек по старой ссылке попадает ровно на то, что по ней
|
||||||
|
# и смотрел, только теперь это главная. Обе формы — со слэшем и без:
|
||||||
|
# `/v3/` вышел из @meraShortSlash вместе с остальным превью и иначе упал бы
|
||||||
|
# в catch-all 404.
|
||||||
|
@meraV3Gone path /v3 /v3/
|
||||||
|
handle @meraV3Gone {
|
||||||
|
redir * / permanent
|
||||||
|
}
|
||||||
|
|
||||||
# Длинные адреса поддерева → 301 на короткие. Один канонический адрес у
|
# Длинные адреса поддерева → 301 на короткие. Один канонический адрес у
|
||||||
# страницы, а не два работающих.
|
# страницы, а не два работающих.
|
||||||
#
|
#
|
||||||
|
|
@ -413,7 +423,7 @@ meraocenka.ru {
|
||||||
# порядок директив внутри `handle` определяет Caddy, и без неё `redir`
|
# порядок директив внутри `handle` определяет Caddy, и без неё `redir`
|
||||||
# выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу
|
# выполняется РАНЬШЕ `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 {
|
handle @meraLongPages {
|
||||||
route {
|
route {
|
||||||
uri strip_prefix /trade-in/mera-public
|
uri strip_prefix /trade-in/mera-public
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,11 @@ function read(rel: string): string {
|
||||||
|
|
||||||
/** Компоненты лэндинга v3, которые могут ссылаться на плейсхолдеры. */
|
/** Компоненты лэндинга v3, которые могут ссылаться на плейсхолдеры. */
|
||||||
const V3_SOURCES = [
|
const V3_SOURCES = [
|
||||||
"v3/page.tsx",
|
// 29.08.2026: лэндинг v3 переехал из `v3/page.tsx` в корень. Путь обязан
|
||||||
|
// ехать вместе с ним — `placeholdersInUse()` молча пропускает ненайденный
|
||||||
|
// файл (это правильно для переименований), поэтому устаревшая строка не
|
||||||
|
// покраснела бы, а просто перестала бы что-либо сканировать.
|
||||||
|
"page.tsx",
|
||||||
"_components/v3/HeroV3.tsx",
|
"_components/v3/HeroV3.tsx",
|
||||||
"_components/v3/AccuracyV3.tsx",
|
"_components/v3/AccuracyV3.tsx",
|
||||||
"_components/v3/FreeCheckCard.tsx",
|
"_components/v3/FreeCheckCard.tsx",
|
||||||
|
|
|
||||||
|
|
@ -92,11 +92,22 @@ describe("короткие адреса публичного домена", () =
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Разрешённый «лишний» путь: временное превью второго варианта дизайна.
|
* Раньше здесь стоял список PREVIEW_ONLY = ["/v3"] — путь, открытый наружу
|
||||||
* Ссылок на него нет, страница noindex; строка удаляется вместе с выбором
|
* сверх 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]+)/);
|
const meraPagesLine = meraBlock.match(/@meraPages path ([^\n]+)/);
|
||||||
|
|
||||||
|
|
@ -173,6 +184,34 @@ describe("короткие адреса публичного домена", () =
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ВЫБЫВШЕЕ ПРЕВЬЮ ОБЯЗАНО ОСТАТЬСЯ ЖИВЫМ АДРЕСОМ. `/v3` две недели был
|
||||||
|
* открыт на публичном домене — владелец сравнивал по нему варианты дизайна,
|
||||||
|
* и ссылка разошлась по закладкам и переписке. 29.08.2026 вариант стал
|
||||||
|
* корнем, каталог `v3/` во фронте удалён; проксировать этот путь больше
|
||||||
|
* нельзя (Next отдаст 404), но и просто выкинуть его из белого списка
|
||||||
|
* нельзя — это тот же класс дефекта, ради которого заведены проверки выше:
|
||||||
|
* рабочая ссылка молча превращается в 404.
|
||||||
|
*
|
||||||
|
* Правильный ответ ровно один — 301 на корень, и он проверяется здесь, а не
|
||||||
|
* держится на памяти о том, почему в конфиге появился ещё один матчер.
|
||||||
|
*/
|
||||||
|
it("адрес выбывшего превью /v3 отдаёт 301 на корень, а не 404", () => {
|
||||||
|
const matcher = meraBlock.match(/@meraV3Gone path ([^\n]+)/)?.[1] ?? "";
|
||||||
|
const paths = matcher.split(/\s+/).filter(Boolean);
|
||||||
|
expect(
|
||||||
|
paths,
|
||||||
|
"нет матчера @meraV3Gone — старые ссылки на превью упадут в catch-all 404",
|
||||||
|
).toEqual(["/v3", "/v3/"]);
|
||||||
|
|
||||||
|
const handler =
|
||||||
|
meraBlock.match(/handle @meraV3Gone \{([^}]*)\}/)?.[1] ?? "";
|
||||||
|
expect(
|
||||||
|
handler,
|
||||||
|
"матчер есть, а редиректа в нём нет — зонд выше прошёл бы вхолостую",
|
||||||
|
).toMatch(/redir\s+\*\s+\/\s+permanent/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("basePath не протекает в публичные ссылки", () => {
|
describe("basePath не протекает в публичные ссылки", () => {
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import { FAQ } from "../content";
|
||||||
/**
|
/**
|
||||||
* Гейт «при переключении корня на v3 ничего не потерялось».
|
* Гейт «при переключении корня на v3 ничего не потерялось».
|
||||||
*
|
*
|
||||||
* Корень meraocenka.ru отдаёт лэндинг v1, а новый дизайн живёт на /v3 и скоро
|
* Новый дизайн жил на /v3 и 29.08.2026 стал корнем meraocenka.ru. Перед этим
|
||||||
* станет корнем. Разведка нашла блоки, которых в v3 не было вовсе: реестр
|
* разведка нашла блоки лэндинга v1, которых в v3 не было вовсе: реестр
|
||||||
* источников, «что вы получите» с дисклеймером 135-ФЗ, плашка покрытия и
|
* источников, «что вы получите» с дисклеймером 135-ФЗ, плашка покрытия и
|
||||||
* строка «мы не покупаем квартиры» в герое, два вопроса из FAQ, город в форме.
|
* строка «мы не покупаем квартиры» в герое, два вопроса из FAQ, город в форме.
|
||||||
* Они перенесены — здесь проверяется, что перенос ЖИВОЙ: секции подключены к
|
* Они перенесены — здесь проверяется, что перенос ЖИВОЙ: секции подключены к
|
||||||
|
|
@ -29,7 +29,7 @@ function read(rel: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("страница v3 рендерит перенесённые секции", () => {
|
describe("страница v3 рендерит перенесённые секции", () => {
|
||||||
const page = read("v3/page.tsx");
|
const page = read("page.tsx");
|
||||||
|
|
||||||
it.each(["SourcesV3", "WhatYouGetV3"])("<%s /> подключён", (name) => {
|
it.each(["SourcesV3", "WhatYouGetV3"])("<%s /> подключён", (name) => {
|
||||||
expect(page).toContain(`<${name} />`);
|
expect(page).toContain(`<${name} />`);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -7,13 +7,13 @@ import { b2cVars, landingVars } from "./theme";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ШАПКА И ПОДВАЛ ЖИВУТ В СТРАНИЦАХ, А НЕ ЗДЕСЬ (15.08.2026). Раньше layout
|
* ШАПКА И ПОДВАЛ ЖИВУТ В СТРАНИЦАХ, А НЕ ЗДЕСЬ (15.08.2026). Раньше layout
|
||||||
* рендерил SiteHeader/SiteFooter для всего поддерева. С появлением второй
|
* рендерил общий хром для всего поддерева. С появлением второй версии
|
||||||
* версии лэндинга (`v3/`, макет владельца) это стало блокером: вложенный
|
* лэндинга (макет владельца) это стало блокером: вложенный layout в Next
|
||||||
* layout в Next добавляется К родительскому, а не заменяет его, поэтому
|
* добавляется К родительскому, а не заменяет его, поэтому страница с
|
||||||
* страница с собственной шапкой получала бы ДВЕ шапки сразу. Теперь layout
|
* собственной шапкой получала бы ДВЕ шапки сразу. Теперь layout отвечает
|
||||||
* отвечает только за обёртку — шрифты, токены, skip-link, — а каждая страница
|
* только за обёртку — шрифты, токены, skip-link, — а каждая страница сама
|
||||||
* сама выбирает свой хром: v1 берёт SiteHeader/SiteFooter, v3 — HeaderV3/
|
* выбирает свой хром: лэндинг берёт HeaderV3/FooterV3/StickyCtaV3, внутренние
|
||||||
* FooterV3/StickyCtaV3.
|
* страницы (проверка, статьи, документы, юр-документы) — InnerHeader/FooterV3.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ import styles from "../landing.module.css";
|
||||||
|
|
||||||
import { PublicLink } from "../_components/PublicLink";
|
import { PublicLink } from "../_components/PublicLink";
|
||||||
|
|
||||||
import { SiteFooter } from "../_components/SiteFooter";
|
import { InnerHeader } from "../_components/InnerHeader";
|
||||||
import { SiteHeader } from "../_components/SiteHeader";
|
import { FooterV3 } from "../_components/v3/FooterV3";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Публичная оферта на оказание информационно-аналитических услуг «МЕРА».
|
* Публичная оферта на оказание информационно-аналитических услуг «МЕРА».
|
||||||
|
|
@ -58,7 +58,7 @@ export default function MeraPublicOfferPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SiteHeader />
|
<InnerHeader />
|
||||||
<main id="content" className={styles.main} tabIndex={-1}>
|
<main id="content" className={styles.main} tabIndex={-1}>
|
||||||
<div className={`${styles.container} ${styles.doc}`}>
|
<div className={`${styles.container} ${styles.doc}`}>
|
||||||
<PublicLink
|
<PublicLink
|
||||||
|
|
@ -480,7 +480,7 @@ export default function MeraPublicOfferPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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, кроме одного
|
* V3 СТАЛ КОРНЕМ (29.08.2026). До этого дня здесь жила сборка v1 (Hero /
|
||||||
* острова — `AddressForm` (её "use client" оправдан состоянием поля и ответом
|
* HowItWorks / WhatYouGet / DataSources / Faq), а дизайн по макету владельца
|
||||||
* на сабмит). FAQ-аккордеон клиентским НЕ является: он на нативных <details>.
|
* лежал рядом, на `/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() {
|
export default function MeraPublicPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SiteHeader />
|
<HeaderV3 />
|
||||||
<main id="content" className={styles.main} tabIndex={-1}>
|
<main id="content" className={styles.main} tabIndex={-1}>
|
||||||
<Hero />
|
<DealsTickerV3 />
|
||||||
<HowItWorks />
|
<HeroV3 />
|
||||||
<WhatYouGet />
|
<StepsV3 />
|
||||||
<DataSources />
|
<AccuracyV3 />
|
||||||
<Faq />
|
<SourcesV3 />
|
||||||
|
<WhatYouGetV3 />
|
||||||
|
<GuessGameV3 />
|
||||||
|
<CostOfErrorV3 />
|
||||||
|
<TwoPathsV3 />
|
||||||
|
<ObjectionsV3 />
|
||||||
|
<ArticlesV3 />
|
||||||
</main>
|
</main>
|
||||||
<SiteFooter />
|
<FooterV3 />
|
||||||
|
<StickyCtaV3 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ import styles from "../landing.module.css";
|
||||||
import { PublicLink } from "../_components/PublicLink";
|
import { PublicLink } from "../_components/PublicLink";
|
||||||
import { safeUrl } from "@/lib/safeUrl";
|
import { safeUrl } from "@/lib/safeUrl";
|
||||||
|
|
||||||
import { SiteFooter } from "../_components/SiteFooter";
|
import { InnerHeader } from "../_components/InnerHeader";
|
||||||
import { SiteHeader } from "../_components/SiteHeader";
|
import { FooterV3 } from "../_components/v3/FooterV3";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Политика обработки персональных данных по ст. 18.1 152-ФЗ.
|
* Политика обработки персональных данных по ст. 18.1 152-ФЗ.
|
||||||
|
|
@ -83,7 +83,7 @@ export default function MeraPublicPrivacyPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SiteHeader />
|
<InnerHeader />
|
||||||
<main id="content" className={styles.main} tabIndex={-1}>
|
<main id="content" className={styles.main} tabIndex={-1}>
|
||||||
<div className={`${styles.container} ${styles.doc}`}>
|
<div className={`${styles.container} ${styles.doc}`}>
|
||||||
<PublicLink
|
<PublicLink
|
||||||
|
|
@ -316,7 +316,7 @@ export default function MeraPublicPrivacyPage() {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<SiteFooter />
|
<FooterV3 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ import styles from "../landing.module.css";
|
||||||
|
|
||||||
import { PublicLink } from "../_components/PublicLink";
|
import { PublicLink } from "../_components/PublicLink";
|
||||||
|
|
||||||
import { SiteFooter } from "../_components/SiteFooter";
|
import { InnerHeader } from "../_components/InnerHeader";
|
||||||
import { SiteHeader } from "../_components/SiteHeader";
|
import { FooterV3 } from "../_components/v3/FooterV3";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Политика возврата денежных средств.
|
* Политика возврата денежных средств.
|
||||||
|
|
@ -50,7 +50,7 @@ export default function MeraPublicRefundPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SiteHeader />
|
<InnerHeader />
|
||||||
<main id="content" className={styles.main} tabIndex={-1}>
|
<main id="content" className={styles.main} tabIndex={-1}>
|
||||||
<div className={`${styles.container} ${styles.doc}`}>
|
<div className={`${styles.container} ${styles.doc}`}>
|
||||||
<PublicLink
|
<PublicLink
|
||||||
|
|
@ -215,7 +215,7 @@ export default function MeraPublicRefundPage() {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<SiteFooter />
|
<FooterV3 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,81 +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 { 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";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Лэндинг v3 — сборка по дизайн-макету владельца (15.08.2026).
|
|
||||||
*
|
|
||||||
* ЭТО ПРЕВЬЮ, А НЕ ЗАМЕНА. Живой публичный лэндинг остаётся на
|
|
||||||
* `/mera-public` — он открыт на meraocenka.ru и его нельзя менять
|
|
||||||
* посреди согласования дизайна. Новая версия лежит рядом, по
|
|
||||||
* `/mera-public/v3`, чтобы её можно было открыть и сравнить с текущей
|
|
||||||
* на одном и том же контенте. Переключение — отдельным решением, когда
|
|
||||||
* версия принята.
|
|
||||||
*
|
|
||||||
* ОБНОВЛЕНИЕ 29.08 (макет «B2C модуль», Макс): добавлены лента
|
|
||||||
* «прогноз → факт», игра «Угадай цену», «Два пути», «Возражения» и тизер
|
|
||||||
* статей. Данные ленты/игры — реквизит из marketing-v3.ts под тем же гейтом
|
|
||||||
* #2904 (контура сверки прогноза с фактом в продукте по-прежнему нет).
|
|
||||||
*
|
|
||||||
* ПОРТ v1-БЛОКОВ (перед тем, как v3 станет корнем): реестр источников
|
|
||||||
* (`SourcesV3`) и «что вы получите» с дисклеймером 135-ФЗ (`WhatYouGetV3`)
|
|
||||||
* встали сразу после «Точности» — доказательная часть идёт подряд: сверяем
|
|
||||||
* прогноз с фактом → вот на каких данных → вот что вы получаете на руки, и всё
|
|
||||||
* это до первого разговора про деньги. Плашка покрытия и строка «мы не
|
|
||||||
* покупаем квартиры» вернулись в героя, два вопроса из `FAQ` — в
|
|
||||||
* «Возражения» (обоснования — в докстрингах компонентов).
|
|
||||||
*
|
|
||||||
* ЧЕГО ЗДЕСЬ ПО-ПРЕЖНЕМУ НЕТ:
|
|
||||||
* - страницы статей («МЕРА статьи»/«МЕРА статья») — карточки тизера
|
|
||||||
* рендерятся без ссылок до появления раздела;
|
|
||||||
* - модалка проверки — сознательно заменена экраном /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 />
|
|
||||||
<SourcesV3 />
|
|
||||||
<WhatYouGetV3 />
|
|
||||||
<GuessGameV3 />
|
|
||||||
<CostOfErrorV3 />
|
|
||||||
<TwoPathsV3 />
|
|
||||||
<ObjectionsV3 />
|
|
||||||
<ArticlesV3 />
|
|
||||||
</main>
|
|
||||||
<FooterV3 />
|
|
||||||
<StickyCtaV3 />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -81,6 +81,18 @@ const PUBLIC_PATHS = ["/mera-public"];
|
||||||
*
|
*
|
||||||
* Совпадение теперь запинено тестом (`__tests__/RouteGuard.publicPaths.test.ts`
|
* Совпадение теперь запинено тестом (`__tests__/RouteGuard.publicPaths.test.ts`
|
||||||
* читает Caddyfile), потому что комментарий этот инцидент уже не предотвратил.
|
* читает Caddyfile), потому что комментарий этот инцидент уже не предотвратил.
|
||||||
|
*
|
||||||
|
* `/v3` УБРАН 29.08.2026 — сознательно, а не заодно с уборкой. Превью стало
|
||||||
|
* корнем, страницы по этому адресу больше нет: на meraocenka.ru `/v3` теперь
|
||||||
|
* 301 на `/` (матчер `@meraV3Gone`), и редирект отрабатывает в Caddy — до
|
||||||
|
* приложения такой запрос не доходит вовсе, значит и гварду решать нечего. На
|
||||||
|
* gendsgn.ru короткие адреса не раздаются, там `/v3` и раньше был 404. То
|
||||||
|
* есть строка стала недостижимой, а недостижимая строка в списке публичных
|
||||||
|
* путей — не «безобидный остаток»: она сообщает следующему читателю, что по
|
||||||
|
* `/v3` есть публичная страница, и переживёт появление НОВОЙ страницы с тем
|
||||||
|
* же адресом, автоматически сделав её публичной без отдельного решения.
|
||||||
|
* Гейт ниже односторонний (Caddy → гвард), поэтому сам он лишнюю строку не
|
||||||
|
* ловит — убирать приходится руками.
|
||||||
*/
|
*/
|
||||||
const PUBLIC_SHORT_PATHS = [
|
const PUBLIC_SHORT_PATHS = [
|
||||||
"/",
|
"/",
|
||||||
|
|
@ -88,7 +100,6 @@ const PUBLIC_SHORT_PATHS = [
|
||||||
"/oferta",
|
"/oferta",
|
||||||
"/refund",
|
"/refund",
|
||||||
"/privacy",
|
"/privacy",
|
||||||
"/v3",
|
|
||||||
"/articles",
|
"/articles",
|
||||||
"/articles/kak-ocenit-kvartiru",
|
"/articles/kak-ocenit-kvartiru",
|
||||||
"/docs",
|
"/docs",
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ describe("isPublicPath", () => {
|
||||||
it("пропускает поддерево лэндинга (путь без basePath)", () => {
|
it("пропускает поддерево лэндинга (путь без basePath)", () => {
|
||||||
expect(isPublicPath("/mera-public")).toBe(true);
|
expect(isPublicPath("/mera-public")).toBe(true);
|
||||||
expect(isPublicPath("/mera-public/privacy")).toBe(true);
|
expect(isPublicPath("/mera-public/privacy")).toBe(true);
|
||||||
expect(isPublicPath("/mera-public/v3")).toBe(true);
|
expect(isPublicPath("/mera-public/estimate")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("пропускает короткие адреса публичного домена", () => {
|
it("пропускает короткие адреса публичного домена", () => {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
* В репозитории уже есть соседние, но НЕ идентичные дисклеймеры — не путать и
|
* В репозитории уже есть соседние, но НЕ идентичные дисклеймеры — не путать и
|
||||||
* не плодить пятый/шестой литерал с той же сутью:
|
* не плодить пятый/шестой литерал с той же сутью:
|
||||||
* - `src/app/mera-public/content.ts` → DELIVERABLES_DISCLAIMER (135-ФЗ)
|
* - `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`
|
* - `src/app/mera-public/oferta/page.tsx`
|
||||||
* - `backend/app/services/exporters/trade_in_pdf.py` (версия для PDF-отчёта)
|
* - `backend/app/services/exporters/trade_in_pdf.py` (версия для PDF-отчёта)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue