feat(mera/b2c): раздел статей и хаб документов (макет 29.08) + числа статьи заменены на замер #3223

Merged
bot-backend merged 1 commit from feat/b2c-articles-docs into main 2026-08-29 12:56:44 +00:00
15 changed files with 1190 additions and 71 deletions

View file

@ -351,7 +351,7 @@ meraocenka.ru {
# страница: владелец сравнивает его с текущим лэндингом. Оно `noindex` и
# ни с одной страницы на него нет ссылки. Убрать эту строку в тот момент,
# когда вариант выберут и он станет корнем.
@meraPages path /estimate /oferta /refund /privacy /v3
@meraPages path /estimate /oferta /refund /privacy /v3 /articles /articles/kak-ocenit-kvartiru /docs
handle @meraPages {
rewrite * /trade-in/mera-public{path}
reverse_proxy tradein-frontend:3000 {
@ -365,7 +365,7 @@ meraocenka.ru {
# голый 404 (так было и до этого PR, с момента #2615). Заодно это
# замыкает цепочку для длинных адресов со слэшем: они приходят на короткий
# со слэшем и здесь нормализуются.
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|v3)/$
@meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|v3|articles|articles/kak-ocenit-kvartiru|docs)/$
handle @meraShortSlash {
redir * /{re.shortslash.1} permanent
}
@ -413,7 +413,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/
@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/
handle @meraLongPages {
route {
uri strip_prefix /trade-in/mera-public

View file

@ -44,6 +44,10 @@ const V3_SOURCES = [
// Макет 29.08: лента и игра тоже стоят на реквизитных величинах.
"_components/v3/DealsTickerV3.tsx",
"_components/v3/GuessGameV3.tsx",
// Раздел статей (29.08) — плитки §4 статьи берут те же плейсхолдеры. Без
// этой строки гейт остался бы зелёным при снятии noindex: он сканировал бы
// только лэндинг, а величины уже живут и на статье.
"articles/articles-content.ts",
];
const PLACEHOLDER_EXPORTS = [

View file

@ -147,6 +147,32 @@ describe("короткие адреса публичного домена", () =
).toBe(true);
}
});
/**
* ТРЕТИЙ СПИСОК, до 29.08.2026 не проверявшийся ничем. `@meraPages path`
* матчит адрес БЕЗ завершающего слэша, поэтому `/docs/` мимо него проходит и
* улетает в catch-all 404. Нормализует такие адреса отдельный матчер
* `@meraShortSlash`, и его альтернация ещё одно место, где список страниц
* можно молча не дополнить. Ровно тот же класс дефекта, ради которого
* заведены две проверки выше; здесь он до сих пор был не закрыт.
*/
it("каждый короткий адрес нормализуется и со слэшем на конце", () => {
const slashLine =
meraBlock.match(/@meraShortSlash path_regexp \w+ \^\/\(([^)]+)\)/)?.[1] ??
"";
const alternatives = slashLine.split("|").filter(Boolean);
expect(
alternatives.length,
"не найдена альтернация @meraShortSlash — зонд сломан, проверка ниже пройдёт вхолостую",
).toBeGreaterThan(0);
for (const route of [...Object.values(PUBLIC_ROUTES), ...PREVIEW_ONLY]) {
if (route === "/") continue; // корень слэшем и так заканчивается
expect(
alternatives.includes(route.slice(1)),
`${route}/ (со слэшем) не нормализуется — адрес отдаст 404 вместо 301`,
).toBe(true);
}
});
});
describe("basePath не протекает в публичные ссылки", () => {

View file

@ -1,21 +1,22 @@
import { B2B_URL } from "../../content";
import styles from "../../landing-v3.module.css";
import { PublicLink } from "../PublicLink";
import { B2B_URL } from "../content";
import styles from "../landing-v3.module.css";
import { PublicLink } from "./PublicLink";
/**
* Шапка страницы проверки намеренно короче лэндинговой (`v3/HeaderV3.tsx`).
* Короткая шапка внутренних страниц проверка (`estimate/`), статьи
* (`articles/`), документы (`docs/`). Намеренно короче лэндинговой
* (`v3/HeaderV3.tsx`).
*
* Из неё убраны навигация по секциям лэндинга, селектор города и CTA
* «Проверить за 30 секунд». Причина у всех трёх одна: человек уже на экране
* проверки. Якоря вроде `#accuracy` тут просто не на что навести (секций нет
* на этой странице), город выбирается прямо в форме а второй селектор
* города в шапке означал бы два места, задающих одно значение, а CTA вёл бы
* на страницу, где посетитель уже стоит.
* «Проверить за 30 секунд». Причина у всех трёх одна: человек уже ушёл с
* лэндинга. Якоря вроде `#accuracy` тут просто не на что навести (секций нет
* на этих страницах), город выбирается прямо в форме проверки а второй
* селектор города в шапке означал бы два места, задающих одно значение.
*
* Логотип отсюда ссылка на главную: это единственный обратный путь на
* лэндинг, и он же то, что человек инстинктивно нажимает.
*/
export function EstimateHeader() {
export function InnerHeader() {
return (
<header className={styles.hdrRoot}>
<div className={styles.hdrInner}>

View file

@ -1,34 +1,13 @@
/**
* ArticlesV3 «Разборы на данных» (макет 29.08, секция «V3 статьи»).
*
* Раздела статей в продукте НЕ СУЩЕСТВУЕТ (страницы «МЕРА статьи»/«МЕРА
* статья» из макета следующий шард). Поэтому карточки рендерятся <article>,
* а НЕ <a>: ссылка «в никуда» на превью читалась бы как сломанная навигация.
* Ссылка «Все статьи » по той же причине не рендерится вовсе появится
* вместе с разделом. Темы и рубрики карточек из макета дословно.
* Раздел статей существует с 29.08: живая статья одна её карточка ведёт на
* страницу; остальные две честно помечены «СКОРО» и ссылками не являются.
*/
import { PublicLink } from "../PublicLink";
import styles from "../../landing-v3.module.css";
interface ArticleCard {
readonly rubric: string;
readonly title: string;
}
const ARTICLES: readonly ArticleCard[] = [
{
rubric: "ЦЕНООБРАЗОВАНИЕ · 6 МИН",
title: "Как оценить квартиру для продажи — без риелтора и «на глазок»",
},
{
rubric: "СРОКИ · 5 МИН",
title: "Сколько продаётся квартира в 2026 году и что «подвешивает» объявление",
},
{
rubric: "ТОРГ · 7 МИН",
title: "Завысить и торговаться или поставить в рынок: что выгоднее",
},
];
import { ARTICLE_CARDS } from "../../articles/articles-content";
export function ArticlesV3() {
return (
@ -37,14 +16,32 @@ export function ArticlesV3() {
<h2 id="articles-v3-title" className={styles.artTitle}>
Разборы на данных
</h2>
<PublicLink to="articles" className={styles.artAllLink}>
Все статьи
</PublicLink>
</div>
<div className={styles.artGrid}>
{ARTICLES.map((a) => (
<article key={a.title} className={styles.artCard}>
<div className={styles.artRubric}>{a.rubric}</div>
<div className={styles.artCardTitle}>{a.title}</div>
</article>
))}
{ARTICLE_CARDS.map((a) =>
a.route ? (
<PublicLink
key={a.title}
to={a.route}
className={styles.artCardLink}
>
<div className={styles.artRubric}>{a.rubric}</div>
<div className={styles.artCardTitle}>{a.title}</div>
<div className={styles.artCardLead}>{a.lead}</div>
<div className={styles.artReadMore}>Читать </div>
</PublicLink>
) : (
<article key={a.title} className={styles.artCard}>
<div className={styles.artRubric}>{a.rubric}</div>
<div className={styles.artCardTitle}>{a.title}</div>
<div className={styles.artCardLead}>{a.lead}</div>
<div className={styles.artSoon}>СКОРО</div>
</article>
),
)}
</div>
</section>
);

View file

@ -14,8 +14,11 @@
* приём платежей не подключён (см. TODO у нижней строки) заявлять способы
* оплаты, которых нет, подпадает под ст. 5 ФЗ «О рекламе».
*
* «Статьи» в «Разделах» неактивны тем же образом, что в шапке (HeaderV3):
* раздела нет в коде.
* «Статьи» в «Разделах» стали ссылкой 29.08.2026 раздел появился
* (`articles/`). До этого пункт был неактивен тем же образом, что в шапке
* (HeaderV3). «Документы и условия» хаб `docs/`: он НЕ заменяет прямые
* ссылки на оферту/возврат/политику (они обязаны быть в подвале и остаются),
* а добавляет одну точку входа с тарифами и порядком возврата.
*
* ССЫЛКИ КОРОТКИЕ (15.08.2026). «Проверить квартиру» ведёт на `/estimate`
* отдельный экран проверки, а не на якорь формы в герое. «МЕРА для бизнеса»
@ -64,13 +67,9 @@ export function FooterV3() {
<PublicLink to="estimate" className={styles.ftrLink}>
Проверить квартиру
</PublicLink>
<span
className={styles.ftrLinkDisabled}
aria-disabled="true"
title="Раздел скоро откроется"
>
<PublicLink to="articles" className={styles.ftrLink}>
Статьи
</span>
</PublicLink>
<a href={B2B_URL} className={styles.ftrLink}>
МЕРА для бизнеса
</a>
@ -115,6 +114,9 @@ export function FooterV3() {
<PublicLink to="privacy" className={styles.ftrLink}>
Обработка персональных данных
</PublicLink>
<PublicLink to="docs" className={styles.ftrLink}>
Документы и условия
</PublicLink>
</div>
</div>
</div>

View file

@ -5,16 +5,17 @@
* Логотип НЕ ссылка: на публичном домене корень это сам лэндинг, то есть
* ссылка вела бы на текущую же страницу.
*
* НЕАКТИВНЫЕ ПУНКТЫ. «Статьи», «Проверьте себя» и «Продажа под ключ»
* разделов нет в коде: ни маршрута, ни секции. Раньше три якоря вели на
* `/mera-public#game` и `#turnkey`, которых не существует НИГДЕ, то есть
* клик уводил на другую страницу и там ничего не находил. Пункты приведены к
* тому же виду, что «Статьи»: видны, но не кликабельны, пока раздела нет.
* НЕАКТИВНЫЕ ПУНКТЫ (29.08.2026). Осталась одна «Продажа под ключ»
* партнёрской формы нет ни в коде, ни у владельца, и пункт остаётся видимым,
* но некликабельным. «Проверьте себя» и «Статьи» раньше стояли рядом с ней по
* той же причине; теперь секция игры (`#game`, GuessGameV3) и раздел статей
* (`/articles`) существуют, поэтому оба пункта снова ведут по назначению.
* Правило прежнее: пункт становится ссылкой ТОЛЬКО когда цель существует.
*
* ЯКОРЯ голые (`#accuracy`, `#check`), потому что эта шапка рендерится
* только на странице, где эти секции и лежат. На юридических страницах стоит
* другая шапка (`_components/SiteHeader.tsx`), на `/estimate` своя,
* укороченная (`_components/estimate/EstimateHeader.tsx`): туда переносить
* укороченная (`_components/InnerHeader.tsx`): туда переносить
* навигацию по секциям лэндинга незачем, там человек уже пришёл считать.
*
* «Для бизнеса» ведёт на АБСОЛЮТНЫЙ адрес B2B-контура: относительный «/» на
@ -26,6 +27,7 @@
import { B2B_URL } from "../../content";
import styles from "../../landing-v3.module.css";
import { PublicLink } from "../PublicLink";
import { CityPicker } from "./CityPicker";
@ -47,16 +49,19 @@ export function HeaderV3() {
<a href="#accuracy" className={styles.hdrNavLink}>
Точность
</a>
{["Проверьте себя", "Продажа под ключ", "Статьи"].map((name) => (
<span
key={name}
className={styles.hdrNavDisabled}
aria-disabled="true"
title="Раздел скоро откроется"
>
{name}
</span>
))}
<a href="#game" className={styles.hdrNavLink}>
Проверьте себя
</a>
<span
className={styles.hdrNavDisabled}
aria-disabled="true"
title="Раздел скоро откроется"
>
Продажа под ключ
</span>
<PublicLink to="articles" className={styles.hdrNavLink}>
Статьи
</PublicLink>
</nav>
<div className={styles.hdrRight}>

View file

@ -0,0 +1,181 @@
/**
* Контент раздела «Статьи» макет 29.08 («МЕРА статья.dc.html», текст Макса).
*
* ЧИСЛА ИЗ МАКЕТА СНЯТЫ (29.08.2026). Макет утверждал «по нашим данным,
* средний разрыв между первой ценой в объявлении и ценой сделки 4,1%, а у
* провисевших дольше трёх месяцев доходит до 9%», «расхождение с кадастровой
* до 40%» и плитки 4,3% / ×2,4 / 83%. Прод-замер (poincare, 29.08.2026)
* не подтвердил ни одно:
*
* | макет | что показал замер |
* | разрыв объявлениесделка | ИЗМЕРИТЬ НЕЛЬЗЯ: связки листинга со сделкой |
* | | в данных нет (cadastral_number пуст у всех |
* | | 108 623 сделок и 111 693 объявлений) |
* | «до 9%» у долгостоя | 62% снижали, медиана 4,9% (n=2513) |
* | кадастровая «до 40%» | ПРОВЕРИТЬ НЕЧЕМ: кадастровой стоимости |
* | | ПОМЕЩЕНИЙ нет ни в одной базе |
* | 4,3% за месяц простоя | медиана внутрилистинговой просадки 1,1%/мес |
* | ×2,4 дольше продаётся | 79 дн против 76 ×1,04 |
* | 83% в ценовой коридор | коридор шириной 47% от цены не точность |
*
* Поэтому §1 и §4 стоят на СВОЁМ замере (см. поля `source` там основание и
* его границы), кадастровое число снято, а поправки §3 честно помечены как
* рыночная практика, а не наш замер. Плейсхолдеров marketing-v3 в статье
* больше нет но строка `articles/articles-content.ts` в `V3_SOURCES` гейта
* #2904 оставлена намеренно: вернётся плейсхолдер гейт его увидит.
*
* ЗАМЕР, НА КОТОРОМ СТОИТ ТЕКСТ. 6202 объявления квартир Домклик в
* Екатеринбурге, срез 18.07.2026, парно «цена публикации цена на дату
* среза». Две границы названы прямо в тексте, потому что без них число врёт:
* (1) это движение ЗАПРАШИВАЕМОЙ цены, а не скидка на сделке; (2) объявлений
* моложе трёх недель в выборке нет вовсе (прогон обогащения 18.07 их не
* захватил), а они снижают цену реже всех поэтому доля по всему живому
* рынку ниже примерно на 3 п.п. Цифра по долгостою от этой дыры не зависит.
* Дерево mera-public закрыто noindex (layout.tsx).
*/
import { SERVICE_PRICE_RUB } from "../content";
export interface ArticleCardMeta {
/**
* Ключ маршрута в `PUBLIC_ROUTES` или `null`, если статьи ещё нет.
* Именно ключ, а не слаг: карточка сама знает, куда ведёт, и добавление
* второй статьи не может увести её на первую (так и было, пока
* `to="articleHowTo"` стоял константой в JSX).
*/
readonly route: "articleHowTo" | null;
readonly rubric: string;
readonly title: string;
/** Подводка карточки — дословно из макета. */
readonly lead: string;
}
/** Карточки списка. slug=null — статьи ещё нет (карточка без ссылки). */
export const ARTICLE_CARDS: readonly ArticleCardMeta[] = [
{
route: "articleHowTo",
rubric: "ЦЕНООБРАЗОВАНИЕ · 6 МИН",
title: "Как оценить квартиру для продажи — без риелтора и «на глазок»",
lead: "Почему цена соседа — не ориентир, и как считать по аналогам.",
},
{
route: null,
rubric: "СРОКИ · 5 МИН",
title: "Сколько продаётся квартира в 2026 году и что «подвешивает» объявление",
lead: "Средние сроки по типам квартир и главные причины простоя.",
},
{
route: null,
rubric: "ТОРГ · 7 МИН",
title: "Завысить и торговаться или поставить в рынок: что выгоднее",
lead: "Считаем на данных, сколько теряет «висящая» квартира каждый месяц.",
},
];
export const HOWTO = {
rubric: "ЦЕНООБРАЗОВАНИЕ · 6 МИН ЧТЕНИЯ",
title: "Как оценить квартиру для продажи — без риелтора и «на глазок»",
lead: [
"Большинство продавцов назначают цену одним из трёх способов: смотрят объявление соседа, берут кадастровую стоимость или спрашивают знакомого риелтора. Все три дают ошибку в обе стороны — и обе стоят денег. Завышенная цена держит квартиру в продаже месяцами, заниженная уносит сотни тысяч сразу.",
"Правильный способ один: сравнение с реальными аналогами. Ниже — как сделать это самому за вечер.",
],
/**
* Оглавление. `id` тот же якорь, что стоит у заголовка секции ниже:
* страница рендерит `id` ИЗ ЭТОГО списка, а не из захардкоженных «s1s5»,
* иначе перестановка пункта молча увела бы ссылку не туда.
*/
toc: [
{ id: "s1", title: "Почему цена соседа — плохой ориентир" },
{ id: "s2", title: "Кто такие «аналоги» и как их отобрать" },
{ id: "s3", title: "Считаем цену за метр" },
{ id: "s4", title: "Проверяем цену сроком продажи" },
{ id: "s5", title: "Пять ошибок, которые стоят дороже всего" },
],
s1: {
id: "s1",
heading: "1. Почему цена соседа — плохой ориентир",
body: "В объявлении вы видите цену предложения, а не сделки. Между ними почти всегда есть разрыв: продавец заложил торг, а покупатель этот торг забрал. Насколько именно — не знает никто: цены сделок Росреестр публикует без привязки к объявлению, и сопоставить одно с другим нельзя. Зато отлично видно, как продавец сам двигает цену, пока квартира не продаётся. По объявлениям Екатеринбурга, провисевшим дольше трёх месяцев, цену снижали 62% продавцов — в медиане на 4,9% от той, с которой они начинали. В первый месяц продажи снижают только 7%.",
source:
"ЗАМЕР 18.07.2026 · 6202 ОБЪЯВЛЕНИЯ ДОМКЛИК В ЕКАТЕРИНБУРГЕ, ПРОВИСЕВШИЕ НЕ МЕНЬШЕ ТРЁХ НЕДЕЛЬ · ЭТО ДВИЖЕНИЕ ЦЕНЫ В ОБЪЯВЛЕНИИ, А НЕ СКИДКА НА СДЕЛКЕ",
memo: "Ориентироваться нужно на цены сделок, а не на цены объявлений. Объявление говорит, чего хочет продавец; сделка — что готов заплатить рынок.",
},
s2: {
id: "s2",
heading: "2. Кто такие «аналоги» и как их отобрать",
body: "Аналог — это квартира, за которую тот же покупатель рассматривал бы вашу. Не «такая же по площади», а взаимозаменяемая по совокупности параметров. Рабочий фильтр выглядит так:",
filter: [
{ k: "РАССТОЯНИЕ", v: "до 1 км от вашего дома — дальше начинается другой рынок" },
{ k: "ПЛОЩАДЬ", v: "±15% от вашей — «двушка 54 м²» и «двушка 68 м²» это разные товары" },
{ k: "КОМНАТЫ", v: "ровно столько же; студия и однушка не взаимозаменяемы" },
{ k: "ТИП ДОМА", v: "панель к панели, монолит к монолиту, год постройки ±10 лет" },
{ k: "ЭТАЖ", v: "не первый и не последний в отдельную группу — по ним скидка своя" },
],
after:
"Если под фильтр попадает меньше пяти квартир — расширяйте радиус, а не критерии. Сравнивать «двушку» с «трёшкой» бессмысленно, а соседний квартал обычно живёт по той же цене. МЕРА поступает так же: сначала перестаёт требовать близкий год постройки, потом расширяет радиус до 2 км, потом допускает площадь ±25% и соседнюю комнатность — и лишь в крайнем случае уходит на 3 и 5 км.",
footnote:
"ЭТО ТЕ ЖЕ ПОРОГИ, ЧТО СТОЯТ В ОЦЕНЩИКЕ МЕРЫ: РАДИУС 1 КМ, ПЛОЩАДЬ ±15%, МИНИМУМ ПЯТЬ АНАЛОГОВ",
},
s3: {
id: "s3",
heading: "3. Считаем цену за метр и приводим к своей квартире",
body: "Возьмите отобранные аналоги, посчитайте по каждому цену за квадратный метр и отбросьте два крайних значения — сверху и снизу. Медиана оставшихся и есть база вашей цены. Дальше добавьте поправки.",
factors: [
{ name: "Первый или последний этаж", value: "5…8%" },
{ name: "Требуется косметический ремонт", value: "4…7%" },
{ name: "Окна во двор, не на трассу", value: "+2…+4%" },
{ name: "Свежий ремонт «под ключ»", value: "+3…+6%" },
{ name: "Пешая доступность метро (до 10 мин)", value: "+5…+9%" },
{ name: "Свободная продажа, без ипотечных обременений", value: "+1…+3%" },
],
footnote:
"ОРИЕНТИРЫ РЫНОЧНОЙ ПРАКТИКИ, А НЕ ЗАМЕР МЕРЫ · В КОНКРЕТНОМ ДОМЕ ПОПРАВКА ОТЛИЧАЕТСЯ",
},
s4: {
id: "s4",
heading: "4. Проверяем цену сроком продажи",
body: "Цена без срока — половина ответа. Одна и та же квартира уйдёт за 30 дней по одной цене и за 90 — по другой. Проверить свою цену сроком можно и без нас: посмотрите, как часто продавцы вокруг сдаются и снижают её. Чем дольше объявление висит, тем чаще это происходит.",
tiles: [
{ value: "7%", label: "снижают цену в первый месяц продажи" },
{ value: "38%", label: "снижают на втором и третьем месяце" },
{
value: "62%",
label: "снижают, если квартира висит дольше трёх месяцев",
},
],
source:
"ТОТ ЖЕ ЗАМЕР · САМЫХ СВЕЖИХ ОБЪЯВЛЕНИЙ (МОЛОЖЕ ТРЁХ НЕДЕЛЬ) В ВЫБОРКЕ НЕТ, А ОНИ СНИЖАЮТ РЕЖЕ ВСЕХ — ПО ВСЕМУ ЖИВОМУ РЫНКУ ДОЛЯ БЫЛА БЫ НИЖЕ",
},
cta: {
eyebrow: "ЕСЛИ СЧИТАТЬ ВРУЧНУЮ НЕКОГДА",
title: "МЕРА делает всё это за 30 секунд",
body: `Мы находим аналоги, считаем медиану, применяем поправки и проверяем цену сроком продажи. Бесплатно покажем рынок вокруг, за ${SERVICE_PRICE_RUB} ₽ — вашу цену и прогноз.`,
},
s5: {
id: "s5",
heading: "5. Пять ошибок, которые стоят дороже всего",
mistakes: [
{
title: "Закладывать «запас на торг» в 10%",
body: "Покупатель не увидит вашу квартиру в фильтре по цене — торговаться будет некому.",
},
{
title: "Считать по кадастровой стоимости",
body: "Она нужна для налога, а не для рынка: её считают массовой моделью по площади, типу и году дома, не заходя в квартиру. С ценой конкретного объекта она связана слабо — и в обе стороны.",
},
{
title: "Добавлять к цене стоимость ремонта",
body: "Рынок оплачивает состояние, а не ваши чеки. Ремонт возвращается частично и не всегда.",
},
{
title: "Сравнивать с квартирой, которая уже полгода висит",
body: "Это не ориентир, а пример того, как делать не надо.",
},
{
title: "Не пересматривать цену через месяц",
body: "Если за 30 дней нет ни одного показа — цена выше рынка, и рынок вам об этом уже сказал.",
},
],
},
summary:
"Смотрите на цены сделок, а не объявлений. Берите 514 действительно взаимозаменяемых аналогов, считайте медиану цены за метр, применяйте поправки и обязательно проверяйте цену сроком продажи. Если считать некогда — это ровно то, что МЕРА делает автоматически.",
} as const;

View file

@ -0,0 +1,178 @@
import type { Metadata } from "next";
import { InnerHeader } from "../../_components/InnerHeader";
import { PublicLink } from "../../_components/PublicLink";
import { FooterV3 } from "../../_components/v3/FooterV3";
import styles from "../../landing-v3.module.css";
import { ARTICLE_CARDS, HOWTO } from "../articles-content";
/**
* Статья «Как оценить квартиру для продажи» макет 29.08 («МЕРА статья»),
* текст из макета целиком. Числа реквизит макета: см. шапку
* articles-content.ts; страница, как и всё дерево, под noindex из layout.
*
* Серверный компонент без клиентского JS: содержание якорные ссылки,
* секции обычные заголовки с id.
*/
export const metadata: Metadata = {
title: `${HOWTO.title}МЕРА`,
robots: { index: false, follow: false },
};
export default function HowToArticlePage() {
return (
<>
<InnerHeader />
<main id="content" className={styles.artPage} tabIndex={-1}>
<nav className={styles.artCrumbs} aria-label="Хлебные крошки">
<PublicLink to="home" className={styles.artCrumbLink}>
Главная
</PublicLink>
<span aria-hidden="true">/</span>
<PublicLink to="articles" className={styles.artCrumbLink}>
Статьи
</PublicLink>
</nav>
<header className={styles.artPageHead}>
<div className={styles.repEyebrow}>{HOWTO.rubric}</div>
<h1 className={styles.artPageTitle}>{HOWTO.title}</h1>
</header>
<nav className={styles.artToc} aria-label="Содержание">
<div className={styles.artTocLabel}>СОДЕРЖАНИЕ</div>
<ol className={styles.artTocList}>
{HOWTO.toc.map((t) => (
<li key={t.id}>
<a href={`#${t.id}`}>{t.title}</a>
</li>
))}
</ol>
</nav>
<div className={styles.artBody}>
{HOWTO.lead.map((p) => (
<p key={p.slice(0, 24)} className={styles.artP}>
{p}
</p>
))}
<h2 id={HOWTO.s1.id} className={styles.artH2}>
{HOWTO.s1.heading}
</h2>
<p className={styles.artP}>{HOWTO.s1.body}</p>
<div className={styles.artFootnote}>{HOWTO.s1.source}</div>
<aside className={styles.artMemo}>
<div className={styles.artMemoLabel}>ЗАПОМНИТЬ</div>
<p>{HOWTO.s1.memo}</p>
</aside>
<h2 id={HOWTO.s2.id} className={styles.artH2}>
{HOWTO.s2.heading}
</h2>
<p className={styles.artP}>{HOWTO.s2.body}</p>
<div className={styles.artKv}>
{HOWTO.s2.filter.map((row) => (
<div key={row.k} className={styles.artKvRow}>
<span className={styles.artKvKey}>{row.k}</span>
<span className={styles.artKvVal}>{row.v}</span>
</div>
))}
</div>
<div className={styles.artFootnote}>{HOWTO.s2.footnote}</div>
<p className={styles.artP}>{HOWTO.s2.after}</p>
<h2 id={HOWTO.s3.id} className={styles.artH2}>
{HOWTO.s3.heading}
</h2>
<p className={styles.artP}>{HOWTO.s3.body}</p>
<div className={styles.artTable} role="table">
<div className={styles.artTableHead} role="row">
<span role="columnheader">ФАКТОР</span>
<span role="columnheader" className={styles.artTableRight}>
ПОПРАВКА К ЦЕНЕ
</span>
</div>
{HOWTO.s3.factors.map((f) => (
<div key={f.name} className={styles.artTableRow} role="row">
<span role="cell">{f.name}</span>
<span role="cell" className={styles.artTableRight}>
{f.value}
</span>
</div>
))}
</div>
<div className={styles.artFootnote}>{HOWTO.s3.footnote}</div>
<h2 id={HOWTO.s4.id} className={styles.artH2}>
{HOWTO.s4.heading}
</h2>
<p className={styles.artP}>{HOWTO.s4.body}</p>
<div className={styles.artTiles}>
{HOWTO.s4.tiles.map((t) => (
<div key={t.label} className={styles.artTile}>
<div className={styles.artTileValue}>{t.value}</div>
<div className={styles.artTileLabel}>{t.label}</div>
</div>
))}
</div>
<div className={styles.artFootnote}>{HOWTO.s4.source}</div>
<div className={styles.artCta}>
<div className={styles.artCtaEyebrow}>{HOWTO.cta.eyebrow}</div>
<div className={styles.artCtaTitle}>{HOWTO.cta.title}</div>
<p className={styles.artCtaBody}>{HOWTO.cta.body}</p>
<PublicLink to="estimate" className={styles.gameBtnAccent}>
Проверить за 30 секунд
</PublicLink>
</div>
<h2 id={HOWTO.s5.id} className={styles.artH2}>
{HOWTO.s5.heading}
</h2>
<ol className={styles.artMistakes}>
{HOWTO.s5.mistakes.map((m, i) => (
<li key={m.title} className={styles.artMistake}>
<span className={styles.artMistakeNum}>
{String(i + 1).padStart(2, "0")}
</span>
<div>
<div className={styles.artMistakeTitle}>{m.title}</div>
<div className={styles.artMistakeBody}>{m.body}</div>
</div>
</li>
))}
</ol>
<div className={styles.artSummary}>
<div className={styles.artTocLabel}>КОРОТКО</div>
<p>{HOWTO.summary}</p>
</div>
</div>
<section className={styles.artMore} aria-label="Читайте дальше">
<div className={styles.artHead}>
<h2 className={styles.artTitle}>Читайте дальше</h2>
<PublicLink to="articles" className={styles.artAllLink}>
Все статьи
</PublicLink>
</div>
<div className={styles.artGrid}>
{ARTICLE_CARDS.filter((c) => c.route !== "articleHowTo").map(
(c) => (
<article key={c.title} className={styles.artCard}>
<div className={styles.artRubric}>{c.rubric}</div>
<div className={styles.artCardTitle}>{c.title}</div>
<div className={styles.artCardLead}>{c.lead}</div>
<div className={styles.artSoon}>СКОРО</div>
</article>
),
)}
</div>
</section>
</main>
<FooterV3 />
</>
);
}

View file

@ -0,0 +1,98 @@
import type { Metadata } from "next";
import { InnerHeader } from "../_components/InnerHeader";
import { PublicLink } from "../_components/PublicLink";
import { FooterV3 } from "../_components/v3/FooterV3";
import { SERVICE_PRICE_RUB } from "../content";
import styles from "../landing-v3.module.css";
import { ARTICLE_CARDS } from "./articles-content";
/**
* Список статей макет 29.08 («МЕРА статьи»).
*
* ОТЛИЧИЯ ОТ МАКЕТА И ПОЧЕМУ:
* - В макете девять карточек и подпись «показано 9 из 9 · новые статьи
* каждую неделю». Написана одна. Остальные две (их рубрики и подводки взяты
* из макета) стоят карточками без ссылки с меткой «СКОРО», а обещания
* еженедельного выхода нет: расписания публикаций никто не устанавливал.
* - Фильтр по рубрикам («Все · Ценообразование · Сроки · Торг · Процесс») не
* рендерится: на трёх карточках фильтровать нечего, а неработающий контрол
* хуже отсутствующего.
* - Шапка короткая (`InnerHeader`), а не лэндинговая, как в макете:
* лэндинговая содержит якоря на секции (`#accuracy`, `#game`), которых на
* этой странице нет, и клик по ним никуда бы не привёл.
*/
export const metadata: Metadata = {
title: "Статьи — МЕРА · разборы на данных",
robots: { index: false, follow: false },
};
export default function ArticlesPage() {
return (
<>
<InnerHeader />
<main id="content" className={styles.artPage} tabIndex={-1}>
<nav className={styles.artCrumbs} aria-label="Хлебные крошки">
<PublicLink to="home" className={styles.artCrumbLink}>
Главная
</PublicLink>
<span aria-hidden="true">/</span>
<span aria-current="page">Статьи</span>
</nav>
<header className={styles.artPageHead}>
<div className={styles.repEyebrow}>СТАТЬИ И ГАЙДЫ</div>
<h1 className={styles.artPageTitle}>
Продажа квартиры: разборы на данных, а не на мнениях
</h1>
<p className={styles.artPageLead}>
Как отбирать аналоги, считать цену за метр и проверять её сроком
продажи. Каждый разбор заканчивается практическим шагом
бесплатной проверкой вашей квартиры.
</p>
</header>
<div className={styles.artGrid}>
{ARTICLE_CARDS.map((c) =>
c.route ? (
<PublicLink
key={c.title}
to={c.route}
className={styles.artCardLink}
>
<div className={styles.artRubric}>{c.rubric}</div>
<div className={styles.artCardTitle}>{c.title}</div>
<div className={styles.artCardLead}>{c.lead}</div>
<div className={styles.artReadMore}>Читать </div>
</PublicLink>
) : (
<article key={c.title} className={styles.artCard}>
<div className={styles.artRubric}>{c.rubric}</div>
<div className={styles.artCardTitle}>{c.title}</div>
<div className={styles.artCardLead}>{c.lead}</div>
<div className={styles.artSoon}>СКОРО</div>
</article>
),
)}
</div>
<div className={styles.artListCta}>
<div>
<div className={styles.artCtaTitle}>
Хватит читать проверьте свою квартиру
</div>
<p className={styles.artCtaBody}>
Бесплатно: сколько похожих квартир продаётся рядом и насколько
свежие это объявления. Полный отчёт {SERVICE_PRICE_RUB} .
</p>
</div>
<PublicLink to="estimate" className={styles.gameBtnAccent}>
Проверить за 30 секунд
</PublicLink>
</div>
</main>
<FooterV3 />
</>
);
}

View file

@ -191,6 +191,10 @@ export const PUBLIC_ROUTES = {
oferta: "/oferta",
refund: "/refund",
privacy: "/privacy",
// Макет 29.08: раздел статей и хаб документов.
articles: "/articles",
articleHowTo: "/articles/kak-ocenit-kvartiru",
docs: "/docs",
} as const;
/** Канонический публичный origin сервиса — база для canonical-ссылок. */

View file

@ -0,0 +1,191 @@
import type { Metadata } from "next";
import { safeUrl } from "@/lib/safeUrl";
import { InnerHeader } from "../_components/InnerHeader";
import { PublicLink } from "../_components/PublicLink";
import { FooterV3 } from "../_components/v3/FooterV3";
import {
LEGAL_ENTITY,
SERVICE_PRICE_RUB,
SUPPORT_EMAIL,
SUPPORT_TELEGRAM_LABEL,
SUPPORT_TELEGRAM_URL,
} from "../content";
import styles from "../landing-v3.module.css";
/**
* Хаб «Документы и условия» макет 29.08 («МЕРА документы»).
*
* ЧЕСТНЫЕ АДАПТАЦИИ:
* - Юридические тексты НЕ дублируются и не переписываются макетной редакцией:
* источник правды существующие /oferta, /privacy, /refund (опубликованы
* юридической волной 13.08). Хаб только ведёт к ним.
* - Реквизиты из макета (ИНН 66700XXXXX, [АДРЕС], mera.example) маска, а не
* данные; до появления настоящих не публикуются то же решение, что в
* FooterV3.
* - Тарифная строка «Продажа под ключ» повторяет ПУТЬ 2 лендинга
* (`TwoPathsV3`) и ничего сверх него не обещает: формы заявки в продукте
* нет, единственный живой канал поддержка в Telegram. Цена «по
* договору», а не число: тарифа под ключ в коде не существует.
* - Часов работы поддержки страница НЕ называет. В макете стояло «по будням
* 9:00-19:00», но регламента ответа никто не устанавливал, а срок ответа на
* странице документов читается как обязательство.
* - Цена расчёта берётся из `SERVICE_PRICE_RUB` того же литерала, что
* назван в оферте (п. 4.1) и политике возврата. Второй раз числом её здесь
* писать нельзя: тогда цена в документах и цена в тарифах смогут разойтись.
*
* ЧЕГО ЭТА СТРАНИЦА НЕ ДОКАЗЫВАЕТ. «Разовый платёж» и «никаких подписок и
* автосписаний» описывают порядок оплаты из оферты, а не работающий контур:
* приём платежей не подключён (`PUBLIC_ESTIMATE_ENABLED = false`, таблицы
* `payments`/`payment_entitlements` на проде пусты, см. TODO в FooterV3).
* Пока дерево под noindex, расхождения «написано против доступно» никто не
* увидит; открывать страницу наружу можно только вместе с платёжным контуром
* (#2896) либо сняв тарифную таблицу.
*/
export const metadata: Metadata = {
title: "Документы и условия — МЕРА",
robots: { index: false, follow: false },
};
const TARIFFS: readonly {
readonly name: string;
readonly note: string;
readonly price: string;
}[] = [
{
name: "Бесплатная проверка",
note: "Число аналогов рядом и возраст объявлений по дому",
price: "0 ₽",
},
{
name: "Полный отчёт",
note: "Цена, прогноз срока, диапазон, аналоги, факторы. Разовый платёж",
price: `${SERVICE_PRICE_RUB}`,
},
{
name: "Продажа под ключ",
note: "Объявление, фото, показы, переговоры, сделка — если вести продажу самому некогда. Условия обсуждаются в поддержке",
price: "по договору",
},
] as const;
export default function DocsPage() {
const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL);
return (
<>
<InnerHeader />
<main id="content" className={styles.artPage} tabIndex={-1}>
<nav className={styles.artCrumbs} aria-label="Хлебные крошки">
<PublicLink to="home" className={styles.artCrumbLink}>
Главная
</PublicLink>
<span aria-hidden="true">/</span>
<span aria-current="page">Документы</span>
</nav>
<header className={styles.artPageHead}>
<div className={styles.repEyebrow}>ДОКУМЕНТЫ</div>
<h1 className={styles.artPageTitle}>Документы и условия</h1>
</header>
<section className={styles.docsSection} aria-labelledby="docs-legal">
<h2 id="docs-legal" className={styles.docsH2}>
Юридические документы
</h2>
<div className={styles.docsLinks}>
<PublicLink to="oferta" className={styles.docsLinkCard}>
<span className={styles.docsLinkTitle}>Публичная оферта</span>
<span className={styles.docsLinkNote}>
Договор оказания информационных услуг
</span>
</PublicLink>
<PublicLink to="privacy" className={styles.docsLinkCard}>
<span className={styles.docsLinkTitle}>Персональные данные</span>
<span className={styles.docsLinkNote}>
Политика обработки и ваши права по 152-ФЗ
</span>
</PublicLink>
<PublicLink to="refund" className={styles.docsLinkCard}>
<span className={styles.docsLinkTitle}>Возврат оплаты</span>
<span className={styles.docsLinkNote}>
Когда и как возвращаются {SERVICE_PRICE_RUB}
</span>
</PublicLink>
</div>
</section>
<section className={styles.docsSection} aria-labelledby="docs-tariffs">
<h2 id="docs-tariffs" className={styles.docsH2}>
Тарифы
</h2>
<p className={styles.artP}>
Никаких подписок и автосписаний. Все цены окончательные.
</p>
<div className={styles.artTable} role="table">
<div className={styles.artTableHead} role="row">
<span role="columnheader">УСЛУГА</span>
<span role="columnheader" className={styles.artTableRight}>
ЦЕНА
</span>
</div>
{TARIFFS.map((t) => (
<div key={t.name} className={styles.artTableRow} role="row">
<span role="cell">
<span className={styles.docsTariffName}>{t.name}</span>
<span className={styles.docsTariffNote}>{t.note}</span>
</span>
<span role="cell" className={styles.artTableRight}>
{t.price}
</span>
</div>
))}
</div>
</section>
<section className={styles.docsSection} aria-labelledby="docs-reqs">
<h2 id="docs-reqs" className={styles.docsH2}>
Контакты и реквизиты
</h2>
{LEGAL_ENTITY ? (
<div className={styles.artKv}>
<div className={styles.artKvRow}>
<span className={styles.artKvKey}>ОРГАНИЗАЦИЯ</span>
<span className={styles.artKvVal}>{LEGAL_ENTITY.name}</span>
</div>
<div className={styles.artKvRow}>
<span className={styles.artKvKey}>ИНН · ОГРН</span>
<span className={styles.artKvVal}>
{LEGAL_ENTITY.inn} · {LEGAL_ENTITY.ogrn}
</span>
</div>
<div className={styles.artKvRow}>
<span className={styles.artKvKey}>АДРЕС</span>
<span className={styles.artKvVal}>{LEGAL_ENTITY.address}</span>
</div>
<div className={styles.artKvRow}>
<span className={styles.artKvKey}>ПОЧТА</span>
<span className={styles.artKvVal}>
<a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>
</span>
</div>
</div>
) : null}
{telegramHref ? (
<p className={styles.artP}>
Поддержка в Telegram:{" "}
<a href={telegramHref} target="_blank" rel="noreferrer">
{SUPPORT_TELEGRAM_LABEL}
</a>
. Это же единственный канал для вопросов по возврату и по продаже
под ключ.
</p>
) : null}
</section>
</main>
<FooterV3 />
</>
);
}

View file

@ -1,7 +1,7 @@
import type { Metadata } from "next";
import { EstimateFlow } from "../_components/estimate/EstimateFlow";
import { EstimateHeader } from "../_components/estimate/EstimateHeader";
import { InnerHeader } from "../_components/InnerHeader";
import { FooterV3 } from "../_components/v3/FooterV3";
import { PUBLIC_ESTIMATE_URL } from "../content";
import styles from "../landing-v3.module.css";
@ -35,7 +35,7 @@ export const metadata: Metadata = {
export default function MeraEstimatePage() {
return (
<>
<EstimateHeader />
<InnerHeader />
<main id="content" className={styles.estSection} tabIndex={-1}>
<div className={styles.estContainer}>
<p className={styles.heroResultEyebrow}>БЕСПЛАТНАЯ ПРОВЕРКА</p>

View file

@ -56,6 +56,10 @@
align-items: center;
gap: 12px;
color: var(--b2c-ink);
/* На лэндинге логотип <div>, на внутренних страницах (InnerHeader)
ссылка на главную; без сброса подчёркивание браузера рисовалось прямо
по слову «МЕРА». */
text-decoration: none;
}
.hdrLogoIcon {
@ -1812,6 +1816,9 @@
text-align: center;
cursor: pointer;
box-sizing: border-box;
/* На лэндинге это <button>, в статье и списке статей ссылка на /estimate:
без сброса браузер подчёркивает подпись прямо по кнопке. */
text-decoration: none;
}
.gameBtnAccent:hover {
background: var(--b2c-ink);
@ -2410,3 +2417,418 @@
line-height: 1.55;
color: #9fb0b4;
}
/* --- Раздел статей и хаб документов (макет 29.08) ------------------------- */
.artPage {
max-width: 900px;
margin: 0 auto;
padding: clamp(32px, 5vw, 56px) clamp(20px, 5vw, 56px) clamp(56px, 7vw, 88px);
}
.artPageHead {
margin-bottom: clamp(28px, 4vw, 40px);
}
.artPageTitle {
margin: 0;
font-size: clamp(28px, 4.2vw, 44px);
line-height: 1.1;
letter-spacing: -0.02em;
font-weight: 800;
text-wrap: balance;
}
.artPageLead {
margin: 16px 0 0;
font-size: 16.5px;
line-height: 1.6;
color: var(--b2c-muted);
max-width: 640px;
text-wrap: pretty;
}
.artAllLink {
font-weight: 700;
font-size: 14.5px;
white-space: nowrap;
color: var(--b2c-accent-text);
text-decoration: none;
border-bottom: 1px solid transparent;
}
.artAllLink:hover {
border-bottom-color: var(--b2c-accent-text);
}
.artCardLink {
composes: artCard;
display: block;
color: var(--b2c-ink);
text-decoration: none;
}
.artCardLink:hover {
border-color: var(--b2c-accent);
color: var(--b2c-ink);
}
.artSoon {
margin-top: 14px;
font-family: var(--b2c-font-mono);
font-size: 9.5px;
letter-spacing: 0.16em;
color: #9fb0b4;
}
.artToc {
margin-bottom: clamp(24px, 3vw, 32px);
padding: 18px 22px;
background: var(--b2c-surface);
border: 1px solid var(--b2c-line);
border-radius: 10px;
}
.artTocLabel {
margin-bottom: 12px;
font-family: var(--b2c-font-mono);
font-size: 10px;
letter-spacing: 0.16em;
color: var(--b2c-muted);
}
.artTocList a {
color: inherit;
text-decoration: none;
border-bottom: 1px solid var(--b2c-line);
}
.artTocList a:hover {
color: var(--b2c-accent-text);
border-bottom-color: var(--b2c-accent-text);
}
.artTocList {
margin: 0;
padding: 0 0 0 20px;
display: flex;
flex-direction: column;
gap: 7px;
font-size: 14.5px;
font-weight: 600;
}
.artBody {
max-width: 760px;
}
.artP {
margin: 0 0 16px;
font-size: 16px;
line-height: 1.7;
color: #2c3a3f;
text-wrap: pretty;
}
.artH2 {
margin: clamp(30px, 4vw, 42px) 0 14px;
font-size: clamp(21px, 2.6vw, 27px);
line-height: 1.2;
letter-spacing: -0.015em;
font-weight: 800;
scroll-margin-top: 90px;
}
.artMemo {
margin: 20px 0;
padding: 18px 22px;
border-left: 3px solid var(--b2c-accent);
background: var(--b2c-surface);
border-radius: 0 10px 10px 0;
}
.artMemoLabel {
margin-bottom: 8px;
font-family: var(--b2c-font-mono);
font-size: 10px;
letter-spacing: 0.16em;
color: var(--b2c-accent-text);
}
.artMemo p {
margin: 0;
font-weight: 700;
font-size: 15.5px;
line-height: 1.6;
}
.artKv {
display: flex;
flex-direction: column;
gap: 8px;
margin: 18px 0;
}
.artKvRow {
display: flex;
gap: 18px;
align-items: baseline;
padding: 13px 18px;
background: var(--b2c-surface);
border: 1px solid var(--b2c-line);
border-radius: 8px;
}
.artKvKey {
flex: none;
width: 110px;
font-family: var(--b2c-font-mono);
font-size: 10px;
letter-spacing: 0.12em;
color: var(--b2c-accent-text);
}
.artKvVal {
font-size: 14.5px;
line-height: 1.5;
}
.artTable {
margin: 18px 0 10px;
border: 1px solid var(--b2c-line);
border-radius: 10px;
overflow: hidden;
}
.artTableHead {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 12px 20px;
background: var(--b2c-surface-soft);
font-family: var(--b2c-font-mono);
font-size: 9.5px;
letter-spacing: 0.12em;
color: var(--b2c-muted);
}
.artTableRow {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 14px 20px;
border-top: 1px solid var(--b2c-line-soft);
background: var(--b2c-surface);
font-size: 14.5px;
align-items: baseline;
}
.artTableRight {
flex: none;
font-family: var(--b2c-font-mono);
font-size: 13px;
font-weight: 500;
}
.artTableRow .artTableRight {
color: var(--b2c-accent-text);
}
.artTableHead .artTableRight {
color: inherit;
}
.artFootnote {
margin-bottom: 8px;
font-family: var(--b2c-font-mono);
font-size: 10px;
letter-spacing: 0.08em;
color: #9fb0b4;
}
.artTiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr));
gap: 14px;
margin: 18px 0;
}
.artTile {
padding: 20px 22px;
background: var(--b2c-surface);
border: 1px solid var(--b2c-line);
border-radius: 10px;
}
.artTileValue {
font-size: clamp(24px, 2.8vw, 30px);
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1;
}
.artTileLabel {
margin-top: 8px;
font-size: 13px;
line-height: 1.5;
color: var(--b2c-muted);
}
.artCta {
margin: clamp(26px, 4vw, 38px) 0;
padding: clamp(24px, 3.5vw, 36px);
background: var(--b2c-surface-dark);
border-radius: 14px;
color: #ffffff;
}
.artCtaEyebrow {
margin-bottom: 12px;
font-family: var(--b2c-font-mono);
font-size: 10px;
letter-spacing: 0.16em;
color: var(--b2c-accent);
}
.artCtaTitle {
margin-bottom: 10px;
font-weight: 800;
font-size: clamp(20px, 2.4vw, 25px);
}
.artCtaBody {
margin: 0 0 20px;
font-size: 15px;
line-height: 1.6;
color: #9fb0b4;
max-width: 520px;
}
.artCta .gameBtnAccent {
display: inline-block;
width: auto;
}
.artMistakes {
margin: 18px 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.artMistake {
display: flex;
gap: 16px;
padding: 16px 20px;
background: var(--b2c-surface);
border: 1px solid var(--b2c-line);
border-radius: 10px;
}
.artMistakeNum {
flex: none;
font-family: var(--b2c-font-mono);
font-size: 11px;
color: var(--b2c-accent-text);
padding-top: 4px;
}
.artMistakeTitle {
font-weight: 800;
font-size: 15.5px;
margin-bottom: 4px;
}
.artMistakeBody {
font-size: 14px;
line-height: 1.55;
color: var(--b2c-muted);
}
.artSummary {
margin-top: clamp(26px, 4vw, 38px);
padding-top: 22px;
border-top: 1px solid var(--b2c-line);
}
.artSummary p {
margin: 0;
font-size: 15.5px;
line-height: 1.65;
}
.artMore {
margin-top: clamp(40px, 6vw, 64px);
}
.artListCta {
margin-top: clamp(32px, 5vw, 48px);
padding: clamp(24px, 3.5vw, 36px);
/* Тёмная плашка как в макете: это последний экран списка, и он обязан
читаться как призыв, а не как ещё одна карточка статьи. */
background: var(--b2c-surface-dark);
border-radius: 14px;
color: #ffffff;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
flex-wrap: wrap;
}
.artListCta .artCtaBody {
margin: 10px 0 0;
max-width: 460px;
}
.artCrumbs {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 18px;
font-size: 13px;
color: var(--b2c-muted);
}
.artCrumbLink {
color: var(--b2c-muted);
text-decoration: none;
}
.artCrumbLink:hover {
color: var(--b2c-accent-text);
}
.artCardLead {
margin-top: 10px;
font-size: 13.5px;
line-height: 1.55;
color: var(--b2c-muted);
}
.artReadMore {
margin-top: 14px;
font-weight: 700;
font-size: 13.5px;
color: var(--b2c-accent-text);
}
.artListCta .gameBtnAccent {
display: inline-block;
width: auto;
}
.docsSection {
margin-bottom: clamp(30px, 4vw, 44px);
}
.docsH2 {
margin: 0 0 14px;
font-size: clamp(19px, 2.3vw, 24px);
letter-spacing: -0.01em;
font-weight: 800;
}
.docsLinks {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
gap: 14px;
}
.docsLinkCard {
display: flex;
flex-direction: column;
gap: 6px;
padding: 20px 22px;
background: var(--b2c-surface);
border: 1px solid var(--b2c-line);
border-radius: 10px;
color: var(--b2c-ink);
text-decoration: none;
}
.docsLinkCard:hover {
border-color: var(--b2c-accent);
color: var(--b2c-ink);
}
.docsLinkTitle {
font-weight: 800;
font-size: 16px;
}
.docsLinkNote {
font-size: 13.5px;
color: var(--b2c-muted);
}
.docsTariffName {
display: block;
font-weight: 700;
}
.docsTariffNote {
display: block;
margin-top: 3px;
font-size: 13px;
color: var(--b2c-muted);
}
/* Узкий экран: ключ пары перестаёт быть колонкой фиксированной ширины
иначе на 320px значению остаётся ~170px и «58%» рвётся переносом. */
@media (max-width: 480px) {
.artKvRow {
flex-direction: column;
gap: 6px;
}
.artKvKey {
width: auto;
}
.artTableRow,
.artTableHead {
gap: 10px;
}
.artListCta {
display: block;
}
.artListCta .gameBtnAccent {
margin-top: 18px;
}
}

View file

@ -82,7 +82,17 @@ const PUBLIC_PATHS = ["/mera-public"];
* Совпадение теперь запинено тестом (`__tests__/RouteGuard.publicPaths.test.ts`
* читает Caddyfile), потому что комментарий этот инцидент уже не предотвратил.
*/
const PUBLIC_SHORT_PATHS = ["/", "/estimate", "/oferta", "/refund", "/privacy", "/v3"];
const PUBLIC_SHORT_PATHS = [
"/",
"/estimate",
"/oferta",
"/refund",
"/privacy",
"/v3",
"/articles",
"/articles/kak-ocenit-kvartiru",
"/docs",
];
export function isPublicPath(rawPath: string): boolean {
// Точное совпадение — ОТДЕЛЬНОЙ веткой и первым делом. Префиксную проверку